feat(models): 「模型设置」接官方厂家目录 + 按官方页结构重做界面(档案 87 补做)

用户实测反馈:新增条目看不懂、厂家选择太少且国内一家都没有。根因是**第一版只做了手填表单**,没接官方 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;删后整块消失
This commit is contained in:
admin committed 2026-09-14 00:15:17 +08:00
1 parent 918f1d3a51
commit 022b1f770c
7 files changed
+489 -121

No files matched your search

+156 -84
View File
@@ -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 密钥」输入框**
* (页面从不问环境变量名 —— 派生 `<ROUTE>_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
] })
] })
] });
}
+1 -1
View File
@@ -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.Line truncated
"type": "module",
"main": "lib/index.js",
+22 -5
View File
@@ -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);
// `<optgroup label="…">` 同理:分组名是 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));
+208
View File
@@ -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` …),只是平台没接。
*
* 数据来源(**只读**,与实例用的同一个包 ⇒ 不会漂):
* `<dsh 安装目录>/node_modules/@earendil-works/pi-ai/dist/providers/data/<id>.json`
* 形状:`{ "<api>": { "<模型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/<id>.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<string, string> = {
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<string, Record<string, Record<string, unknown>>>
let api = ''
let baseURL: string | null = null
const models: CatalogProvider['models'] = []
const seen = new Set<string>()
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<string, unknown>
if (baseURL === null && typeof row.baseUrl === 'string' && row.baseUrl !== '') baseURL = row.baseUrl
const cost = (row.cost ?? null) as Record<string, unknown> | 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<CatalogProvider[]> {
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<boolean> {
return (await listCatalogProviders()).some((p) => p.id === id)
}
+30 -16
View File
@@ -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<LandingEntry, 'route' | 'baseUrl'>): 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 与
// 自己的 `<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) {
+54 -5
View File
@@ -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,
+18 -10
View File
@@ -183,16 +183,24 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
}
seen.add(ref)
creds.push({ ref, value })
// 只有"自定义厂家"才写 settings.yaml:内置 DeepSeek 由官方 `dsh-llm-deepseek` 自己管
// (它的 route 是 `deepseek-official`,不是 `llm-pi-ai` 下的键)。
if (row.baseUrl !== null && row.baseUrl !== '' && row.route !== null && row.route !== '') {
providers.push({
route: row.route,
apiKeyEnv: ref,
baseURL: row.baseUrl,
api: normalizeProtocol(row.api),
models: parseModels(row.models),
})
// 有 route 才写 settings.yaml。两种情形:
// · **目录厂家**(route 命中官方 pi-ai catalog,baseUrl 为空)⇒ 只写 `apiKeyEnv`,
// endpoint / 协议 / 模型目录全由官方目录提供(档案 87 补做)。
// · **自定义厂家**(baseUrl 非空)⇒ endpoint + 协议 + 模型清单必须齐全。
// 内置 DeepSeek 没有 route ⇒ 只写 `refs.DEEPSEEK_API_KEY`,不进 settings.yaml。
const route = row.route ?? ''
if (route !== '') {
if (row.baseUrl === null || row.baseUrl === '') {
providers.push({ route, apiKeyEnv: ref })
} else {
providers.push({
route,
apiKeyEnv: ref,
baseURL: row.baseUrl,
api: normalizeProtocol(row.api),
models: parseModels(row.models),
})
}
}
}
const credFile = join(owner.home_dir, '.credentials.yaml')