Files

336 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>