336 lines
18 KiB
HTML
336 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="providers">
|
||
|
||
<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>供应商</h1>
|
||
</div>
|
||
<div class="top-actions"><span class="tag tag-success">3 条线路</span><a class="btn btn-secondary btn-sm" href="models.html">模型清单</a></div>
|
||
</header>
|
||
<section class="page-body">
|
||
<div class="split">
|
||
<div class="stagger">
|
||
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head">
|
||
<div>
|
||
<h3>已接入供应商</h3>
|
||
<div class="sub">每个供应商对应一条上游线路,密钥加密存入 SQLite</div>
|
||
</div>
|
||
<button class="btn btn-sm" id="newProviderBtn"><span class="ico" data-icon="plus"></span>新增供应商</button>
|
||
</div>
|
||
<div class="card-body tight">
|
||
<div id="providerList"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="discoverCard" style="margin-bottom:16px">
|
||
<div class="card-head">
|
||
<div>
|
||
<h3>模型发现</h3>
|
||
<div class="sub">请求上游 <span class="mono">/models</span>,筛选 ID 含 <span class="mono">image</span> 的模型</div>
|
||
</div>
|
||
<span class="tag" id="discoverTarget">未选择供应商</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="hint" style="margin-bottom:14px">保存供应商后,点击列表中的“拉取模型”,勾选需要启用的图片模型并保存。</p>
|
||
<div id="discoverResult"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<h3>供应商配置</h3>
|
||
<span class="sub" id="formMode">新建</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="field">
|
||
<label for="fId">供应商 ID <span class="req">*</span></label>
|
||
<input type="text" id="fId" placeholder="例如 slomerex / agnes" />
|
||
<div class="hint">小写字母与连字符,作为 API 中 provider 字段的取值</div>
|
||
<div class="err-msg">已存在该 ID,请换一个</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fName">显示名称 <span class="req">*</span></label>
|
||
<input type="text" id="fName" placeholder="例如 Slomex 网关" />
|
||
<div class="err-msg">请填写显示名称</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fAdapter">适配器类型</label>
|
||
<select id="fAdapter">
|
||
<option value="openai_compat">OpenAI 兼容(/images/generations)</option>
|
||
<option value="agnes">Agnes 原生(文生图 / 图生图)</option>
|
||
</select>
|
||
<div class="hint">决定请求体与响应解析方式</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fBaseUrl">Base URL <span class="req">*</span></label>
|
||
<input type="text" id="fBaseUrl" placeholder="https://api.slomerex.xyz/v1" />
|
||
<div class="err-msg">请填写 Base URL</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fApiKey">API Key <span class="req">*</span></label>
|
||
<div class="input-group">
|
||
<input type="password" id="fApiKey" placeholder="sk-..." />
|
||
<button class="btn btn-secondary btn-sm" id="toggleKeyBtn" title="显示 / 隐藏"><span class="ico" data-icon="eye"></span></button>
|
||
</div>
|
||
<div class="hint">仅在保存时写入,列表中以掩码展示</div>
|
||
<div class="err-msg">请填写 API Key</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fDefaultModel">默认模型</label>
|
||
<input type="text" id="fDefaultModel" placeholder="保存后可从已发现模型中选择" />
|
||
<div class="hint">出图页优先选择此模型;留空或不可用时选择第一个已启用模型</div>
|
||
</div>
|
||
<div class="field">
|
||
<label><input type="checkbox" id="fEnabled" checked /> 启用供应商</label>
|
||
<div class="hint">停用后不可出图,已有配置和历史记录保留</div>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fNote">备注</label>
|
||
<textarea id="fNote" rows="2" placeholder="线路质量、限额、注意事项"></textarea>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:6px">
|
||
<button class="btn btn-secondary btn-sm" id="resetFormBtn">重置</button>
|
||
<button class="btn btn-sm" id="saveProviderBtn">保存供应商</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-mask" id="confirmModal">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<h3>删除供应商</h3>
|
||
<button class="icon-btn" data-close="confirmModal"><span class="ico" data-icon="x"></span></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p style="margin:0">确认删除供应商 <b id="confirmName"></b>?其下已发现的模型也会一并移除,已生成的图片保留在 output/ 目录。</p>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn btn-secondary btn-sm" data-close="confirmModal">取消</button>
|
||
<button class="btn btn-danger btn-sm" id="confirmDeleteBtn">确认删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const $ = (s) => document.querySelector(s);
|
||
let providers = [];
|
||
let editingId = null;
|
||
let pendingDelete = null;
|
||
|
||
|
||
function adapterTag(a) {
|
||
const map = { openai_compat: ["OpenAI 兼容", "tag-info"], agnes: ["Agnes 原生", "tag-warn"] };
|
||
const [label, cls] = map[a] || ["未知", ""];
|
||
return '<span class="tag ' + cls + '">' + label + "</span>";
|
||
}
|
||
|
||
function renderProviders() {
|
||
const host = $("#providerList");
|
||
if (!providers.length) {
|
||
host.innerHTML = '<div class="empty" style="border:none"><span class="ico" data-icon="plug"></span><h4>还没有供应商</h4><p>在右侧填写上游 API Key 与 Base URL,开始接入第一条线路。</p></div>';
|
||
paintIcons(host);
|
||
return;
|
||
}
|
||
host.innerHTML =
|
||
'<div class="table-wrap"><table class="tbl"><thead><tr>' +
|
||
"<th>供应商</th><th>适配器</th><th>Base URL</th><th>模型</th><th>状态</th><th>最近发现</th><th></th>" +
|
||
"</tr></thead><tbody>" +
|
||
providers.map((p) =>
|
||
"<tr>" +
|
||
'<td><b>' + esc(p.name) + '</b><div class="dim mono" style="font-size:11px">' + esc(p.id) + "</div></td>" +
|
||
"<td>" + adapterTag(p.adapter) + "</td>" +
|
||
'<td class="mono dim" style="max-width:220px;overflow:hidden;text-overflow:ellipsis">' + esc(p.base_url) + "</td>" +
|
||
'<td class="mono">' + p.model_count + '<span class="dim"> 个</span></td>' +
|
||
"<td>" + (p.enabled ? '<span class="tag tag-success">启用</span>' : '<span class="tag">停用</span>') + "</td>" +
|
||
'<td class="dim mono">' + esc(p.models_discovered) + "</td>" +
|
||
'<td style="white-space:nowrap;text-align:right">' +
|
||
'<button class="btn btn-ghost btn-sm" data-act="discover" data-id="' + esc(p.id) + '">拉取模型</button> ' +
|
||
'<button class="btn btn-ghost btn-sm" data-act="edit" data-id="' + esc(p.id) + '">编辑</button> ' +
|
||
'<button class="btn btn-ghost btn-sm" data-act="del" data-id="' + esc(p.id) + '">删除</button>' +
|
||
"</td></tr>"
|
||
).join("") +
|
||
"</tbody></table></div>";
|
||
paintIcons(host);
|
||
}
|
||
|
||
function fillForm(p) {
|
||
editingId = p ? p.id : null;
|
||
$("#formMode").textContent = p ? "编辑:" + p.id : "新建";
|
||
$("#fId").value = p ? p.id : "";
|
||
$("#fId").disabled = !!p;
|
||
$("#fName").value = p ? p.name : "";
|
||
$("#fAdapter").value = p ? p.adapter : "openai_compat";
|
||
$("#fBaseUrl").value = p ? p.base_url : "";
|
||
$("#fApiKey").value = "";
|
||
$("#fApiKey").placeholder = p ? p.api_key_masked + "(留空表示不修改)" : "sk-...";
|
||
$("#fDefaultModel").value = p ? p.default_model : "";
|
||
$("#fEnabled").checked = p ? p.enabled : true;
|
||
$("#fNote").value = p ? (p.note || "") : "";
|
||
$("#discoverTarget").textContent = p ? "目标:" + p.id : "未选择供应商";
|
||
document.querySelectorAll(".field").forEach((f) => f.classList.remove("has-error"));
|
||
}
|
||
|
||
function validate() {
|
||
let ok = true;
|
||
const set = (sel, bad) => {
|
||
const f = $(sel).closest(".field");
|
||
if (f) f.classList.toggle("has-error", bad);
|
||
if (bad) ok = false;
|
||
};
|
||
const id = $("#fId").value.trim();
|
||
set("#fId", !id || (!editingId && providers.some((p) => p.id === id)));
|
||
set("#fName", !$("#fName").value.trim());
|
||
set("#fBaseUrl", !$("#fBaseUrl").value.trim());
|
||
set("#fApiKey", !$("#fApiKey").value.trim() && !editingId);
|
||
return ok;
|
||
}
|
||
|
||
async function save() {
|
||
if (!validate()) { toast("请检查表单中标红的字段", "err"); return; }
|
||
const btn = $("#saveProviderBtn");
|
||
btn.classList.add("is-loading");
|
||
const id = $("#fId").value.trim();
|
||
const payload = {
|
||
id: id,
|
||
name: $("#fName").value.trim(),
|
||
adapter: $("#fAdapter").value,
|
||
base_url: $("#fBaseUrl").value.trim(),
|
||
default_model: $("#fDefaultModel").value.trim(),
|
||
note: $("#fNote").value.trim(),
|
||
};
|
||
const key = $("#fApiKey").value.trim();
|
||
providers = await window.API.saveProvider({
|
||
...payload,
|
||
api_key: key,
|
||
enabled: $("#fEnabled").checked,
|
||
});
|
||
renderProviders();
|
||
fillForm(null);
|
||
btn.classList.remove("is-loading");
|
||
toast("供应商已保存");
|
||
}
|
||
|
||
async function discover(id, btn) {
|
||
const p = providers.find((x) => x.id === id);
|
||
if (!p) return;
|
||
btn.classList.add("is-loading");
|
||
$("#discoverTarget").textContent = "目标:" + id;
|
||
$("#discoverResult").innerHTML = '<div class="skel" style="height:18px;width:60%;margin-bottom:10px"></div><div class="skel" style="height:18px;width:40%"></div>';
|
||
try {
|
||
const res = await window.API.discoverProviderModels(id);
|
||
const models = await window.API.listModels(id);
|
||
$("#discoverResult").innerHTML =
|
||
'<div class="dim mono" style="margin-bottom:10px">上游返回 ' + res.total + " 个模型,命中 " + res.matched.length + " 个含 image 的模型</div>" +
|
||
'<div class="model-list">' +
|
||
res.matched.map((m) => {
|
||
const knownOne = models.find((x) => x.model_id === m);
|
||
return '<label class="model-row" style="cursor:pointer">' +
|
||
'<input type="checkbox" ' + (knownOne && knownOne.enabled ? "checked" : "") + ' data-model="' + esc(m) + '" />' +
|
||
'<span class="name">' + esc(m) + (knownOne ? "<small>已在库中 · " + (knownOne.enabled ? "已启用" : "已停用") + "</small>" : "<small>新发现</small>") + "</span>" +
|
||
'<span class="tag tag-info">image</span>' +
|
||
"</label>";
|
||
}).join("") +
|
||
"</div>" +
|
||
'<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">' +
|
||
'<button class="btn btn-secondary btn-sm" id="applyDiscoverBtn">保存勾选模型</button></div>';
|
||
$("#applyDiscoverBtn").addEventListener("click", (event) => runTask(async () => {
|
||
const picked = Array.from(document.querySelectorAll("#discoverResult input[type=checkbox]")).filter((c) => c.checked).map((c) => c.dataset.model);
|
||
await window.API.saveProviderModels(id, picked, true, "自动发现");
|
||
providers = await window.API.listProviders();
|
||
renderProviders();
|
||
toast("已保存 " + picked.length + " 个模型");
|
||
}, event.currentTarget));
|
||
} catch (err) {
|
||
$("#discoverResult").innerHTML = '<div class="empty" style="border-color:rgba(217,100,95,.4)"><span class="ico" data-icon="alert"></span><h4>拉取失败</h4><p>' + esc(err.message) + '</p></div>';
|
||
paintIcons($("#discoverResult"));
|
||
}
|
||
btn.classList.remove("is-loading");
|
||
}
|
||
|
||
$("#providerList").addEventListener("click", (e) => {
|
||
const b = e.target.closest("button[data-act]");
|
||
if (!b) return;
|
||
const p = providers.find((x) => x.id === b.dataset.id);
|
||
if (b.dataset.act === "edit") { fillForm(p); window.scrollTo({ top: 0, behavior: "smooth" }); }
|
||
if (b.dataset.act === "discover") discover(b.dataset.id, b);
|
||
if (b.dataset.act === "del") {
|
||
pendingDelete = b.dataset.id;
|
||
$("#confirmName").textContent = p ? p.name : b.dataset.id;
|
||
openModal("confirmModal");
|
||
}
|
||
});
|
||
|
||
$("#confirmDeleteBtn").addEventListener("click", (event) => runTask(async () => {
|
||
providers = await window.API.deleteProvider(pendingDelete);
|
||
renderProviders();
|
||
closeModal("confirmModal");
|
||
toast("供应商已删除");
|
||
}, event.currentTarget));
|
||
|
||
document.addEventListener("click", (e) => {
|
||
const c = e.target.closest("[data-close]");
|
||
if (c) closeModal(c.dataset.close);
|
||
});
|
||
|
||
$("#toggleKeyBtn").addEventListener("click", () => {
|
||
const inp = $("#fApiKey");
|
||
inp.type = inp.type === "password" ? "text" : "password";
|
||
$("#toggleKeyBtn").innerHTML = icon(inp.type === "password" ? "eye" : "eyeOff", 16);
|
||
});
|
||
$("#saveProviderBtn").addEventListener("click", (event) => runTask(save, event.currentTarget));
|
||
$("#resetFormBtn").addEventListener("click", () => fillForm(null));
|
||
$("#newProviderBtn").addEventListener("click", () => { fillForm(null); window.scrollTo({ top: 0, behavior: "smooth" }); });
|
||
|
||
paintIcons();
|
||
runTask(async () => {
|
||
providers = await window.API.listProviders();
|
||
renderProviders();
|
||
fillForm(null);
|
||
});
|
||
})();
|
||
</script>
|
||
</section>
|
||
</main>
|
||
|
||
</body>
|
||
</html>
|