From 022b1f770c5d8df91e004b17e7bb69107eeb7413 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Mon, 14 Sep 2026 00:15:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(models):=20=E3=80=8C=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E3=80=8D=E6=8E=A5=E5=AE=98=E6=96=B9=E5=8E=82?= =?UTF-8?q?=E5=AE=B6=E7=9B=AE=E5=BD=95=20+=20=E6=8C=89=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E9=A1=B5=E7=BB=93=E6=9E=84=E9=87=8D=E5=81=9A=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=EF=BC=88=E6=A1=A3=E6=A1=88=2087=20=E8=A1=A5=E5=81=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测反馈:新增条目看不懂、厂家选择太少且国内一家都没有。根因是**第一版只做了手填表单**,没接官方 pi-ai 自带目录(实测 38 家,国内 11 家:蚂蚁 / 通义千问 / 小米 / 月之暗面 / 智谱 / MiniMax)。 - 新文件 src/web/model-catalog.ts:只读官方 data/*.json(10 分钟缓存,不导入 pi-ai 运行时);中文名走显示用静态表,选取范围以目录为准 - 新增 GET /api/me/model-providers(38 家 + cn 分组 + modelCount + 模型名;排除 deepseek —— 平台已有内置入口) - POST /api/me/keys 新增 provider 参数:**目录厂家只需 API 密钥**(端点/协议/模型全由官方目录兜底 —— 官方 config.d.ts 明示 route 命中即继承默认) - refForEntry 判据由「baseUrl 为空」改为「**没有 route**」:否则目录厂家的 key 会被写进 DEEPSEEK_API_KEY - SettingsEntry 的 baseURL/api/models 改为可选:目录厂家只写 apiKeyEnv(写得越少越不容易漂) - 前端按官方页结构重写:提供方行(厂家/说明/状态/操作)+ 新增**先选厂家**(中国大陆 / 国际分组 / 自定义)+ 保留并强化 admin 的「平台共享模型」区块 - 真环境验收:目录端点 38 家 / 国内 11 家;加 moonshotai-cn 只给 key → settings.yaml 仅 apiKeyEnv;删后整块消失 --- poc/business-plugins/lib/client.js | 240 ++++++++++++++-------- poc/business-plugins/package.json | 2 +- scripts/verify-platform-admin-section.mjs | 27 ++- src/web/model-catalog.ts | 208 +++++++++++++++++++ src/web/model-landing.ts | 46 +++-- src/web/routes/auth.ts | 59 +++++- src/web/server.ts | 28 ++- 7 files changed, 489 insertions(+), 121 deletions(-) create mode 100644 src/web/model-catalog.ts diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index 3273af5..ecda252 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -404,19 +404,26 @@ window.__ModuleLoader__.load({ "ms.ph.url": "Endpoint(留空 = 内置 DeepSeek)", "ms.ph.route": "厂家标识(可选,英文小写)", "ms.ph.models": "模型 id,一行一个", - "ms.formHint": "Endpoint 留空即使用平台内置 DeepSeek;一旦填写,就必须给至少一个模型 id。厂家标识会写进实例配置,同一个标识不能重复。", + "ms.pickProvider": "厂家:", + "ms.optBuiltin": "内置 DeepSeek(平台共享)", + "ms.optCustom": "自定义厂家(OpenAI 兼容网关)…", + "ms.grpCn": "中国大陆", + "ms.grpIntl": "国际", + "ms.catalogHint": "端点与 {n} 个模型由官方目录提供,你只需填 API 密钥。", + "ms.showModels": "看看自带哪些模型", + "ms.hideModels": "收起模型清单", + "ms.builtinPickHint": "内置 DeepSeek:平台内置通道;密钥留空则回落到平台共享密钥。", + "ms.needUrl": "请填 Endpoint", "ms.save": "保存", - "ms.list": "我的模型条目", - "ms.col.name": "名称", - "ms.col.route": "厂家标识", - "ms.col.url": "Endpoint", - "ms.col.api": "协议", - "ms.col.models": "模型数", + "ms.list": "我已添加的厂家", + "ms.col.provider": "厂家", + "ms.col.desc": "说明", "ms.col.state": "状态", "ms.col.act": "操作", - "ms.empty": "还没有条目 —— 在上面添加一个即可", - "ms.builtin": "内置 DeepSeek", - "ms.builtinHint": "官方内置,无需 endpoint", + "ms.empty": "还没有厂家 —— 在下面选一个添加即可", + "ms.kindBuiltin": "内置 DeepSeek", + "ms.builtinHint": "平台内置通道,无需 endpoint", + "ms.catalogRow": "官方目录厂家(端点 / 协议 / 模型由目录提供)", "ms.on": "已启用", "ms.off": "已停用", "ms.enable": "启用", @@ -428,7 +435,8 @@ window.__ModuleLoader__.load({ "ms.e.route": "厂家标识不合法(小写字母/数字/短横线,字母开头)", "ms.e.api": "不支持该协议", "ms.e.models": "请至少填一个模型 id", - "ms.e.taken": "该厂家标识已被占用,换一个" + "ms.e.taken": "该厂家标识已被占用,换一个", + "ms.e.provider": "官方目录里没有这个厂家" }; /** English dictionary, key-set complete against zh. */ var en = { @@ -682,19 +690,26 @@ window.__ModuleLoader__.load({ "ms.ph.url": "Endpoint (empty = built-in DeepSeek)", "ms.ph.route": "Provider id (optional, lowercase)", "ms.ph.models": "One model id per line", - "ms.formHint": "Leave Endpoint empty to use the platform's built-in DeepSeek; if you fill it in you must give at least one model id. The provider id is written into the instance config and must be unique.", + "ms.pickProvider": "Provider:", + "ms.optBuiltin": "Built-in DeepSeek (platform-shared)", + "ms.optCustom": "Custom provider (OpenAI-compatible gateway)…", + "ms.grpCn": "Mainland China", + "ms.grpIntl": "International", + "ms.catalogHint": "Endpoint and {n} models come from the official catalog — you only need the API key.", + "ms.showModels": "Show bundled models", + "ms.hideModels": "Hide models", + "ms.builtinPickHint": "Built-in DeepSeek: the platform channel; leave the key empty to fall back to the platform-shared one.", + "ms.needUrl": "Endpoint is required", "ms.save": "Save", - "ms.list": "My model entries", - "ms.col.name": "Name", - "ms.col.route": "Provider id", - "ms.col.url": "Endpoint", - "ms.col.api": "Protocol", - "ms.col.models": "Models", + "ms.list": "My providers", + "ms.col.provider": "Provider", + "ms.col.desc": "Details", "ms.col.state": "State", "ms.col.act": "Actions", - "ms.empty": "No entries yet — add one above", - "ms.builtin": "Built-in DeepSeek", - "ms.builtinHint": "Built in; no endpoint needed", + "ms.empty": "No providers yet — pick one below to add", + "ms.kindBuiltin": "Built-in DeepSeek", + "ms.builtinHint": "Platform channel; no endpoint needed", + "ms.catalogRow": "Catalog provider (endpoint/protocol/models from the catalog)", "ms.on": "Enabled", "ms.off": "Disabled", "ms.enable": "Enable", @@ -706,7 +721,8 @@ window.__ModuleLoader__.load({ "ms.e.route": "Invalid provider id (lowercase letters, digits, dashes; must start with a letter)", "ms.e.api": "Unsupported protocol", "ms.e.models": "Add at least one model id", - "ms.e.taken": "That provider id is taken — pick another" + "ms.e.taken": "That provider id is taken — pick another", + "ms.e.provider": "That provider is not in the official catalog" }; /** @@ -1558,15 +1574,31 @@ window.__ModuleLoader__.load({ var MS_ERR = { invalid_name: "ms.e.name", invalid_api_key: "ms.e.key", invalid_base_url: "ms.e.url", invalid_route: "ms.e.route", invalid_api: "ms.e.api", models_required: "ms.e.models", - route_taken: "ms.e.taken" + route_taken: "ms.e.taken", unknown_provider: "ms.e.provider" }; + /** + * 「模型设置」——**按官方 `dsh-client-ui-settings-models` 的结构做**(用户 2026-09-14 要求: + * 「页面就按照 dsh 模型设置的页面,增加 admin 设置的模型管理」)。官方那页的骨架是: + * · **提供方行**:每个已配置的厂家一行;卡片主字段是**单独一个「API 密钥」输入框** + * (页面从不问环境变量名 —— 派生 `_API_KEY`,pi-ai 记为 `apiKeyEnv`); + * · 收起的「自定义设置」才承载 `baseURL` / 协议 / 模型目录; + * · **新增** = 一张卡片,含**目录厂家选择框**(裸挂载的 `llm-pi-ai` 就能提供完整 + * 已安装 catalog)+ 「添加自定义提供方」(要 Provider ID / 端点 / 协议 / ≥1 模型)。 + * 本页与之同构,另加**平台共享模型**(= admin 配的那批,用户可开关,口径②)。 + * + * 关键差别:官方页把密钥写进 profile 的凭据引用;本平台是**平台侧落地**(spawn 时写 + * 实例的 `.credentials.yaml` / `settings.yaml`),所以保存后**要重启实例才生效**。 + */ function ModelSettingsSection() { var useState = React.useState; var useEffect = React.useEffect; var dataState = useState(null); var data = dataState[0]; var setData = dataState[1]; + var provState = useState([]); + var providers = provState[0]; + var setProviders = provState[1]; var loadingState = useState(true); var loading = loadingState[0]; var setLoading = loadingState[1]; @@ -1576,31 +1608,32 @@ window.__ModuleLoader__.load({ var msgState = useState(""); var msg = msgState[0]; var setMsg = msgState[1]; + var fPick = useState(""); var fName = useState(""); var fKey = useState(""); - var fUrl = useState(""); var fRoute = useState(""); + var fUrl = useState(""); var fApi = useState(""); var fModels = useState(""); + var fShow = useState(false); function load() { setLoading(true); - paReq("/api/me/keys") - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (d) { - if (d) { - setData(d); - // 协议默认取官方表的第一项(`dsh-llm-pi-ai` 的 PROTOCOLS 顺序即默认优先级)。 - if (!fApi[0]) fApi[1]((d.protocols || [])[0] || ""); - } + Promise.all([ + paReq("/api/me/keys").then(function (r) { return r.ok ? r.json() : null; }), + paReq("/api/me/model-providers").then(function (r) { return r.ok ? r.json() : null; }) + ]) + .then(function (res) { + if (res[0]) setData(res[0]); + if (res[1] && res[1].providers) setProviders(res[1].providers); setLoading(false); }) .catch(function () { setLoading(false); setMsg(t("ms.loadFailed")); }); } useEffect(function () { load(); }, []); - /** 统一收尾:跑一个写操作 → 刷新列表 / 或把后端错误码翻成文案。 */ - function act(promise) { + /** 统一收尾:跑一个写操作 → 成功则刷新列表 / 失败则把后端错误码翻成文案。 */ + function act(promise, onOk) { if (busy) return; setBusy(true); setMsg(""); @@ -1609,32 +1642,59 @@ window.__ModuleLoader__.load({ .then(function (res) { setBusy(false); if (!res.ok) { - var code = res.d && res.d.error; - setMsg(t(MS_ERR[code] || "ms.saveFailed")); + setMsg(t(MS_ERR[res.d && res.d.error] || "ms.saveFailed")); return; } - if (res.d && res.d.saved) setMsg(t("ms.saved")); + setMsg(t("ms.saved")); + if (onOk) onOk(); load(); }) .catch(function () { setBusy(false); setMsg(t("ms.saveFailed")); }); } + var pick = fPick[0]; + var isCatalog = pick !== "" && pick !== "custom"; + var picked = null; + for (var pi = 0; pi < providers.length; pi++) { + if (providers[pi].id === pick) picked = providers[pi]; + } + + /** 行标题:内置 → 固定文案;目录厂家 → 官方目录里的中文名;自定义 → route。 */ + function labelOf(k) { + if (!k.route) return t("ms.kindBuiltin"); + for (var i = 0; i < providers.length; i++) { + if (providers[i].id === k.route) return providers[i].label; + } + return k.route; + } + /** 行副标题:内置 → 说明;自定义 → endpoint · 协议;目录厂家 → 「由目录提供」。 */ + function descOf(k) { + if (!k.route) return t("ms.builtinHint"); + if (k.baseUrl) return k.baseUrl + " · " + (k.api || "openai-completions"); + return t("ms.catalogRow"); + } + function submit() { var name = fName[0].trim(); var key = fKey[0].trim(); + if (isCatalog && name === "" && picked !== null) name = picked.label; if (name === "" || key === "") { setMsg(t("ms.needNameKey")); return; } - var url = fUrl[0].trim(); var body = { name: name, apiKey: key }; - if (url !== "") { + if (isCatalog) { + // 目录厂家:只给 apiKey —— 端点 / 协议 / 模型清单全部由官方目录兜底。 + body.provider = pick; + } else if (pick === "custom") { + if (fUrl[0].trim() === "") { setMsg(t("ms.needUrl")); return; } var ids = fModels[0].split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s !== ""; }); if (ids.length === 0) { setMsg(t("ms.needModels")); return; } - body.baseUrl = url; + body.baseUrl = fUrl[0].trim(); body.models = ids; if (fRoute[0].trim() !== "") body.route = fRoute[0].trim(); if (fApi[0]) body.api = fApi[0]; } - act(papostJson("/api/me/keys", body)); - fName[1](""); fKey[1](""); fUrl[1](""); fRoute[1](""); fModels[1](""); + act(papostJson("/api/me/keys", body), function () { + fName[1](""); fKey[1](""); fUrl[1](""); fRoute[1](""); fModels[1](""); setShow(false); + }); } if (loading) { @@ -1644,18 +1704,19 @@ window.__ModuleLoader__.load({ var keys = (data && data.keys) || []; var shared = (data && data.shared) || {}; var sharedOn = !!(data && data.sharedModelEnabled); - var protocols = (data && data.protocols) || (fApi[0] ? [fApi[0]] : []); + var protocols = (data && data.protocols) || []; + var cnList = providers.filter(function (p) { return p.cn; }); + var intlList = providers.filter(function (p) { return !p.cn; }); + function provOpts(list) { + return list.map(function (p) { + return jsxRuntime.jsx("option", { value: p.id, children: p.label + "(" + p.modelCount + " 个模型)" }, p.id); + }); + } var rows = keys.map(function (k) { - var custom = !!(k.baseUrl && k.baseUrl !== ""); - var n = 0; - try { var arr = JSON.parse(k.models || "[]"); n = Array.isArray(arr) ? arr.length : 0; } catch (e) { n = 0; } return jsxRuntime.jsxs("tr", { children: [ - jsxRuntime.jsx("td", { children: k.name }), - jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? (k.route || "—") : t("ms.builtin") }), - jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? k.baseUrl : t("ms.builtinHint") }), - jsxRuntime.jsx("td", { className: "pa-dim", children: custom ? (k.api || "openai-completions") : "—" }), - jsxRuntime.jsx("td", { className: "pa-num", children: custom ? String(n) : "—" }), + jsxRuntime.jsx("td", { children: labelOf(k) }), + jsxRuntime.jsx("td", { className: "pa-dim", children: descOf(k) }), jsxRuntime.jsx("td", { children: jsxRuntime.jsx("span", { className: "pa-badge " + (k.enabled ? "active" : "disabled"), children: k.enabled ? t("ms.on") : t("ms.off") }) }), jsxRuntime.jsxs("td", { children: [ jsxRuntime.jsx("button", { key: "t", className: "pa-sm", disabled: busy, onClick: function () { act(papostJson("/api/me/keys/" + k.id + "/toggle", { enabled: !k.enabled })); }, children: k.enabled ? t("ms.disable") : t("ms.enable") }), @@ -1668,7 +1729,7 @@ window.__ModuleLoader__.load({ jsxRuntime.jsx("h1", { className: "pa-hd", children: t("ms.title") }), jsxRuntime.jsx("p", { className: "pa-sub", children: t("ms.sub") }), - // ── 平台共享模型(口径②:列入 + 可开关;开关只影响自己,不动 admin 的配置)── + // ── admin 配的「平台共享模型」(口径②:也列在这里、用户可开关)── jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [ jsxRuntime.jsxs("div", { className: "pa-card-h", children: [ jsxRuntime.jsx("span", { children: t("ms.shared") }), @@ -1676,51 +1737,62 @@ window.__ModuleLoader__.load({ ] }), jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.sharedHint") }), jsxRuntime.jsxs("div", { className: "pa-row", style: { margin: 0 }, children: [ - jsxRuntime.jsx("span", { className: "pa-badge " + (shared.available ? "active" : "disabled"), children: shared.available ? (shared.name || t("ms.available")) : t("ms.unavailable") }), + jsxRuntime.jsx("span", { className: "pa-badge " + (shared.available ? "active" : "disabled"), children: shared.available ? (shared.name || t("ms.available")) + (shared.count > 1 ? " ×" + shared.count : "") : t("ms.unavailable") }), jsxRuntime.jsx("button", { className: "pa-btn" + (sharedOn ? "" : " pa-primary"), disabled: busy || !shared.available, onClick: function () { act(papostJson("/api/me/models/shared", { enabled: !sharedOn })); }, children: sharedOn ? t("ms.sharedOn") : t("ms.sharedOff") }) ] }) ] }), - // ── 新增条目(Endpoint 留空 = 内置 DeepSeek;填了就必须给模型清单)── - jsxRuntime.jsxs("div", { className: "pa-box", style: { marginBottom: 16 }, children: [ - jsxRuntime.jsx("div", { className: "pa-card-h", children: jsxRuntime.jsx("span", { children: t("ms.add") }) }), - jsxRuntime.jsxs("div", { className: "pa-row", children: [ - jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.name"), value: fName[0], onChange: function (e) { fName[1](e.target.value); } }), - jsxRuntime.jsx("input", { className: "pa-input", type: "password", autoComplete: "off", placeholder: t("ms.ph.key"), value: fKey[0], onChange: function (e) { fKey[1](e.target.value); } }) - ] }), - jsxRuntime.jsxs("div", { className: "pa-row", children: [ - jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.url"), value: fUrl[0], onChange: function (e) { fUrl[1](e.target.value); } }), - jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.route"), value: fRoute[0], onChange: function (e) { fRoute[1](e.target.value); } }) - ] }), - jsxRuntime.jsxs("div", { className: "pa-row", children: [ - jsxRuntime.jsx("select", { className: "pa-input", value: fApi[0], onChange: function (e) { fApi[1](e.target.value); }, children: protocols.map(function (p) { return jsxRuntime.jsx("option", { value: p, children: p }, p); }) }) - ] }), - jsxRuntime.jsx("textarea", { className: "pa-input", rows: 3, style: { width: "100%", boxSizing: "border-box" }, placeholder: t("ms.ph.models"), value: fModels[0], onChange: function (e) { fModels[1](e.target.value); } }), - jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.formHint") }), - jsxRuntime.jsxs("div", { className: "pa-row", style: { marginBottom: 0 }, children: [ - jsxRuntime.jsx("button", { className: "pa-btn pa-primary", disabled: busy, onClick: submit, children: t("ms.save") }), - msg ? jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: msg }) : null - ] }) - ] }), - - // ── 我的条目(口径①:每条独立开关,可同时启用)── + // ── 我已添加的厂家(官方页的「提供方行」)── jsxRuntime.jsx("div", { className: "pa-sec", children: t("ms.list") }), - jsxRuntime.jsx("div", { className: "pa-wrap", children: jsxRuntime.jsxs("table", { className: "pa-tbl", children: [ + jsxRuntime.jsx("div", { className: "pa-wrap", style: { marginBottom: 16 }, children: jsxRuntime.jsxs("table", { className: "pa-tbl", children: [ jsxRuntime.jsx("thead", { children: jsxRuntime.jsxs("tr", { children: [ - jsxRuntime.jsx("th", { children: t("ms.col.name") }), - jsxRuntime.jsx("th", { children: t("ms.col.route") }), - jsxRuntime.jsx("th", { children: t("ms.col.url") }), - jsxRuntime.jsx("th", { children: t("ms.col.api") }), - jsxRuntime.jsx("th", { children: t("ms.col.models") }), + jsxRuntime.jsx("th", { children: t("ms.col.provider") }), + jsxRuntime.jsx("th", { children: t("ms.col.desc") }), jsxRuntime.jsx("th", { children: t("ms.col.state") }), jsxRuntime.jsx("th", { children: t("ms.col.act") }) ] }) }), jsxRuntime.jsx("tbody", { children: rows.length > 0 ? rows - : jsxRuntime.jsx("tr", { children: jsxRuntime.jsx("td", { className: "pa-empty-cell", colSpan: 7, children: t("ms.empty") }) }) + : jsxRuntime.jsx("tr", { children: jsxRuntime.jsx("td", { className: "pa-empty-cell", colSpan: 4, children: t("ms.empty") }) }) }) - ] }) }) + ] }) }), + + // ── 新增厂家:**先选厂家**,目录厂家只需填 API 密钥(官方「新增」卡片同构)── + jsxRuntime.jsxs("div", { className: "pa-box", children: [ + jsxRuntime.jsx("div", { className: "pa-card-h", children: jsxRuntime.jsx("span", { children: t("ms.add") }) }), + jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: t("ms.pickProvider") }), + jsxRuntime.jsx("select", { className: "pa-input", value: pick, onChange: function (e) { fPick[1](e.target.value); setShow(false); }, children: [ + jsxRuntime.jsx("option", { value: "", children: t("ms.optBuiltin") }, "_b"), + cnList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpCn"), children: provOpts(cnList) }, "_cn") : null, + intlList.length > 0 ? jsxRuntime.jsx("optgroup", { label: t("ms.grpIntl"), children: provOpts(intlList) }, "_intl") : null, + jsxRuntime.jsx("option", { value: "custom", children: t("ms.optCustom") }, "_c") + ] }) + ] }), + jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("input", { className: "pa-input", placeholder: isCatalog && picked !== null ? picked.label : t("ms.ph.name"), value: fName[0], onChange: function (e) { fName[1](e.target.value); } }), + jsxRuntime.jsx("input", { className: "pa-input", type: "password", autoComplete: "off", placeholder: t("ms.ph.key"), value: fKey[0], onChange: function (e) { fKey[1](e.target.value); } }) + ] }), + isCatalog && picked !== null ? jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: t("ms.catalogHint").replace("{n}", String(picked.modelCount)) }), + jsxRuntime.jsx("button", { className: "pa-sm", onClick: function () { setShow(!fShow[0]); }, children: fShow[0] ? t("ms.hideModels") : t("ms.showModels") }) + ] }) : null, + isCatalog && picked !== null && fShow[0] ? jsxRuntime.jsx("p", { className: "pa-hint", children: (picked.models || []).join("、") }) : null, + pick === "custom" ? jsxRuntime.jsxs("div", { children: [ + jsxRuntime.jsxs("div", { className: "pa-row", children: [ + jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.url"), value: fUrl[0], onChange: function (e) { fUrl[1](e.target.value); } }), + jsxRuntime.jsx("input", { className: "pa-input", placeholder: t("ms.ph.route"), value: fRoute[0], onChange: function (e) { fRoute[1](e.target.value); } }) + ] }), + jsxRuntime.jsx("div", { className: "pa-row", children: jsxRuntime.jsx("select", { className: "pa-input", value: fApi[0], onChange: function (e) { fApi[1](e.target.value); }, children: protocols.map(function (p) { return jsxRuntime.jsx("option", { value: p, children: p }, p); }) }) }), + jsxRuntime.jsx("textarea", { className: "pa-input", rows: 3, style: { width: "100%", boxSizing: "border-box" }, placeholder: t("ms.ph.models"), value: fModels[0], onChange: function (e) { fModels[1](e.target.value); } }) + ] }) : null, + pick === "" ? jsxRuntime.jsx("p", { className: "pa-hint", children: t("ms.builtinPickHint") }) : null, + jsxRuntime.jsxs("div", { className: "pa-row", style: { marginBottom: 0 }, children: [ + jsxRuntime.jsx("button", { className: "pa-btn pa-primary", disabled: busy, onClick: submit, children: t("ms.save") }), + msg ? jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: msg }) : null + ] }) + ] }) ] }); } diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index 52cd0d6..b290a2a 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.11", + "version": "0.3.12", "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs index 597ab66..5a0afc0 100644 --- a/scripts/verify-platform-admin-section.mjs +++ b/scripts/verify-platform-admin-section.mjs @@ -65,6 +65,13 @@ const DATA = { sharedModelEnabled: true, protocols: ["openai-completions", "openai-responses", "anthropic-messages"], }, + // 档案 87 补做:官方 pi-ai 厂家目录(「新增」选择框的数据源)—— 国内 + 国际各一家代表 + "/api/me/model-providers": { + providers: [ + { id: "moonshotai-cn", label: "月之暗面 Moonshot(中国)", api: "openai-completions", baseURL: "https://api.moonshot.cn/v1", cn: true, modelCount: 12, models: ["kimi-k2", "moonshot-v1-8k"] }, + { id: "openai", label: "OpenAI", api: "openai-responses", baseURL: null, cn: false, modelCount: 20, models: ["gpt-4o"] }, + ], + }, }; const sandbox = { console, setTimeout, clearTimeout, @@ -112,6 +119,8 @@ function text(n, out = [], depth = 0) { // 表单控件的占位符也是**用户可见文案**(输入框没有 children)⇒ 一并收集, // 否则「新增表单有哪些栏位」这类断言只能查到可见标签,漏掉真正的引导文案。 if (typeof n.props.placeholder === "string") out.push(n.props.placeholder); + // `` 同理:分组名是 prop,不收就会漏掉「中国大陆 / 国际」这类分组断言。 + if (n.type === "optgroup" && typeof n.props.label === "string") out.push(n.props.label); text(n.props.children, out, depth + 1); } } @@ -222,11 +231,19 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include ok("模型设置:平台共享模型区块 + 开关按钮", tMs.includes("平台共享模型") && tMs.includes("停用共享模型")); // 口径①:条目各自开关 ⇒ 要有「启用/停用」而不是「设为当前」 ok("模型设置:条目按各自状态渲染徽章(已启用 / 已停用)", tMs.includes("已启用") && tMs.includes("已停用")); - ok("模型设置:内置与自定义两类条目都在列表里", tMs.includes("内置 DeepSeek") && tMs.includes("我的中转网关")); - ok("模型设置:自定义条目显示厂家标识 / endpoint / 协议", - tMs.includes("my-gw") && tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions")); - ok("模型设置:新增表单(名称 / API Key / Endpoint / 模型清单栏位齐备)", - tMs.includes("新增模型条目") && tMs.includes("模型 id,一行一个") && tMs.includes("留空 = 内置 DeepSeek")); + ok("模型设置:内置条目显示为「内置 DeepSeek」、自定义条目显示其 route", + tMs.includes("内置 DeepSeek") && tMs.includes("my-gw")); + ok("模型设置:自定义条目副行显示 endpoint · 协议", + tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions")); + // 档案 87 补做:**按官方页的结构** —— 新增先「选厂家」,目录厂家只需填 API 密钥 + ok("模型设置:新增有厂家选择框(含内置 / 自定义两个端点选项)", + tMs.includes("厂家:") && tMs.includes("内置 DeepSeek(平台共享)") && tMs.includes("自定义厂家(OpenAI 兼容网关)…")); + ok("模型设置:选择框按「中国大陆 / 国际」分组,并列出目录厂家(含国内厂家)", + tMs.includes("中国大陆") && tMs.includes("国际") && tMs.includes("月之暗面 Moonshot(中国)") && tMs.includes("OpenAI")); + ok("模型设置:默认选中「内置 DeepSeek」时给出回落说明", + tMs.includes("密钥留空则回落到平台共享密钥")); + ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型)", + !!ZH["ms.catalogHint"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] && !!ZH["ms.pickProvider"]); // 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效 ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效")); const cMs = classes(await render(ms.Comp)); diff --git a/src/web/model-catalog.ts b/src/web/model-catalog.ts new file mode 100644 index 0000000..2faed24 --- /dev/null +++ b/src/web/model-catalog.ts @@ -0,0 +1,208 @@ +/** + * 官方**模型厂家目录**读取层(档案 87 补做)。 + * + * 背景:第一版「模型设置」只支持「内置 DeepSeek + 手填一个 OpenAI 兼容网关」两类, + * 用户反馈**「厂家选择怎么这么少、国内一家都没有」** —— 而实际情况是官方 `pi-ai` 包里 + * 自带 **39 个厂家**(含 `deepseek` / `kimi-coding` / `moonshotai-cn` / `minimax-cn` / + * `qwen-token-plan-cn` / `zai` / `xiaomi` / `ant-ling` …),只是平台没接。 + * + * 数据来源(**只读**,与实例用的同一个包 ⇒ 不会漂): + * `/node_modules/@earendil-works/pi-ai/dist/providers/data/.json` + * 形状:`{ "": { "<模型id>": { id, name, api, baseUrl, provider, contextWindow, maxTokens, cost… } } }` + * + * 关键结论(读官方 `dsh-llm-pi-ai` 的 `config.d.ts` 实证): + * **route 只要命中目录里的厂家 id,就被视为「目录厂家」** —— 该厂家的 endpoint、协议、 + * 显示名、模型清单**全部由目录提供**,profile 只需给 `apiKeyEnv`(外加可选覆盖)。 + * 所以对 39 个目录厂家,用户**只需要填一把 API Key**,不必手写 endpoint / 协议 / 模型。 + * + * 显示名策略:**目录数据里只有模型信息、没有厂家名**(厂家名在 `dist/providers/.js` 里, + * 用正则去抠 JS 太脆)。所以这里用一张**显示用**的静态表(仅影响文案): + * · 表里有 → 用「中文名|官方名」;表里没有(dsh 以后新增厂家)→ 回落成 id 的 Title Case。 + * ⇒ **选取范围永远以数据目录为准**,静态表只是标签;不会因为表落后而漏掉新厂家。 + * + * @module dshs/web/model-catalog + */ + +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +/** dsh 包安装目录(可用 env 覆盖,便于测试与其它部署形态)。 */ +const DSH_PACKAGE_DIR = process.env.DSH_PACKAGE_DIR ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh' + +/** 目录数据目录。 */ +export function catalogDir(): string { + return ( + process.env.PI_AI_DATA_DIR ?? + join(DSH_PACKAGE_DIR, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data') + ) +} + +/** 目录里一个厂家。 */ +export interface CatalogProvider { + /** = settings.yaml 里 `llm-pi-ai.providers` 的 dict 键。 */ + id: string + /** 展示名(中文名|官方名;认不出就 Title Case 化 id)。 */ + label: string + api: string + baseURL: string | null + models: Array<{ id: string; name: string | null; contextWindow: number | null; inputCost: number | null }> +} + +/** + * 显示用标签表(`id → 中文名|官方名`)。**只影响文案**,不影响可选范围。 + * 国内厂家集中在前半段,方便用户在列表里先看到。 + */ +const LABEL: Record = { + deepseek: '深度求索 DeepSeek', + 'kimi-coding': '月之暗面 Kimi(编程)', + moonshotai: '月之暗面 Moonshot', + 'moonshotai-cn': '月之暗面 Moonshot(中国)', + minimax: 'MiniMax', + 'minimax-cn': 'MiniMax(中国)', + 'qwen-token-plan': '通义千问 Qwen(Token Plan)', + 'qwen-token-plan-cn': '通义千问 Qwen(中国)', + 'qwen-token-plan-individual': '通义千问 Qwen(个人版)', + zai: '智谱 Z.AI', + 'zai-coding-cn': '智谱 Z.AI Coding(中国)', + xiaomi: '小米 Xiaomi', + 'xiaomi-token-plan-cn': '小米 Xiaomi(中国)', + 'xiaomi-token-plan-ams': '小米 Xiaomi(AMS)', + 'xiaomi-token-plan-sgp': '小米 Xiaomi(SGP)', + 'ant-ling': '蚂蚁 Ant Ling', + anthropic: 'Anthropic Claude', + openai: 'OpenAI', + 'openai-codex': 'OpenAI Codex', + 'azure-openai-responses': 'Azure OpenAI', + google: 'Google Gemini', + 'google-vertex': 'Google Vertex', + xai: 'xAI Grok', + mistral: 'Mistral', + groq: 'Groq', + cerebras: 'Cerebras', + baseten: 'Baseten', + fireworks: 'Fireworks', + together: 'Together AI', + nvidia: 'NVIDIA NIM', + openrouter: 'OpenRouter', + huggingface: 'Hugging Face', + 'vercel-ai-gateway': 'Vercel AI Gateway', + 'cloudflare-ai-gateway': 'Cloudflare AI Gateway', + 'cloudflare-workers-ai': 'Cloudflare Workers AI', + 'github-copilot': 'GitHub Copilot', + opencode: 'OpenCode Zen', + 'opencode-go': 'OpenCode Go', + 'amazon-bedrock': 'Amazon Bedrock', + radius: 'Radius', +} + +/** 认不出时的回落:`my-provider` → `My Provider`。 */ +function prettify(id: string): string { + return id + .split(/[-_]/) + .map((s) => (s === '' ? s : s[0].toUpperCase() + s.slice(1))) + .join(' ') +} + +/** + * 中国大陆可直连的厂家 id(仅用于界面**分组**,让用户一眼看到国内选项 —— 用户原话: + * 「模型厂商选择怎么这么少 国内的一家都没有」)。判据是厂家主体在中国大陆。 + */ +const CN_IDS = new Set([ + 'deepseek', + 'kimi-coding', + 'moonshotai-cn', + 'minimax-cn', + 'qwen-token-plan', + 'qwen-token-plan-cn', + 'qwen-token-plan-individual', + 'zai', + 'zai-coding-cn', + 'xiaomi', + 'xiaomi-token-plan-cn', + 'ant-ling', +]) + +/** @returns 该厂家是否属于「中国大陆可直连」分组。 */ +export function isCnProvider(id: string): boolean { + return CN_IDS.has(id) +} + +/** @returns 该厂家的展示名。 */ +export function providerLabel(id: string): string { + return LABEL[id] ?? prettify(id) +} + +/** 解析一个厂家的数据文件。 */ +function parseProvider(id: string, raw: string): CatalogProvider | null { + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return null + } + if (data === null || typeof data !== 'object') return null + const byApi = data as Record>> + let api = '' + let baseURL: string | null = null + const models: CatalogProvider['models'] = [] + const seen = new Set() + for (const [apiKey, group] of Object.entries(byApi)) { + if (group === null || typeof group !== 'object') continue + if (api === '') api = apiKey + for (const [modelId, m] of Object.entries(group)) { + if (m === null || typeof m !== 'object') continue + if (seen.has(modelId)) continue + seen.add(modelId) + const row = m as Record + if (baseURL === null && typeof row.baseUrl === 'string' && row.baseUrl !== '') baseURL = row.baseUrl + const cost = (row.cost ?? null) as Record | null + models.push({ + id: typeof row.id === 'string' ? row.id : modelId, + name: typeof row.name === 'string' ? row.name : null, + contextWindow: typeof row.contextWindow === 'number' ? row.contextWindow : null, + inputCost: cost !== null && typeof cost.input === 'number' ? cost.input : null, + }) + } + } + if (models.length === 0) return null + models.sort((a, b) => a.id.localeCompare(b.id)) + return { id, label: providerLabel(id), api, baseURL, models } +} + +/** 进程内缓存(目录是安装期冻结的,TTL 给长一点省 IO)。 */ +let cache: { at: number; list: CatalogProvider[] } | null = null +const TTL_MS = 10 * 60 * 1000 + +/** + * 读出目录里的全部厂家(按中文名/官方名排序)。 + * 目录不存在(例如本地开发机)⇒ 返回空数组,**不抛错**:调用方据此降级为"只有内置 + 自定义"。 + */ +export async function listCatalogProviders(force = false): Promise { + if (!force && cache !== null && Date.now() - cache.at < TTL_MS) return cache.list + const dir = catalogDir() + let files: string[] + try { + files = await readdir(dir) + } catch { + cache = { at: Date.now(), list: [] } + return [] + } + const ids = files.filter((f) => f.endsWith('.json') && !f.startsWith('.')).map((f) => f.slice(0, -'.json'.length)) + const out: CatalogProvider[] = [] + for (const id of ids) { + try { + const p = parseProvider(id, await readFile(join(dir, id + '.json'), 'utf8')) + if (p !== null) out.push(p) + } catch { + /* 单个文件读坏不影响其余 */ + } + } + out.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN')) + cache = { at: Date.now(), list: out } + return out +} + +/** 目录里有没有这个厂家 id(`POST /api/me/keys` 的校验用)。 */ +export async function isCatalogProvider(id: string): Promise { + return (await listCatalogProviders()).some((p) => p.id === id) +} diff --git a/src/web/model-landing.ts b/src/web/model-landing.ts index 323106f..7e3a1d1 100644 --- a/src/web/model-landing.ts +++ b/src/web/model-landing.ts @@ -78,9 +78,14 @@ export function routeRef(route: string): string { return `${up}_API_KEY` } -/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)`。 */ +/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;目录厂家 / 自定义 ⇒ `routeRef(route)`。 */ export function refForEntry(entry: Pick): string { - return entry.baseUrl === null || entry.baseUrl === '' ? BUILTIN_REF : routeRef(entry.route ?? 'custom') + // 内置 DeepSeek ⇔ **没有 route**(它走 `dsh-llm-deepseek` + `DEEPSEEK_API_KEY`,不写 settings.yaml)。 + // ⚠️ 不能再用「baseUrl 为空」当判据:**目录厂家**(读官方 pi-ai 目录的那些 route)也是 + // baseUrl 为空 —— 它们的 endpoint/协议/模型全由目录提供 —— 但它们有自己的 route 与 + // 自己的 `_API_KEY`。两者混一起会把目录厂家的 key 写进 DEEPSEEK_API_KEY。 + const route = entry.route ?? '' + return route === '' ? BUILTIN_REF : routeRef(route) } /** 协议取值规范化:认不出的一律回落官方默认(第一个),不抛错、不写坏配置。 */ @@ -186,13 +191,19 @@ export function reconcileCredentials( return { text: out.join('\n'), managed: [...new Set(kept)] } } -/** 一条要写进 `settings.yaml` 的厂家声明。 */ +/** + * 一条要写进 `settings.yaml` 的厂家声明。 + * ⚠️ `baseURL` / `api` / `models` **都可选**:**目录厂家**(route 命中官方 pi-ai 目录)只需要 + * `apiKeyEnv` —— 它的 endpoint、协议、模型目录全部由官方目录提供(官方 `config.d.ts`: + * "When it does, that provider's endpoint, protocol, display name, and model catalog are the + * profile's defaults and the profile overrides them field by field")。写得越少越不容易漂。 + */ export interface SettingsEntry { route: string apiKeyEnv: string - baseURL: string - api: Protocol - models: string[] + baseURL?: string + api?: Protocol + models?: string[] } const markBegin = (route: string): string => ` # dshs:model-route ${route} begin` @@ -285,16 +296,19 @@ export function reconcileSettings( } } - const blocks = add.flatMap((d) => [ - markBegin(d.route), - ` ${d.route}:`, - ` apiKeyEnv: ${d.apiKeyEnv}`, - ` baseURL: ${d.baseURL}`, - ` api: ${d.api}`, - ' models:', - ...d.models.map((m) => ` - id: ${m}`), - markEnd(d.route), - ]) + const blocks = add.flatMap((d) => { + const lines = [markBegin(d.route), ` ${d.route}:`, ` apiKeyEnv: ${d.apiKeyEnv}`] + // 只写**给了的**字段:目录厂家只给 apiKeyEnv(其余由官方目录兜底), + // 自定义厂家才需要 baseURL / api / models。 + if (d.baseURL !== undefined && d.baseURL !== '') lines.push(` baseURL: ${d.baseURL}`) + if (d.api !== undefined) lines.push(` api: ${d.api}`) + if (d.models !== undefined && d.models.length > 0) { + lines.push(' models:') + for (const m of d.models) lines.push(` - id: ${m}`) + } + lines.push(markEnd(d.route)) + return lines + }) const out = [...kept] if (provAt >= 0) { diff --git a/src/web/routes/auth.ts b/src/web/routes/auth.ts index f4db18a..9bbb3d1 100644 --- a/src/web/routes/auth.ts +++ b/src/web/routes/auth.ts @@ -10,6 +10,7 @@ import { requireAuth } from '../middleware/authn.js' import { homeRoot, userRoot } from '../../fs/workspace.js' import { deriveKey, encrypt } from '../../crypto.js' import { toPublicUser } from '../../db/types.js' +import { isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js' import { PROTOCOLS } from '../model-landing.js' import { clearSessionCookie, @@ -129,6 +130,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => { properties: { name: { type: 'string', minLength: 1, maxLength: 32 }, apiKey: { type: 'string', minLength: 1, maxLength: 256 }, + // 档案 87 补做:**目录厂家 id**(选了它只需 apiKey;endpoint/协议/模型由官方目录兜底)。 + provider: { type: 'string', minLength: 1, maxLength: 40 }, // 档案 87:自定义厂家三件套 —— **都不给**就是老语义的「内置 DeepSeek 那一把 key」。 route: { type: 'string', minLength: 1, maxLength: 40 }, baseUrl: { type: 'string', maxLength: 300 }, @@ -216,10 +219,38 @@ export const authRoutes: FastifyPluginAsync = async (app) => { protocols: [...PROTOCOLS], })) + /** + * 官方 **pi-ai 厂家目录**(档案 87 补做)——「新增模型条目」的选择框数据源。 + * + * 为什么由后端给:目录是**安装期冻结**在官方包里的(`@earendil-works/pi-ai/dist/providers/data/`), + * 前端拿不到也不该硬编码;后端读一次缓存 10 分钟。选中的厂家**只要填 API Key** —— + * endpoint / 协议 / 模型目录全由目录提供(见 `model-catalog.ts` 头注释)。 + * ⚠️ 目录里的 `deepseek` 被**排除**:平台已有「内置 DeepSeek」入口(走 `dsh-llm-deepseek` + + * 平台共享 key),再列一个同名选项只会让用户分不清哪个生效。 + */ + app.get('/api/me/model-providers', { preHandler: requireAuth }, async () => { + const all = await listCatalogProviders() + return { + providers: all + .filter((p) => p.id !== 'deepseek') + .map((p) => ({ + id: p.id, + label: p.label, + api: p.api, + baseURL: p.baseURL, + cn: isCnProvider(p.id), + modelCount: p.models.length, + // 只回前 60 个模型名给界面展示(足量的"看到它自带什么"),不整份下发。 + models: p.models.slice(0, 60).map((m) => m.name ?? m.id), + })), + } + }) + app.post('/api/me/keys', { preHandler: requireAuth, schema: keyAddSchema }, async (request, reply) => { const body = request.body as { name: string apiKey: string + provider?: string route?: string baseUrl?: string api?: string @@ -235,6 +266,28 @@ export const authRoutes: FastifyPluginAsync = async (app) => { if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(body.apiKey)) { return reply.code(400).send({ error: 'invalid_api_key' }) } + const existingKeys = await app.db.listCredentialKeys(request.user!.id) + /** route 是 settings.yaml 里的 dict 键 ⇒ 同一用户下撞键 = 后写的覆盖前者、静默失效。 */ + const routeTaken = (r: string): boolean => existingKeys.some((k) => k.route === r && k.name !== cleanName) + + // ── 情形 A:**目录厂家**(用户只填了 API Key)──────────────────────────────── + const providerId = (body.provider ?? '').trim() + if (providerId !== '') { + if (!(await isCatalogProvider(providerId))) return reply.code(400).send({ error: 'unknown_provider' }) + if (routeTaken(providerId)) return reply.code(409).send({ error: 'route_taken' }) + const key = await app.db.setCredentialKey( + request.user!.id, + cleanName, + encrypt(body.apiKey, deriveKey(app.config.encryptionSecret)), + // 只记 route:endpoint / 协议 / 模型清单**都不写**,交给官方目录兜底。 + { route: providerId, baseUrl: null, api: null, models: null }, + ) + await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName, provider: providerId })) + await refreshAfterKeyChange(request.user!.id, request.user!.role) + return { key } + } + + // ── 情形 B:内置 DeepSeek(不带 baseUrl)/自定义网关(带 baseUrl)──────────── const baseUrl = (body.baseUrl ?? '').trim() const isCustom = baseUrl !== '' let route: string | null = null @@ -251,11 +304,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { const ids = (body.models ?? []).map((m) => m.trim()).filter((m) => m !== '') if (ids.length === 0) return reply.code(400).send({ error: 'models_required' }) models = JSON.stringify(ids.slice(0, 50)) - // route 是 settings.yaml 里的 dict 键 ⇒ 同一个用户下撞键 = 后写入的会覆盖前者,静默失效。 - const existing = await app.db.listCredentialKeys(request.user!.id) - if (existing.some((k) => k.route === route && k.name !== cleanName)) { - return reply.code(409).send({ error: 'route_taken' }) - } + if (routeTaken(route)) return reply.code(409).send({ error: 'route_taken' }) } const key = await app.db.setCredentialKey( request.user!.id, diff --git a/src/web/server.ts b/src/web/server.ts index 7a3661a..6769d0a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -183,16 +183,24 @@ export async function buildServer(config: ServerConfig): Promise