feat: complete image console and add verified REST and MCP workflows

This commit is contained in:
2026-11-04 00:41:54 +08:00
parent 3140cd51d2
commit bef8af53ed
48 changed files with 5167 additions and 1176 deletions
+387
View File
@@ -0,0 +1,387 @@
<!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>