388 lines
18 KiB
HTML
388 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>出图 · ai-image</title>
|
||
<link rel="stylesheet" href="styles.css" />
|
||
<script src="api.js"></script>
|
||
<script src="components.js"></script>
|
||
</head>
|
||
<body data-page="create">
|
||
|
||
<aside class="app-nav">
|
||
<div class="app-brand">
|
||
<span class="brand-mark" aria-hidden="true"></span>
|
||
<span class="brand-text"><b>ai-image</b><small>绘图聚合控制台</small></span>
|
||
</div>
|
||
<nav class="nav-list">
|
||
<p class="nav-group">工作台</p>
|
||
<a class="nav-item" data-nav="create" href="create.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3-1.9 5.8L4 10.7l6.1 1.9L12 18.4l1.9-5.8 6.1-1.9-6.1-1.9Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg></span>出图</a>
|
||
<a class="nav-item" data-nav="gallery" href="index.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg></span>画廊</a>
|
||
<a class="nav-item" data-nav="records" href="records.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg></span>生成记录</a>
|
||
<p class="nav-group">配置</p>
|
||
<a class="nav-item" data-nav="providers" href="providers.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v3a6 6 0 0 1-12 0V8Z"/></svg></span>供应商</a>
|
||
<a class="nav-item" data-nav="models" href="models.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z"/><path d="m7 16.5-4.74-2.85"/><path d="m7 16.5 5-3"/><path d="M7 16.5v5.17"/><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z"/><path d="m17 16.5-5-3"/><path d="m17 16.5 4.74-2.85"/><path d="M17 16.5v5.17"/><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z"/><path d="M12 8 7.26 5.15"/><path d="m12 8 4.74-2.85"/><path d="M12 13.5V8"/></svg></span>模型</a>
|
||
<p class="nav-group">Agent</p>
|
||
<a class="nav-item" data-nav="skills" href="skills.html"><span class="nav-ico"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg></span>SKILL.md</a>
|
||
</nav>
|
||
<div class="nav-foot">
|
||
<span class="dot dot-ok"></span>服务在线 <span class="mono">:8765</span>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="app-content">
|
||
<header class="top-bar">
|
||
<div class="crumb">
|
||
<span class="crumb-sec">工作台</span>
|
||
<span class="crumb-sep">/</span>
|
||
<h1 id="pageTitle">出图</h1>
|
||
</div>
|
||
<div class="top-actions">
|
||
<span class="tag tag-info mono">output/user/</span>
|
||
<a class="btn btn-secondary btn-sm" href="index.html">查看画廊</a>
|
||
<a class="btn btn-sm" href="providers.html">接入供应商</a>
|
||
</div>
|
||
</header>
|
||
<section class="page-body">
|
||
|
||
<div class="split">
|
||
<!-- ==================== 左:出图表单 ==================== -->
|
||
<div>
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h3>生成图片</h3>
|
||
<div class="sub">网页端出图固定写入 <code>output/user/</code>,目录不存在时自动创建</div>
|
||
</div>
|
||
<div class="top-actions">
|
||
<span class="tag" id="profileTag">profile_name = user</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="field" id="fieldPrompt">
|
||
<label>提示词 <span class="req">*</span></label>
|
||
<textarea id="prompt" rows="5" placeholder="描述你想要的画面,例如:a luminous floating city above a misty canyon at sunrise, cinematic realism, volumetric light"></textarea>
|
||
<div class="err-msg">请填写提示词</div>
|
||
<div class="hint">建议包含主体 + 风格 + 光线 + 画幅关键词,中英文均可</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2">
|
||
<div class="field" id="fieldProvider">
|
||
<label>供应商 <span class="req">*</span></label>
|
||
<select id="provider"></select>
|
||
<div class="err-msg">请选择供应商</div>
|
||
</div>
|
||
<div class="field" id="fieldModel">
|
||
<label>模型 <span class="req">*</span></label>
|
||
<select id="model"></select>
|
||
<div class="err-msg">请选择模型</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-2">
|
||
<div class="field">
|
||
<label>尺寸</label>
|
||
<select id="size">
|
||
<option value="1024x1024">1024x1024(1:1)</option>
|
||
<option value="2048x2048">2048x2048(1:1 · 高清)</option>
|
||
<option value="1312x736">1312x736(16:9)</option>
|
||
<option value="1152x864">1152x864(4:3)</option>
|
||
<option value="2048x1152">2048x1152(16:9 · 高清)</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>数量</label>
|
||
<select id="count">
|
||
<option value="1">1 张</option>
|
||
<option value="2">2 张</option>
|
||
<option value="4">4 张</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toolbar" style="margin-bottom:0">
|
||
<button class="btn btn-lg" id="generateBtn"><span class="ico" data-icon="sparkles"></span>开始生成</button>
|
||
<button class="btn btn-secondary btn-sm" id="discoverBtn"><span class="ico" data-icon="refresh"></span>拉取上游模型</button>
|
||
<span class="spacer"></span>
|
||
<span class="dim mono" id="modelCount">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-head">
|
||
<div>
|
||
<h3>本次结果</h3>
|
||
<div class="sub">生成成功后可在下方查看,并已自动加入 <code>output/user/</code> 画廊</div>
|
||
</div>
|
||
<div class="top-actions">
|
||
<span class="tag tag-success" id="resultStatus">尚未生成</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="gal-grid" id="resultGrid"></div>
|
||
<div class="empty" id="resultEmpty">
|
||
<span class="ico" data-icon="image"></span>
|
||
<h4>还没有生成结果</h4>
|
||
<p>填写提示词并选择可用模型后点击「开始生成」。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== 右:说明与最近产出 ==================== -->
|
||
<div>
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h3>落盘规则</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="code-box"><code>output/
|
||
└── user/ ← 网页端手动出图
|
||
├── gpt-image-2-1K-0014ef2b.png
|
||
└── ...</code></div>
|
||
<ul class="md" style="padding-left:18px;margin:12px 0 0">
|
||
<li>output/ 下的<b>文件夹名 = 谁生成的</b>,网页端固定为 <code>user</code></li>
|
||
<li>Agent 调用时由调用方指定 <code>profile_name</code></li>
|
||
<li>目录不存在时后端自动创建</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-head">
|
||
<h3>可用模型</h3>
|
||
<div class="top-actions">
|
||
<span class="tag tag-info" id="modelSource">来自实时拉取</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="model-list" id="modelList"></div>
|
||
<div class="empty hidden" id="modelEmpty">
|
||
<span class="ico" data-icon="alert"></span>
|
||
<h4>没找到有效的模型</h4>
|
||
<p>请先接入供应商并拉取上游模型清单。</p>
|
||
<a class="btn btn-secondary btn-sm" href="providers.html">接入供应商</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-head">
|
||
<h3>user 画廊最近产出</h3>
|
||
<div class="top-actions">
|
||
<a class="btn btn-ghost btn-sm" href="index.html">全部</a>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="gal-grid" id="recentGrid"></div>
|
||
<div class="empty hidden" id="recentEmpty">
|
||
<span class="ico" data-icon="images"></span>
|
||
<h4>output/user/ 还是空的</h4>
|
||
<p>生成一张图试试。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
(function () {
|
||
const $ = (s) => document.querySelector(s);
|
||
let providers = [];
|
||
let models = [];
|
||
let recent = [];
|
||
|
||
|
||
function setError(fieldSel, on, msg) {
|
||
const f = $(fieldSel);
|
||
f.classList.toggle("has-error", on);
|
||
if (msg) f.querySelector(".err-msg").textContent = msg;
|
||
}
|
||
|
||
/* ---------------------------------------------------- 供应商 / 模型 */
|
||
function enabledModels(providerId) {
|
||
return models.filter(
|
||
(m) => m.enabled && m.kind === "image" && (!providerId || m.provider_id === providerId)
|
||
&& providers.some((p) => p.id === m.provider_id && p.enabled)
|
||
);
|
||
}
|
||
|
||
function renderProviderOptions() {
|
||
const sel = $("#provider");
|
||
sel.innerHTML = providers
|
||
.map((p) => '<option value="' + esc(p.id) + '"' + (p.enabled ? "" : " disabled") + ">" +
|
||
esc(p.name) + (p.enabled ? "" : "(已停用)") + "</option>")
|
||
.join("");
|
||
if (!providers.length) sel.innerHTML = '<option value="">(无可用供应商)</option>';
|
||
const first = providers.find((p) => p.enabled);
|
||
sel.value = first ? first.id : "";
|
||
}
|
||
|
||
function renderModelOptions() {
|
||
const pid = $("#provider").value;
|
||
const list = enabledModels(pid);
|
||
const sel = $("#model");
|
||
sel.innerHTML = list.length
|
||
? list
|
||
.map((m) => '<option value="' + esc(m.model_id) + '">' + esc(m.display_name || m.model_id) + " · " + esc(m.model_id) + "</option>")
|
||
.join("")
|
||
: '<option value="">(该供应商暂无可用模型)</option>';
|
||
$("#modelCount").textContent = list.length + " 个可用模型";
|
||
const preferred = providers.find((p) => p.id === pid)?.default_model;
|
||
if (list.some((m) => m.model_id === preferred)) sel.value = preferred;
|
||
}
|
||
|
||
function renderModelList() {
|
||
const list = enabledModels();
|
||
const box = $("#modelList");
|
||
box.innerHTML = "";
|
||
$("#modelEmpty").classList.toggle("hidden", list.length !== 0);
|
||
list.forEach((m) => {
|
||
const row = document.createElement("div");
|
||
row.className = "model-row";
|
||
const p = providers.find((x) => x.id === m.provider_id);
|
||
row.innerHTML =
|
||
'<div style="flex:1;min-width:0">' +
|
||
'<div class="mono" style="font-size:12px">' + esc(m.model_id) + "</div>" +
|
||
'<div class="hint">' + esc(p ? p.name : m.provider_id) + " · " + esc(m.size_hint || "—") + "</div>" +
|
||
"</div>" +
|
||
'<span class="tag tag-success">可用</span>';
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function loadProviders() {
|
||
providers = await window.API.listProviders();
|
||
renderProviderOptions();
|
||
renderModelOptions();
|
||
}
|
||
|
||
async function loadModels() {
|
||
models = await window.API.listModels();
|
||
renderModelOptions();
|
||
renderModelList();
|
||
}
|
||
|
||
/* ------------------------------------------------------ 拉取上游模型 */
|
||
$("#discoverBtn").addEventListener("click", async () => {
|
||
const pid = $("#provider").value;
|
||
if (!pid) {
|
||
toast("请先选择供应商", "err");
|
||
return;
|
||
}
|
||
const btn = $("#discoverBtn");
|
||
if (btn.disabled) return;
|
||
btn.disabled = true;
|
||
btn.classList.add("is-loading");
|
||
try {
|
||
const res = await window.API.discoverProviderModels(pid);
|
||
await loadModels();
|
||
if (!res.matched || !res.matched.length) {
|
||
toast("没找到有效的模型", "err");
|
||
$("#modelSource").textContent = "未命中模型";
|
||
return;
|
||
}
|
||
$("#modelSource").textContent = "实时拉取 · " + res.matched.length + " 个";
|
||
toast("拉取到 " + res.matched.length + " 个可用模型");
|
||
} catch (e) {
|
||
toast(e.message, "err");
|
||
} finally {
|
||
btn.classList.remove("is-loading");
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
/* ---------------------------------------------------------- 生成 */
|
||
$("#generateBtn").addEventListener("click", async () => {
|
||
const prompt = $("#prompt").value.trim();
|
||
const provider_id = $("#provider").value;
|
||
const model = $("#model").value;
|
||
const size = $("#size").value;
|
||
const n = parseInt($("#count").value, 10);
|
||
|
||
setError("#fieldPrompt", !prompt);
|
||
setError("#fieldProvider", !provider_id);
|
||
setError("#fieldModel", !model, "请选择模型");
|
||
if (!prompt || !provider_id || !model) return;
|
||
|
||
const btn = $("#generateBtn");
|
||
if (btn.disabled) return;
|
||
btn.disabled = true;
|
||
btn.classList.add("is-loading");
|
||
$("#resultStatus").textContent = "生成中…";
|
||
$("#resultStatus").className = "tag tag-warn";
|
||
|
||
try {
|
||
const res = await window.API.generateImage({
|
||
provider_id: provider_id,
|
||
model: model,
|
||
prompt: prompt,
|
||
size: size,
|
||
n: n,
|
||
});
|
||
renderResult(res);
|
||
$("#resultStatus").textContent = "生成成功 · " + res.images.length + " 张";
|
||
$("#resultStatus").className = "tag tag-success";
|
||
toast("已保存到 " + res.output_dir);
|
||
await loadRecent();
|
||
} catch (e) {
|
||
$("#resultStatus").textContent = "生成失败";
|
||
$("#resultStatus").className = "tag tag-danger";
|
||
toast(e.message, "err");
|
||
} finally {
|
||
btn.classList.remove("is-loading");
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
function renderResult(res) {
|
||
const grid = $("#resultGrid");
|
||
grid.innerHTML = "";
|
||
$("#resultEmpty").classList.toggle("hidden", res.images.length !== 0);
|
||
res.images.forEach((img) => {
|
||
const el = document.createElement("div");
|
||
el.className = "gal-item";
|
||
el.innerHTML =
|
||
'<img src="' + esc(img.url) + '" alt="' + esc(img.filename) + '" />' +
|
||
'<span class="tag gal-tag">' + esc(res.generation.model) + "</span>" +
|
||
'<div class="gal-meta"><b>' + esc(img.filename) + "</b><span>" +
|
||
fmtBytes(img.bytes) + " · " + esc(res.generation.created_at) + "</span></div>";
|
||
grid.appendChild(el);
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------- 最近产出 */
|
||
async function loadRecent() {
|
||
const items = await window.API.listGallery("user");
|
||
recent = items.slice(0, 6);
|
||
const grid = $("#recentGrid");
|
||
grid.innerHTML = "";
|
||
$("#recentEmpty").classList.toggle("hidden", recent.length !== 0);
|
||
recent.forEach((g) => {
|
||
const el = document.createElement("div");
|
||
el.className = "gal-item";
|
||
el.innerHTML =
|
||
'<img src="' + esc(g.url) + '" alt="' + esc(g.filename) + '" loading="lazy" />' +
|
||
'<div class="gal-meta"><b>' + esc(g.filename) + "</b><span>" + esc(g.created_at) + "</span></div>";
|
||
grid.appendChild(el);
|
||
});
|
||
}
|
||
|
||
$("#provider").addEventListener("change", renderModelOptions);
|
||
|
||
paintIcons();
|
||
runTask(async () => {
|
||
await loadProviders();
|
||
await loadModels();
|
||
await loadRecent();
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|