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:
1 parent
918f1d3a51
commit
022b1f770c
7 files changed
+489
-121
No files matched your search
@@ -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,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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user