dsh-dock 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ const modelsStore = {
15
15
  this.set({ loading: true, error: null });
16
16
  // Provider 可在 DSH 原生模型设置中随时增删;不要复用浏览器缓存,
17
17
  // 否则返回本页时仍可能看到旧的共享快照。
18
- fetch("/dsh-dock/models", { cache: "no-store", signal: AbortSignal.timeout(20000) })
18
+ fetch("dsh-dock/models", { cache: "no-store", signal: AbortSignal.timeout(20000) })
19
19
  .then((res) => (res.ok ? res.json() : Promise.reject(new Error("模型目录接口 HTTP " + res.status))))
20
20
  .then((data) => this.set({ data: data, loading: false, error: null }))
21
21
  .catch((e) => this.set({ loading: false, error: (e && e.message) || String(e) }));
@@ -38,6 +38,100 @@ const DKM_TAGS = [["video", "视频"], ["audio", "音频"], ["document", "文档
38
38
  const DKM_PI_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
39
39
  const DKM_DS_LEVELS = ["off", "low", "high", "max"];
40
40
  const DKM_EFFORT_NAMES = { off: "关闭", minimal: "极低", low: "低", medium: "中", high: "高", xhigh: "超高", max: "最高" };
41
+ // Provider 级增删改:api 协议选项(与宿主 PI_AI_APIS 一致)与路由 key 约束(与宿主同规则)
42
+ const DKM_APIS = [
43
+ ["openai-completions", "OpenAI 兼容(chat/completions)"],
44
+ ["openai-responses", "OpenAI Responses"],
45
+ ["anthropic-messages", "Anthropic Messages"]
46
+ ];
47
+ const DKM_ROUTE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
48
+
49
+ /** 由路由 key 推导凭据库条目名:jiyuanlvdong -> JIYUANLVDONG_API_KEY。让 apiKeyEnv 可以真的留空。 */
50
+ function dkmEnvFromRoute(route) {
51
+ const s = String(route || "").toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
52
+ if (!s) return "";
53
+ return (/^[0-9]/.test(s) ? "KEY_" + s : s) + "_API_KEY";
54
+ }
55
+ const DKM_ALL_INPUT = DKM_MODALITIES.map((x) => x[0]);
56
+ const DKM_ALL_TAGS = DKM_TAGS.map((x) => x[0]);
57
+
58
+ function dkmToggleIn(list, v) {
59
+ return list.indexOf(v) >= 0 ? list.filter((x) => x !== v) : list.concat([v]);
60
+ }
61
+ function dkmAllInputs(on) {
62
+ return { input: on ? DKM_ALL_INPUT.slice() : [], tags: on ? DKM_ALL_TAGS.slice() : [] };
63
+ }
64
+ function dkmAllLevels(on) {
65
+ const lv = {};
66
+ for (const k of DKM_PI_LEVELS) lv[k] = !!on;
67
+ return { effortsMode: "custom", effortLevels: lv };
68
+ }
69
+
70
+ /** 单个目录模型条目 → 可编辑草稿(目录编辑器与 Provider 表单共用)。 */
71
+ function draftFromModel(m) {
72
+ // effortMap: {档位: wire},声明过(wire 字符串或 null)即视为勾选,undefined = 未提供
73
+ const levels = {};
74
+ if (m.efforts === "custom" && m.effortMap && typeof m.effortMap === "object") {
75
+ for (const lv of DKM_PI_LEVELS) levels[lv] = m.effortMap[lv] !== undefined;
76
+ }
77
+ return {
78
+ id: m.id || "",
79
+ name: m.name || "",
80
+ contextWindow: m.contextWindow != null ? String(m.contextWindow) : "",
81
+ maxTokens: m.maxTokens != null ? String(m.maxTokens) : "",
82
+ input: m.input && m.input.length ? m.input.slice() : ["text"],
83
+ tags: (m.tags || []).slice(),
84
+ effortsMode: m.efforts === "custom" ? "custom" : m.efforts === "off" ? "off" : "inherit",
85
+ effortLevels: levels,
86
+ raw: m.raw && typeof m.raw === "object" ? m.raw : null
87
+ };
88
+ }
89
+
90
+ /** 空白模型行(新增 Provider / 添加模型用)。 */
91
+ function emptyModelDraft() {
92
+ return { id: "", name: "", contextWindow: "", maxTokens: "", input: ["text"], tags: [], effortsMode: "inherit", effortLevels: {}, raw: null };
93
+ }
94
+
95
+ /** 新增 Provider 的空表单。 */
96
+ function emptyProviderForm() {
97
+ return {
98
+ route: "",
99
+ displayName: "",
100
+ api: "openai-completions",
101
+ baseURL: "",
102
+ apiKeyEnv: "",
103
+ apiKey: "",
104
+ defaultInput: ["text"],
105
+ models: [],
106
+ extra: {}
107
+ };
108
+ }
109
+
110
+ /**
111
+ * 由目录 Provider 视图构建「编辑 Provider」表单。
112
+ * 以用户层原始配置(rawProfile)为准回填;面板不负责的未知字段(自配 compat/description 等)
113
+ * 收进 extra,保存时原样并回,避免把用户手写配置抹掉。API Key 明文永不回显。
114
+ */
115
+ function providerFormFromProvider(p) {
116
+ const raw = p.rawProfile && typeof p.rawProfile === "object" && !Array.isArray(p.rawProfile) ? p.rawProfile : {};
117
+ const extra = Object.assign({}, raw);
118
+ delete extra.displayName; delete extra.api; delete extra.baseURL;
119
+ delete extra.apiKeyEnv; delete extra.defaultInput; delete extra.models;
120
+ const models = draftFromProvider(p).models;
121
+ return {
122
+ route: p.route || p.id || "",
123
+ displayName: typeof raw.displayName === "string" ? raw.displayName : (p.displayName || ""),
124
+ api: typeof raw.api === "string" && raw.api ? raw.api : (p.api || "openai-completions"),
125
+ baseURL: typeof raw.baseURL === "string" ? raw.baseURL : (p.baseURL || ""),
126
+ apiKeyEnv: typeof raw.apiKeyEnv === "string" ? raw.apiKeyEnv : (p.apiKeyEnv || ""),
127
+ apiKey: "",
128
+ defaultInput: Array.isArray(raw.defaultInput) && raw.defaultInput.length
129
+ ? raw.defaultInput.slice()
130
+ : (Array.isArray(p.defaultInput) && p.defaultInput.length ? p.defaultInput.slice() : ["text"]),
131
+ models: models.length ? models : [emptyModelDraft()],
132
+ extra
133
+ };
134
+ }
41
135
 
42
136
  /** 从目录 Provider 视图构建可编辑草稿。 */
43
137
  function draftFromProvider(p) {
@@ -46,31 +140,87 @@ function draftFromProvider(p) {
46
140
  kind: p.kind,
47
141
  thinking: p.thinking === "disabled" ? "disabled" : "enabled",
48
142
  defaultEffort: DKM_DS_LEVELS.indexOf(p.defaultEffort) >= 0 ? p.defaultEffort : "high",
49
- models: (p.models || []).map((m) => {
50
- // effortMap: {档位: wire},声明过(wire 字符串或 null)即视为勾选,undefined = 未提供
51
- const levels = {};
52
- if (m.efforts === "custom" && m.effortMap && typeof m.effortMap === "object") {
53
- for (const lv of DKM_PI_LEVELS) levels[lv] = m.effortMap[lv] !== undefined;
54
- }
55
- return {
56
- id: m.id || "",
57
- name: m.name || "",
58
- contextWindow: m.contextWindow != null ? String(m.contextWindow) : "",
59
- maxTokens: m.maxTokens != null ? String(m.maxTokens) : "",
60
- input: m.input && m.input.length ? m.input.slice() : ["text"],
61
- tags: (m.tags || []).slice(),
62
- effortsMode: m.efforts === "custom" ? "custom" : m.efforts === "off" ? "off" : "inherit",
63
- effortLevels: levels,
64
- raw: m.raw && typeof m.raw === "object" ? m.raw : null
65
- };
66
- })
143
+ models: (p.models || []).map(draftFromModel)
67
144
  };
68
145
  }
69
146
 
70
- function ModelsView() {
147
+ /**
148
+ * 单个模型编辑行(Provider 表单与目录模型编辑器共用;无 hook,可直接当纯函数调用)。
149
+ * props: { m, i, kind, onPatch(i, patch), onDel(i) }
150
+ */
151
+ function ModelRow(props) {
152
+ const m = props.m, i = props.i, kind = props.kind, onPatch = props.onPatch, onDel = props.onDel;
153
+ return react.createElement("div", { key: i, className: "dkm-model" },
154
+ react.createElement("div", { className: "dkm-model-head" },
155
+ react.createElement("span", { className: "dkm-model-idx" }, "#" + (i + 1)),
156
+ react.createElement("button", { type: "button", className: "dkm-del", onClick: () => onDel(i) }, "删除")),
157
+ react.createElement("div", { className: "dkm-grid" },
158
+ react.createElement("input", { className: "dkm-input", placeholder: "模型 id(必填,如 deepseek-v4-pro)", value: m.id, onChange: (e) => onPatch(i, { id: e.target.value }) }),
159
+ react.createElement("input", { className: "dkm-input", placeholder: "显示名称(可选)", value: m.name, onChange: (e) => onPatch(i, { name: e.target.value }) }),
160
+ react.createElement("input", { className: "dkm-input", placeholder: "上下文窗口", inputMode: "numeric", value: m.contextWindow, onChange: (e) => onPatch(i, { contextWindow: e.target.value }) }),
161
+ react.createElement("input", { className: "dkm-input", placeholder: "最大输出", inputMode: "numeric", value: m.maxTokens, onChange: (e) => onPatch(i, { maxTokens: e.target.value }) })),
162
+ react.createElement("div", { className: "dkm-checks" },
163
+ react.createElement("span", { className: "dkm-label" }, "输入类型:"),
164
+ DKM_MODALITIES.map(([v, label]) => react.createElement("label", {
165
+ key: v,
166
+ className: "dkm-check",
167
+ title: v === "image" ? "由你声明该端点支持图片:保存后原图直发,并可作为视觉模型候选。系统不会替你猜测;端点实际不支持时取消勾选即可恢复走图片理解代理" : null
168
+ },
169
+ react.createElement("input", {
170
+ type: "checkbox",
171
+ checked: m.input.indexOf(v) >= 0,
172
+ onChange: () => {
173
+ const next = dkmToggleIn(m.input, v);
174
+ onPatch(i, { input: next.length > 0 ? next : ["text"] });
175
+ }
176
+ }),
177
+ label)),
178
+ react.createElement("span", { className: "dkm-label dkm-tag", title: "标注仅在本面板展示与持久化,不参与请求路由(官方 schema 只接受文本/图片)" }, "标注:"),
179
+ DKM_TAGS.map(([v, label]) => react.createElement("label", { key: v, className: "dkm-check dkm-tag" },
180
+ react.createElement("input", { type: "checkbox", checked: m.tags.indexOf(v) >= 0, onChange: () => onPatch(i, { tags: dkmToggleIn(m.tags, v) }) }),
181
+ label)),
182
+ react.createElement("button", { type: "button", className: "dkm-mini", title: "一键勾选全部输入类型与标注", onClick: () => onPatch(i, dkmAllInputs(true)) }, "全选"),
183
+ react.createElement("button", { type: "button", className: "dkm-mini", title: "一键取消全部勾选(输入留空 = 继承目录默认)", onClick: () => onPatch(i, dkmAllInputs(false)) }, "取消全选")),
184
+ react.createElement("div", { className: "dkm-efforts" },
185
+ react.createElement("span", { className: "dkm-label" }, "思考强度:"),
186
+ kind === "deepseek"
187
+ ? react.createElement("span", { className: "dkm-sub" }, "随 Provider 设置(Off/Low/High/Max,全模型共享)")
188
+ : react.createElement("span", null,
189
+ react.createElement("select", {
190
+ className: "dkm-select",
191
+ value: m.effortsMode,
192
+ onChange: (e) => onPatch(i, {
193
+ effortsMode: e.target.value,
194
+ effortLevels: e.target.value === "custom" && m.effortsMode !== "custom"
195
+ ? { off: true, low: true, high: true }
196
+ : m.effortLevels
197
+ })
198
+ },
199
+ react.createElement("option", { value: "inherit" }, "跟随目录默认"),
200
+ react.createElement("option", { value: "off" }, "不支持思考"),
201
+ react.createElement("option", { value: "custom" }, "自定义档位")),
202
+ m.effortsMode === "custom"
203
+ ? DKM_PI_LEVELS.map((lv) => react.createElement("label", { key: lv, className: "dkm-check" },
204
+ react.createElement("input", {
205
+ type: "checkbox",
206
+ checked: !!m.effortLevels[lv],
207
+ onChange: () => {
208
+ const nl = Object.assign({}, m.effortLevels);
209
+ nl[lv] = !nl[lv];
210
+ onPatch(i, { effortLevels: nl });
211
+ }
212
+ }),
213
+ DKM_EFFORT_NAMES[lv] || lv))
214
+ : null,
215
+ react.createElement("button", { type: "button", className: "dkm-mini", title: "一键勾选全部强度档(切为自定义)", onClick: () => onPatch(i, dkmAllLevels(true)) }, "全选"),
216
+ react.createElement("button", { type: "button", className: "dkm-mini", title: "一键取消全部强度档", onClick: () => onPatch(i, dkmAllLevels(false)) }, "取消全选"))));
217
+ }
218
+
219
+ function ModelsView(props) {
71
220
  const snap = useModels();
72
221
  const data = snap.data;
73
222
  const providers = data && Array.isArray(data.providers) ? data.providers : [];
223
+ const params = props && props.params;
74
224
  const [selId, setSelId] = react.useState(null);
75
225
  const [draft, setDraft] = react.useState(null);
76
226
  const [builtKey, setBuiltKey] = react.useState(null);
@@ -84,6 +234,10 @@ function ModelsView() {
84
234
  const [vpSaving, setVpSaving] = react.useState(false);
85
235
  const [vpMsg, setVpMsg] = react.useState(null);
86
236
  const [vpDirectExpanded, setVpDirectExpanded] = react.useState(false);
237
+ // 子页面路由:page = "settings"(模型设置主页)| "providers"(模型提供商子页面)
238
+ const [page, setPage] = react.useState("settings");
239
+ // 进入「模型提供商」子页面时是否直接展开新增表单(deep-link:params.openProviderForm)
240
+ const [pageOpenCreate, setPageOpenCreate] = react.useState(false);
87
241
 
88
242
  // 每次进入模型设置都重新读取官方目录。modelsStore 是跨页面共享快照,
89
243
  // 原生设置刚新增 Provider 时不能要求用户再滚到页面底部手动刷新。
@@ -112,7 +266,7 @@ function ModelsView() {
112
266
  const saveVp = () => {
113
267
  if (!vpDraft || vpSaving) return;
114
268
  setVpSaving(true);
115
- fetch("/dsh-dock/models", {
269
+ fetch("dsh-dock/models", {
116
270
  method: "POST",
117
271
  headers: { "content-type": "application/json" },
118
272
  body: JSON.stringify({ visionProxy: vpDraft, revisions: (data && data.revisions) || {} }),
@@ -152,6 +306,14 @@ function ModelsView() {
152
306
  const bulkModels = (fn) => patchDraft({ models: draft.models.map((m) => Object.assign({}, m, fn(m))) });
153
307
  const selectProvider = (id) => { setSelId(id); setMsg(null); };
154
308
 
309
+ // deep-link:params.page === "providers" 直达「模型提供商」子页面;
310
+ // params.openProviderForm 再直接展开新增表单(设置页深链与渲染回归测试共用)。
311
+ react.useEffect(() => {
312
+ if (params && params.page === "providers") setPage("providers");
313
+ if (params && params.openProviderForm) { setPage("providers"); setPageOpenCreate(true); }
314
+ // eslint-disable-next-line react-hooks/exhaustive-deps
315
+ }, []);
316
+
155
317
  const save = () => {
156
318
  if (!draft || !cur || !dirty || saving) return;
157
319
  const models = draft.models.map((m) => ({
@@ -171,7 +333,7 @@ function ModelsView() {
171
333
  payload.defaultEffort = draft.defaultEffort;
172
334
  }
173
335
  setSaving(true);
174
- fetch("/dsh-dock/models", {
336
+ fetch("dsh-dock/models", {
175
337
  method: "POST",
176
338
  headers: { "content-type": "application/json" },
177
339
  body: JSON.stringify(payload),
@@ -192,74 +354,28 @@ function ModelsView() {
192
354
  .then(() => setSaving(false));
193
355
  };
194
356
 
195
- const modelRow = (m, i) => react.createElement("div", { key: i, className: "dkm-model" },
196
- react.createElement("div", { className: "dkm-model-head" },
197
- react.createElement("span", { className: "dkm-model-idx" }, "#" + (i + 1)),
198
- react.createElement("button", {
199
- type: "button",
200
- className: "dkm-del",
201
- onClick: () => patchDraft({ models: draft.models.filter((_, j) => j !== i) })
202
- }, "删除")),
203
- react.createElement("div", { className: "dkm-grid" },
204
- react.createElement("input", { className: "dkm-input", placeholder: "模型 id(必填,如 deepseek-v4-pro)", value: m.id, onChange: (e) => patchModel(i, { id: e.target.value }) }),
205
- react.createElement("input", { className: "dkm-input", placeholder: "显示名称(可选)", value: m.name, onChange: (e) => patchModel(i, { name: e.target.value }) }),
206
- react.createElement("input", { className: "dkm-input", placeholder: "上下文窗口", inputMode: "numeric", value: m.contextWindow, onChange: (e) => patchModel(i, { contextWindow: e.target.value }) }),
207
- react.createElement("input", { className: "dkm-input", placeholder: "最大输出", inputMode: "numeric", value: m.maxTokens, onChange: (e) => patchModel(i, { maxTokens: e.target.value }) })),
208
- react.createElement("div", { className: "dkm-checks" },
209
- react.createElement("span", { className: "dkm-label" }, "输入类型:"),
210
- DKM_MODALITIES.map(([v, label]) => react.createElement("label", {
211
- key: v,
212
- className: "dkm-check",
213
- title: v === "image" ? "由你声明该端点支持图片:保存后原图直发,并可作为视觉模型候选。系统不会替你猜测;端点实际不支持时取消勾选即可恢复走图片理解代理" : null
214
- },
215
- react.createElement("input", {
216
- type: "checkbox",
217
- checked: m.input.indexOf(v) >= 0,
218
- onChange: () => {
219
- const next = toggleIn(m.input, v);
220
- patchModel(i, { input: next.length > 0 ? next : ["text"] });
221
- }
222
- }),
223
- label)),
224
- react.createElement("span", { className: "dkm-label dkm-tag", title: "标注仅在本面板展示与持久化,不参与请求路由(官方 schema 只接受文本/图片)" }, "标注:"),
225
- DKM_TAGS.map(([v, label]) => react.createElement("label", { key: v, className: "dkm-check dkm-tag" },
226
- react.createElement("input", { type: "checkbox", checked: m.tags.indexOf(v) >= 0, onChange: () => patchModel(i, { tags: toggleIn(m.tags, v) }) }),
227
- label)),
228
- react.createElement("button", { type: "button", className: "dkm-mini", title: "一键勾选全部输入类型与标注", onClick: () => patchModel(i, allInputs(true)) }, "全选"),
229
- react.createElement("button", { type: "button", className: "dkm-mini", title: "一键取消全部勾选(输入留空 = 继承目录默认)", onClick: () => patchModel(i, allInputs(false)) }, "取消全选")),
230
- react.createElement("div", { className: "dkm-efforts" },
231
- react.createElement("span", { className: "dkm-label" }, "思考强度:"),
232
- cur.kind === "deepseek"
233
- ? react.createElement("span", { className: "dkm-sub" }, "随 Provider 设置(Off/Low/High/Max,全模型共享)")
234
- : react.createElement("span", null,
235
- react.createElement("select", {
236
- className: "dkm-select",
237
- value: m.effortsMode,
238
- onChange: (e) => patchModel(i, {
239
- effortsMode: e.target.value,
240
- effortLevels: e.target.value === "custom" && m.effortsMode !== "custom"
241
- ? { off: true, low: true, high: true }
242
- : m.effortLevels
243
- })
244
- },
245
- react.createElement("option", { value: "inherit" }, "跟随目录默认"),
246
- react.createElement("option", { value: "off" }, "不支持思考"),
247
- react.createElement("option", { value: "custom" }, "自定义档位")),
248
- m.effortsMode === "custom"
249
- ? DKM_PI_LEVELS.map((lv) => react.createElement("label", { key: lv, className: "dkm-check" },
250
- react.createElement("input", {
251
- type: "checkbox",
252
- checked: !!m.effortLevels[lv],
253
- onChange: () => {
254
- const nl = Object.assign({}, m.effortLevels);
255
- nl[lv] = !nl[lv];
256
- patchModel(i, { effortLevels: nl });
257
- }
258
- }),
259
- DKM_EFFORT_NAMES[lv] || lv))
260
- : null,
261
- react.createElement("button", { type: "button", className: "dkm-mini", title: "一键勾选全部强度档(切为自定义)", onClick: () => patchModel(i, allLevels(true)) }, "全选"),
262
- react.createElement("button", { type: "button", className: "dkm-mini", title: "一键取消全部强度档", onClick: () => patchModel(i, allLevels(false)) }, "取消全选"))));
357
+ // 目录模型编辑行:委托给共用的 ModelRow(kind 用当前 Provider 的类型)
358
+ const modelRow = (m, i) => ModelRow({
359
+ m, i, kind: cur.kind,
360
+ onPatch: (j, patch) => patchModel(j, patch),
361
+ onDel: (j) => patchDraft({ models: draft.models.filter((_, k) => k !== j) })
362
+ });
363
+
364
+ // ---- 子页面:模型提供商 ----
365
+ // 整块替换模型设置主页内容(官方「模型」设置页同款布局)。这里是独立组件,
366
+ // 因此返回主页时自动卸载——未保存的 Provider 草稿不会悄悄留在后台。
367
+ if (page === "providers") {
368
+ return react.createElement(ProvidersPage, {
369
+ data: data,
370
+ providers: providers,
371
+ loading: snap.loading,
372
+ error: snap.error,
373
+ openCreate: pageOpenCreate,
374
+ onCreateHandled: () => setPageOpenCreate(false),
375
+ // 返回主页时顺手重读目录:子页面里增删改过的 Provider 立即反映到模型编辑器
376
+ onBack: () => { setPage("settings"); refreshModels(); }
377
+ });
378
+ }
263
379
 
264
380
  const body = [];
265
381
  if (data) {
@@ -367,6 +483,33 @@ function ModelsView() {
367
483
  : react.createElement("span", { className: "dkm-msg" }, vpDirty ? "有未保存的修改" : ""))));
368
484
  }
369
485
 
486
+ // ---- 模型提供商入口卡(子页面入口;卡片外观与官方模型管理页的行卡片一致)----
487
+ if (data) {
488
+ const custom = providers.filter((p) => p.kind === "pi-ai");
489
+ const missingKey = custom.filter((p) => p.apiKeyEnv && !p.apiKeyConfigured).length;
490
+ body.push(react.createElement("div", { key: "pf-entry", className: "dkp-card" },
491
+ react.createElement("div", { className: "dkp-rowhead" },
492
+ react.createElement("span", { className: "dkp-identity" },
493
+ react.createElement("span", { className: "dkp-name" }, "模型提供商"),
494
+ react.createElement("span", { className: "dkp-tag" }, custom.length + " 个自定义路由"),
495
+ custom.length > 0
496
+ ? react.createElement("span", {
497
+ className: "dkp-dot " + (missingKey > 0 ? "warn" : "ok"),
498
+ role: "img",
499
+ title: missingKey > 0 ? "有 " + missingKey + " 个 Provider 未配置密钥" : "密钥均已配置",
500
+ "aria-label": missingKey > 0 ? "有 Provider 未配置密钥" : "密钥均已配置"
501
+ })
502
+ : null),
503
+ react.createElement("span", { className: "dkp-actions" },
504
+ react.createElement("button", {
505
+ type: "button",
506
+ className: "dkp-btn ghost sm",
507
+ onClick: () => setPage("providers")
508
+ }, "管理"))),
509
+ react.createElement("p", { className: "dkp-hint" },
510
+ "新增 / 编辑 / 删除自定义模型 Provider(OpenAI 兼容、Anthropic 等),写入官方配置并热生效;API Key 存本机凭据库(文件仅 root 可读)。")));
511
+ }
512
+
370
513
  if (data && providers.length === 0) {
371
514
  body.push(react.createElement("div", { key: "empty", className: "dkm-note" }, "没有可编辑的模型 Provider(Host 侧尚未配置)"));
372
515
  }
@@ -423,6 +566,441 @@ function ModelsView() {
423
566
  return react.createElement("div", { className: "dkm-list" }, body);
424
567
  }
425
568
 
569
+ /**
570
+ * Provider 表单里的单个模型行(官方模型行同款:id / 名称 / 容量 一行网格,下方输入类型与思考强度)。
571
+ * props: { m, i, onPatch(i, patch), onDel(i) }
572
+ */
573
+ function ProviderModelRow(props) {
574
+ const m = props.m;
575
+ const i = props.i;
576
+ const check = (v, list, kind) => react.createElement("label", { key: v[0], className: "dkp-check" },
577
+ react.createElement("input", {
578
+ type: "checkbox",
579
+ checked: list.indexOf(v[0]) >= 0,
580
+ onChange: () => {
581
+ const next = dkmToggleIn(list, v[0]);
582
+ props.onPatch(i, kind === "input" ? { input: next.length > 0 ? next : ["text"] } : { tags: next });
583
+ }
584
+ }),
585
+ v[1]);
586
+ return react.createElement("div", { className: "dkp-model-entry" },
587
+ react.createElement("div", { className: "dkp-model-row" },
588
+ react.createElement("input", { className: "dkp-input", placeholder: "模型 id(必填)", value: m.id, onChange: (e) => props.onPatch(i, { id: e.target.value }) }),
589
+ react.createElement("input", { className: "dkp-input", placeholder: "显示名称(可选)", value: m.name, onChange: (e) => props.onPatch(i, { name: e.target.value }) }),
590
+ react.createElement("input", { className: "dkp-input", placeholder: "上下文窗口", inputMode: "numeric", value: m.contextWindow, onChange: (e) => props.onPatch(i, { contextWindow: e.target.value }) }),
591
+ react.createElement("input", { className: "dkp-input", placeholder: "最大输出", inputMode: "numeric", value: m.maxTokens, onChange: (e) => props.onPatch(i, { maxTokens: e.target.value }) }),
592
+ react.createElement("button", { type: "button", className: "dkp-iconbtn danger", title: "删除该模型", "aria-label": "删除模型", onClick: () => props.onDel(i) }, "✕")),
593
+ react.createElement("div", { className: "dkp-model-opts" },
594
+ react.createElement("div", { className: "dkp-checks" },
595
+ react.createElement("span", { className: "dkp-flabel" }, "输入类型:"),
596
+ DKM_MODALITIES.map((v) => check(v, m.input, "input")),
597
+ react.createElement("span", { className: "dkp-flabel dkp-dim", title: "标注仅在本面板展示与持久化,不参与请求路由(官方 schema 只接受文本/图片)" }, "标注:"),
598
+ DKM_TAGS.map((v) => check(v, m.tags, "tags")),
599
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键勾选全部输入类型与标注", onClick: () => props.onPatch(i, dkmAllInputs(true)) }, "全选"),
600
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键取消全部勾选", onClick: () => props.onPatch(i, dkmAllInputs(false)) }, "取消全选")),
601
+ react.createElement("div", { className: "dkp-checks" },
602
+ react.createElement("span", { className: "dkp-flabel" }, "思考强度:"),
603
+ react.createElement("select", {
604
+ className: "dkp-input dkp-select dkp-select-sm",
605
+ value: m.effortsMode,
606
+ onChange: (e) => props.onPatch(i, {
607
+ effortsMode: e.target.value,
608
+ effortLevels: e.target.value === "custom" && m.effortsMode !== "custom"
609
+ ? { off: true, low: true, high: true }
610
+ : m.effortLevels
611
+ })
612
+ },
613
+ react.createElement("option", { value: "inherit" }, "跟随目录默认"),
614
+ react.createElement("option", { value: "off" }, "不支持思考"),
615
+ react.createElement("option", { value: "custom" }, "自定义档位")),
616
+ m.effortsMode === "custom"
617
+ ? DKM_PI_LEVELS.map((lv) => react.createElement("label", { key: lv, className: "dkp-check" },
618
+ react.createElement("input", {
619
+ type: "checkbox",
620
+ checked: !!m.effortLevels[lv],
621
+ onChange: () => {
622
+ const nl = Object.assign({}, m.effortLevels);
623
+ nl[lv] = !nl[lv];
624
+ props.onPatch(i, { effortLevels: nl });
625
+ }
626
+ }),
627
+ DKM_EFFORT_NAMES[lv] || lv))
628
+ : null,
629
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键勾选全部强度档(切为自定义)", onClick: () => props.onPatch(i, dkmAllLevels(true)) }, "全选"),
630
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键取消全部强度档", onClick: () => props.onPatch(i, dkmAllLevels(false)) }, "取消全选"))));
631
+ }
632
+
633
+ /**
634
+ * 模型提供商子页面:官方「模型」设置页同款布局——
635
+ * 返回入口 + 标题 + 说明 + 行卡片列表(每行可展开同一个编辑卡片)+ 底部虚线「新增 Provider」。
636
+ * 只管自定义 pi-ai 路由的增删改(内置 DeepSeek 官方 Provider 在主页编辑其模型目录)。
637
+ * 由 ModelsView 在 page === "providers" 时整页替换渲染;返回主页即卸载,草稿不会留在后台。
638
+ */
639
+ function ProvidersPage(props) {
640
+ const data = props.data;
641
+ const providers = props.providers || [];
642
+ const piProviders = providers.filter((p) => p.kind === "pi-ai");
643
+ // pfMode = "create" | "edit" | null;pfDraft 为对应草稿(同一时刻只开一个编辑卡片)
644
+ const [pfMode, setPfMode] = react.useState(null);
645
+ const [pfDraft, setPfDraft] = react.useState(null);
646
+ const [pfDirty, setPfDirty] = react.useState(false);
647
+ const [pfSaving, setPfSaving] = react.useState(false);
648
+ const [pfMsg, setPfMsg] = react.useState(null);
649
+ const [pfDelArm, setPfDelArm] = react.useState(null);
650
+ const [pfFetching, setPfFetching] = react.useState(false);
651
+ const [pfFetchedList, setPfFetchedList] = react.useState(null);
652
+
653
+ const openPfCreate = () => {
654
+ setPfMode("create"); setPfDraft(emptyProviderForm()); setPfDirty(false);
655
+ setPfMsg(null); setPfDelArm(null); setPfFetchedList(null);
656
+ };
657
+ const openPfEdit = (p) => {
658
+ setPfMode("edit"); setPfDraft(providerFormFromProvider(p)); setPfDirty(false);
659
+ setPfMsg(null); setPfDelArm(null); setPfFetchedList(null);
660
+ };
661
+ const closePf = () => {
662
+ setPfMode(null); setPfDraft(null); setPfDirty(false);
663
+ setPfMsg(null); setPfDelArm(null); setPfFetchedList(null);
664
+ };
665
+ const patchPf = (patch) => { setPfDraft(Object.assign({}, pfDraft, patch)); setPfDirty(true); setPfMsg(null); };
666
+ const patchPfModel = (i, patch) => {
667
+ const models = pfDraft.models.slice();
668
+ models[i] = Object.assign({}, models[i], patch);
669
+ patchPf({ models: models });
670
+ };
671
+ const delPfModel = (i) => patchPf({ models: pfDraft.models.filter((_, j) => j !== i) });
672
+ const addPfModel = () => patchPf({ models: pfDraft.models.concat([emptyModelDraft()]) });
673
+ // 勾选式选模型:checked 状态直接映射「该 id 是否在草稿模型列表里」
674
+ const togglePfModel = (id, name, on) => {
675
+ const base = pfDraft.models.filter((m) => String(m.id || "").trim());
676
+ if (on) {
677
+ if (base.some((m) => String(m.id).trim() === id)) return;
678
+ base.push(Object.assign(emptyModelDraft(), { id: id, name: name || "", input: pfDraft.defaultInput.slice() }));
679
+ } else {
680
+ const i = base.findIndex((m) => String(m.id).trim() === id);
681
+ if (i >= 0) base.splice(i, 1);
682
+ }
683
+ patchPf({ models: base });
684
+ };
685
+ const setAllPfModels = (on) => {
686
+ const base = [];
687
+ if (on && pfFetchedList) {
688
+ for (const mm of pfFetchedList) {
689
+ const id = String((mm && mm.id) || "").trim();
690
+ if (!id) continue;
691
+ base.push(Object.assign(emptyModelDraft(), { id: id, name: (mm && mm.name) || "", input: pfDraft.defaultInput.slice() }));
692
+ }
693
+ }
694
+ patchPf({ models: base });
695
+ };
696
+ const pfSelectedCount = () => pfDraft ? pfDraft.models.filter((m) => String(m.id || "").trim()).length : 0;
697
+ const pfModelsPayload = () => pfDraft.models.map((m) => ({
698
+ id: String(m.id || "").trim(),
699
+ name: String(m.name || "").trim(),
700
+ contextWindow: m.contextWindow === "" || m.contextWindow == null ? undefined : Number(m.contextWindow),
701
+ maxTokens: m.maxTokens === "" || m.maxTokens == null ? undefined : Number(m.maxTokens),
702
+ input: m.input,
703
+ tags: m.tags,
704
+ effortsMode: m.effortsMode,
705
+ effortLevels: m.effortLevels,
706
+ raw: m.raw
707
+ }));
708
+ // 自动获取模型列表:宿主代打 GET {baseURL}/models(Key 还没保存也能先拉)
709
+ const fetchPfModels = () => {
710
+ if (!pfDraft || pfFetching) return;
711
+ setPfFetching(true); setPfMsg(null);
712
+ fetch("dsh-dock/models", {
713
+ method: "POST",
714
+ headers: { "content-type": "application/json" },
715
+ body: JSON.stringify({
716
+ action: "listModels",
717
+ baseURL: pfDraft.baseURL,
718
+ api: pfDraft.api,
719
+ apiKeyEnv: pfDraft.apiKeyEnv,
720
+ apiKey: pfDraft.apiKey
721
+ }),
722
+ signal: AbortSignal.timeout(30000)
723
+ })
724
+ .then((res) => res.json()
725
+ .then((b) => ({ status: res.status, body: b }))
726
+ .catch(() => ({ status: res.status, body: {} })))
727
+ .then((r) => {
728
+ if (r.status === 200 && r.body && r.body.ok) {
729
+ setPfFetchedList(Array.isArray(r.body.models) ? r.body.models : []);
730
+ } else {
731
+ setPfMsg({ ok: false, text: (r.body && r.body.error) || ("HTTP " + r.status) });
732
+ }
733
+ })
734
+ .catch((e) => setPfMsg({ ok: false, text: (e && e.message) || String(e) }))
735
+ .then(() => setPfFetching(false));
736
+ };
737
+ const savePf = () => {
738
+ if (!pfDraft || pfSaving) return;
739
+ const route = pfDraft.route.trim();
740
+ const baseURL = pfDraft.baseURL.trim();
741
+ const apiKey = pfDraft.apiKey.trim();
742
+ // Key 填了但 apiKeyEnv 留空:按路由 key 自动生成凭据库条目名,不再逼用户现编一个
743
+ let apiKeyEnv = pfDraft.apiKeyEnv.trim();
744
+ if (apiKey && !apiKeyEnv) {
745
+ apiKeyEnv = dkmEnvFromRoute(route);
746
+ setPfDraft(Object.assign({}, pfDraft, { apiKeyEnv: apiKeyEnv }));
747
+ }
748
+ const models = pfModelsPayload();
749
+ if (!route) { setPfMsg({ ok: false, text: "路由 key 不能为空(如 modelscope)" }); return; }
750
+ if (!DKM_ROUTE_KEY_RE.test(route)) { setPfMsg({ ok: false, text: "路由 key 非法:只能含字母、数字与 . _ -,且以字母/数字开头" }); return; }
751
+ if (!/^https?:\/\//i.test(baseURL)) { setPfMsg({ ok: false, text: "baseURL 须以 http:// 或 https:// 开头" }); return; }
752
+ const ids = models.map((m) => m.id);
753
+ if (ids.length === 0 || ids.some((x) => !x)) { setPfMsg({ ok: false, text: "每个模型都要有 id(可点「获取模型列表」自动拉取)" }); return; }
754
+ if (new Set(ids).size !== ids.length) { setPfMsg({ ok: false, text: "模型 id 有重复" }); return; }
755
+ const profile = Object.assign({}, pfDraft.extra || {});
756
+ const dn = pfDraft.displayName.trim();
757
+ if (dn) profile.displayName = dn; else delete profile.displayName;
758
+ profile.api = pfDraft.api;
759
+ profile.baseURL = baseURL;
760
+ if (apiKeyEnv) profile.apiKeyEnv = apiKeyEnv; else delete profile.apiKeyEnv;
761
+ profile.defaultInput = pfDraft.defaultInput;
762
+ profile.models = models;
763
+ const payload = {
764
+ action: pfMode === "create" ? "create" : "update",
765
+ route: route,
766
+ profile: profile,
767
+ revisions: (data && data.revisions) || {}
768
+ };
769
+ if (apiKey) payload.apiKey = apiKey;
770
+ setPfSaving(true); setPfMsg(null);
771
+ fetch("dsh-dock/models", {
772
+ method: "POST",
773
+ headers: { "content-type": "application/json" },
774
+ body: JSON.stringify(payload),
775
+ signal: AbortSignal.timeout(30000)
776
+ })
777
+ .then((res) => res.json()
778
+ .then((b) => ({ status: res.status, body: b }))
779
+ .catch(() => ({ status: res.status, body: {} })))
780
+ .then((r) => {
781
+ if (r.status === 200 && r.body && r.body.ok) {
782
+ setPfMsg({ ok: true, text: "Provider「" + route + "」已" + (pfMode === "create" ? "新增" : "更新") + ";会话模型选择器即时可选" });
783
+ setPfMode(null); setPfDraft(null); setPfDirty(false);
784
+ modelsStore.load();
785
+ } else {
786
+ setPfMsg({ ok: false, text: (r.body && r.body.error) || ("HTTP " + r.status) });
787
+ }
788
+ })
789
+ .catch((e) => setPfMsg({ ok: false, text: (e && e.message) || String(e) }))
790
+ .then(() => setPfSaving(false));
791
+ };
792
+ // 删除:两步确认(先点一次进入待确认态,再点一次真删;不用 window.confirm)
793
+ const deletePf = (p) => {
794
+ const route = p.route || p.id;
795
+ if (pfDelArm !== route) { setPfDelArm(route); setPfMsg(null); return; }
796
+ setPfDelArm(null); setPfSaving(true); setPfMsg(null);
797
+ fetch("dsh-dock/models", {
798
+ method: "POST",
799
+ headers: { "content-type": "application/json" },
800
+ body: JSON.stringify({ action: "delete", route: route, revisions: (data && data.revisions) || {} }),
801
+ signal: AbortSignal.timeout(20000)
802
+ })
803
+ .then((res) => res.json()
804
+ .then((b) => ({ status: res.status, body: b }))
805
+ .catch(() => ({ status: res.status, body: {} })))
806
+ .then((r) => {
807
+ if (r.status === 200 && r.body && r.body.ok) {
808
+ setPfMsg({ ok: true, text: "Provider「" + route + "」已删除" });
809
+ if (pfMode === "edit" && pfDraft && pfDraft.route === route) { setPfMode(null); setPfDraft(null); }
810
+ modelsStore.load();
811
+ } else {
812
+ setPfMsg({ ok: false, text: (r.body && r.body.error) || ("HTTP " + r.status) });
813
+ }
814
+ })
815
+ .catch((e) => setPfMsg({ ok: false, text: (e && e.message) || String(e) }))
816
+ .then(() => setPfSaving(false));
817
+ };
818
+ // deep-link:带 openProviderForm 进入本页时直接展开新增表单(只认第一次,之后由用户自己开关)
819
+ const openedRef = react.useRef(false);
820
+ react.useEffect(() => {
821
+ if (props.openCreate && !openedRef.current) {
822
+ openedRef.current = true;
823
+ openPfCreate();
824
+ if (typeof props.onCreateHandled === "function") props.onCreateHandled();
825
+ }
826
+ // eslint-disable-next-line react-hooks/exhaustive-deps
827
+ }, []);
828
+
829
+ // 官方 ProviderEditor 同款字段:一行一个字段(标签在上、输入铺满),非必填项收进「自定义设置」
830
+ const input = (p) => react.createElement("input", Object.assign({ className: "dkp-input" }, p));
831
+ const field = (label, node, hint) => react.createElement("div", { className: "dkp-field", key: label },
832
+ react.createElement("label", { className: "dkp-flabel" }, label),
833
+ node,
834
+ hint ? react.createElement("p", { className: "dkp-hint" }, hint) : null);
835
+
836
+ const editor = !pfDraft ? null : react.createElement("div", { className: "dkp-editor" },
837
+ react.createElement("div", { className: "dkp-editor-head" },
838
+ react.createElement("span", { className: "dkp-editor-title" }, pfMode === "create" ? "新增 Provider" : "编辑 Provider"),
839
+ pfMode === "edit" ? react.createElement("span", { className: "dkp-editor-route" }, pfDraft.route) : null),
840
+ field("路由 key", input({
841
+ value: pfDraft.route,
842
+ disabled: pfMode === "edit",
843
+ placeholder: "acme-gateway",
844
+ onChange: (e) => patchPf({ route: e.target.value })
845
+ }), pfMode === "edit" ? "路由 key 是配置键,保存后不建议修改。" : "唯一标识:只能含字母、数字与 . _ -,且以字母/数字开头。"),
846
+ field("显示名称", input({
847
+ value: pfDraft.displayName,
848
+ placeholder: pfDraft.route || "如 魔搭 ModelScope",
849
+ onChange: (e) => patchPf({ displayName: e.target.value })
850
+ })),
851
+ field("baseURL", input({
852
+ value: pfDraft.baseURL,
853
+ placeholder: pfDraft.api === "anthropic-messages" ? "https://gateway.example" : "https://gateway.example/v1",
854
+ onChange: (e) => patchPf({ baseURL: e.target.value })
855
+ }), "OpenAI 兼容端点通常以 /v1 结尾;Anthropic Messages 由 SDK 自行追加 /v1/messages。"),
856
+ field("API 协议", react.createElement("select", {
857
+ className: "dkp-input dkp-select",
858
+ value: pfDraft.api,
859
+ onChange: (e) => patchPf({ api: e.target.value })
860
+ },
861
+ DKM_APIS.map(([v, label]) => react.createElement("option", { key: v, value: v }, label)))),
862
+ field("API Key", react.createElement("input", {
863
+ className: "dkp-input",
864
+ type: "password",
865
+ autoComplete: "new-password",
866
+ placeholder: pfMode === "edit" ? "留空 = 不改动已存密钥" : "sk-…(仅本次写入本机凭据库,不回显)",
867
+ value: pfDraft.apiKey,
868
+ onChange: (e) => patchPf({ apiKey: e.target.value })
869
+ })),
870
+ react.createElement("details", { className: "dkp-customized" },
871
+ react.createElement("summary", { className: "dkp-summary" }, "自定义设置"),
872
+ react.createElement("div", { className: "dkp-custombody" },
873
+ field("apiKeyEnv", input({
874
+ value: pfDraft.apiKeyEnv,
875
+ placeholder: "留空 = 按路由 key 自动生成(" + (dkmEnvFromRoute(pfDraft.route) || "ROUTE_API_KEY") + ")",
876
+ onChange: (e) => patchPf({ apiKeyEnv: e.target.value })
877
+ }), "凭据库条目名;留空时按路由 key 推导,一般无需填写。"),
878
+ react.createElement("div", { className: "dkp-field" },
879
+ react.createElement("span", { className: "dkp-flabel" }, "默认输入类型"),
880
+ react.createElement("div", { className: "dkp-checks" },
881
+ DKM_MODALITIES.map(([v, label]) => react.createElement("label", { key: v, className: "dkp-check" },
882
+ react.createElement("input", {
883
+ type: "checkbox",
884
+ checked: pfDraft.defaultInput.indexOf(v) >= 0,
885
+ onChange: () => {
886
+ const next = dkmToggleIn(pfDraft.defaultInput, v);
887
+ patchPf({ defaultInput: next.length > 0 ? next : ["text"] });
888
+ }
889
+ }),
890
+ label)),
891
+ react.createElement("span", { className: "dkp-hint" }, "模型未单独声明输入类型时继承这里。"))))),
892
+ react.createElement("section", { className: "dkp-catalog", "aria-label": "模型" },
893
+ react.createElement("div", { className: "dkp-catalog-head" },
894
+ react.createElement("div", { className: "dkp-catalog-titlewrap" },
895
+ react.createElement("span", { className: "dkp-catalog-title" }, "模型"),
896
+ react.createElement("span", { className: "dkp-catalog-meta" },
897
+ "已选 " + pfSelectedCount() + " 个" + (pfFetchedList ? " / 上游共 " + pfFetchedList.length + " 个" : ""))),
898
+ react.createElement("div", { className: "dkp-actions" },
899
+ react.createElement("button", {
900
+ type: "button",
901
+ className: "dkp-btn ghost sm",
902
+ disabled: pfFetching || !pfDraft.baseURL,
903
+ title: "调用 baseURL/models 拉取该端点可用模型,勾选后加入下方列表",
904
+ onClick: fetchPfModels
905
+ }, pfFetching ? "拉取中…" : "获取模型列表"),
906
+ pfFetchedList && pfFetchedList.length > 0
907
+ ? react.createElement("button", { type: "button", className: "dkp-btn ghost sm", title: "勾选上游返回的全部模型", onClick: () => setAllPfModels(true) }, "全选")
908
+ : null,
909
+ pfFetchedList && pfFetchedList.length > 0
910
+ ? react.createElement("button", { type: "button", className: "dkp-btn ghost sm", title: "清空已勾选的模型", onClick: () => setAllPfModels(false) }, "清空")
911
+ : null)),
912
+ pfFetchedList
913
+ ? react.createElement("div", { className: "dkp-picker" },
914
+ pfFetchedList.length === 0
915
+ ? react.createElement("div", { className: "dkp-hint" }, "上游没有返回任何模型")
916
+ : pfFetchedList.map((mm) => {
917
+ const id = String((mm && mm.id) || "").trim();
918
+ if (!id) return null;
919
+ const checked = pfDraft.models.some((m) => String(m.id || "").trim() === id);
920
+ return react.createElement("label", { key: id },
921
+ react.createElement("input", {
922
+ type: "checkbox",
923
+ checked: checked,
924
+ onChange: () => togglePfModel(id, (mm && mm.name) || "", !checked)
925
+ }),
926
+ react.createElement("span", { className: "dkp-picker-id" }, id),
927
+ (mm && mm.name) ? react.createElement("span", { className: "dkp-picker-name" }, mm.name) : null);
928
+ }))
929
+ : null,
930
+ pfDraft.models.length === 0
931
+ ? react.createElement("div", { className: "dkp-empty" }, "还没有模型——点「获取模型列表」勾选上游模型,或「添加模型」手动填写。")
932
+ : react.createElement("div", { className: "dkp-model-list" },
933
+ pfDraft.models.map((m, i) => react.createElement(ProviderModelRow, { key: i, m: m, i: i, onPatch: patchPfModel, onDel: delPfModel }))),
934
+ react.createElement("button", { type: "button", className: "dkp-addmodel", onClick: addPfModel }, "+ 添加模型")),
935
+ pfMsg && !pfMsg.ok ? react.createElement("p", { className: "dkp-error" }, pfMsg.text) : null,
936
+ react.createElement("div", { className: "dkp-editor-actions" },
937
+ react.createElement("span", { className: "dkp-hint" }, pfDirty ? "有未保存的修改" : ""),
938
+ react.createElement("button", { type: "button", className: "dkp-btn ghost", onClick: closePf }, "取消"),
939
+ react.createElement("button", { type: "button", className: "dkp-btn primary", disabled: pfSaving, onClick: savePf },
940
+ pfSaving ? "保存中…" : (pfMode === "create" ? "新增 Provider" : "保存修改"))));
941
+
942
+ const body = [
943
+ react.createElement("button", { key: "back", type: "button", className: "dkp-back", onClick: props.onBack }, "← 模型设置"),
944
+ react.createElement("h2", { key: "title", className: "dkp-title" }, "模型提供商"),
945
+ react.createElement("p", { key: "intro", className: "dkp-intro" },
946
+ "新增 / 编辑 / 删除自定义模型 Provider(OpenAI 兼容、Anthropic 等),写入官方配置并热生效;API Key 存本机凭据库(文件仅 root 可读)。内置 DeepSeek 官方 Provider 在本页只读,其模型目录请回「模型设置」编辑。")
947
+ ];
948
+ if (props.error) {
949
+ body.push(react.createElement("p", { key: "err", className: "dkp-error" }, "模型目录拉取失败:" + props.error));
950
+ }
951
+ if (pfMsg && !pfDraft) {
952
+ body.push(react.createElement("p", { key: "msg", className: "dkp-msg " + (pfMsg.ok ? "ok" : "err"), role: "status" }, pfMsg.text));
953
+ }
954
+ if (!data) {
955
+ body.push(react.createElement("p", { key: "loading", className: "dkp-hint" }, props.loading ? "正在拉取模型目录…" : "等待拉取模型目录"));
956
+ } else if (piProviders.length === 0) {
957
+ body.push(react.createElement("div", { key: "empty", className: "dkp-empty" }, "还没有自定义 Provider——点下方「新增 Provider」添加。"));
958
+ } else {
959
+ body.push(react.createElement("ul", { key: "rows", className: "dkp-rows" },
960
+ piProviders.map((p) => {
961
+ const route = p.route || p.id;
962
+ const editing = pfMode === "edit" && !!pfDraft && pfDraft.route === route;
963
+ const keyMissing = !!(p.apiKeyEnv && !p.apiKeyConfigured);
964
+ return react.createElement("li", { key: p.id, className: "dkp-card" },
965
+ react.createElement("div", { className: "dkp-rowhead" },
966
+ react.createElement("span", { className: "dkp-identity" },
967
+ react.createElement("span", { className: "dkp-name" }, p.displayName || route),
968
+ react.createElement("span", { className: "dkp-tag" }, route),
969
+ react.createElement("span", { className: "dkp-tag" }, (p.models ? p.models.length : 0) + " 模型"),
970
+ react.createElement("span", {
971
+ className: "dkp-dot " + (keyMissing ? "missing" : "ok"),
972
+ role: "img",
973
+ title: p.apiKeyEnv ? (p.apiKeyConfigured ? p.apiKeyEnv + " 已配置" : p.apiKeyEnv + " 未配置") : "未设 apiKeyEnv",
974
+ "aria-label": p.apiKeyEnv ? (p.apiKeyConfigured ? "密钥已配置" : "密钥未配置") : "未设 apiKeyEnv"
975
+ }),
976
+ p.apiKeyEnv
977
+ ? react.createElement("span", { className: "dkp-tag" + (keyMissing ? " warn" : " ok") },
978
+ p.apiKeyConfigured ? p.apiKeyEnv + " 已配置" : p.apiKeyEnv + " 未配置")
979
+ : null),
980
+ react.createElement("span", { className: "dkp-actions" },
981
+ react.createElement("button", {
982
+ type: "button",
983
+ className: "dkp-btn ghost sm",
984
+ "aria-expanded": editing,
985
+ onClick: () => (editing ? closePf() : openPfEdit(p))
986
+ }, editing ? "收起" : "编辑"),
987
+ react.createElement("button", {
988
+ type: "button",
989
+ className: "dkp-btn danger sm" + (pfDelArm === route ? " arm" : ""),
990
+ title: pfDelArm === route ? "再点一次确认删除" : "删除该 Provider",
991
+ onClick: () => deletePf(p)
992
+ }, pfDelArm === route ? "确认删除?" : "删除"))),
993
+ react.createElement("div", { className: "dkp-sub" }, [p.api, p.baseURL].filter(Boolean).join(" · ")),
994
+ editing ? editor : null);
995
+ })));
996
+ }
997
+ body.push(react.createElement("div", { key: "add", className: "dkp-addblock" },
998
+ pfMode === "create" && pfDraft
999
+ ? react.createElement("div", { className: "dkp-card dkp-addcard" }, editor)
1000
+ : react.createElement("button", { type: "button", className: "dkp-addbtn", onClick: openPfCreate }, "+ 新增 Provider")));
1001
+ return react.createElement("div", { className: "dkp-page" }, body);
1002
+ }
1003
+
426
1004
  function ModelsStat() {
427
1005
  const snap = useModels();
428
1006
  const data = snap.data;
@@ -438,7 +1016,7 @@ export const feature = {
438
1016
  name: "模型设置",
439
1017
  order: 100,
440
1018
  accent: "#22d3ee",
441
- description: "编辑各 Provider 模型目录:输入类型(文本/图片 + 标注)与思考强度档位;写回官方配置热生效,会话模型选择器即时可选",
1019
+ description: "编辑各 Provider 模型目录(输入类型/思考强度档位);自定义 Provider 的增删改在「模型提供商」子页面,写回官方配置热生效,会话模型选择器即时可选",
442
1020
  css: [
443
1021
  ".dkm-note{color:var(--dsw-alias-label-secondary);font-size:12px;}",
444
1022
  ".dkm-warn{color:var(--dk-warn);}",
@@ -483,7 +1061,99 @@ export const feature = {
483
1061
  ".dkm-mini{cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-family:inherit;font-size:11px;flex:none;}",
484
1062
  ".dkm-mini:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-secondary);}",
485
1063
  ".dkm-toolbar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;border-top:1px dashed var(--dsw-alias-border-l1);padding-top:8px;}",
486
- ".dkm-toolbar-label{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none;}"
1064
+ ".dkm-toolbar-label{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none;}",
1065
+ // ---- 模型提供商子页面(dkp- 前缀):对齐官方「模型」设置页的排版与尺寸 ----
1066
+ // 官方用量参考:section 限宽 720、行卡片 .5px 描边 + radius-xl + 12/14 内距、
1067
+ // 输入高 32、按钮高 36(行内小按钮 28)、新增按钮 44 虚线、编辑器 bg-module-platform。
1068
+ ".dkp-page{width:100%;max-width:720px;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);}",
1069
+ ".dkp-back{box-sizing:border-box;align-self:flex-start;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:var(--dsw-radius-sm,6px);padding:4px 8px;margin-left:-8px;font-family:inherit;font-size:12px;line-height:18px;}",
1070
+ ".dkp-back:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
1071
+ ".dkp-title{margin:0;font-size:16px;font-weight:500;line-height:24px;}",
1072
+ ".dkp-intro{margin:0;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:22px;}",
1073
+ ".dkp-rows{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:8px;}",
1074
+ ".dkp-card{box-sizing:border-box;margin:0;border:.5px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l2));background:var(--dsw-alias-settings-card-fill,var(--dsw-alias-bg-layer-1));border-radius:var(--dsw-radius-xl,12px);padding:12px 14px;display:flex;flex-direction:column;gap:10px;}",
1075
+ ".dkp-rowhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}",
1076
+ ".dkp-identity{display:inline-flex;align-items:center;gap:6px;min-width:0;flex-wrap:wrap;}",
1077
+ ".dkp-name{font-size:14px;font-weight:500;line-height:22px;}",
1078
+ ".dkp-tag{flex:none;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-xs,4px);color:var(--dsw-alias-label-secondary);padding:1px 6px;font-size:11px;line-height:16px;}",
1079
+ ".dkp-tag.ok{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
1080
+ ".dkp-tag.warn{color:var(--dk-warn);border-color:currentColor;}",
1081
+ ".dkp-dot{box-sizing:border-box;flex:none;width:8px;height:8px;border-radius:50%;display:inline-block;}",
1082
+ ".dkp-dot.ok{background:var(--dsw-alias-state-success-primary);}",
1083
+ ".dkp-dot.missing{background:var(--dsw-alias-state-error-primary);}",
1084
+ ".dkp-dot.warn{background:var(--dk-warn);}",
1085
+ ".dkp-sub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
1086
+ ".dkp-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;}",
1087
+ ".dkp-dim{color:var(--dsw-alias-label-tertiary);}",
1088
+ ".dkp-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;}",
1089
+ ".dkp-msg{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;}",
1090
+ ".dkp-msg.ok{color:var(--dsw-alias-state-success-primary);}",
1091
+ ".dkp-msg.err{color:var(--dsw-alias-state-error-primary);}",
1092
+ ".dkp-actions{flex:none;margin-left:auto;display:inline-flex;align-items:center;gap:4px;}",
1093
+ ".dkp-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:none;border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;cursor:pointer;}",
1094
+ ".dkp-btn.ghost{border:.5px solid var(--dsw-alias-border-l3);}",
1095
+ ".dkp-btn.ghost:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1096
+ ".dkp-btn.primary{background:var(--dsw-alias-button-primary-fill,var(--dk-accent));color:var(--dsw-alias-label-primary-foreground,#fff);}",
1097
+ ".dkp-btn.primary:hover{background:var(--dsw-alias-button-primary-hover,var(--dk-accent));}",
1098
+ ".dkp-btn.danger{color:var(--dsw-alias-state-error-primary);}",
1099
+ ".dkp-btn.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.1));}",
1100
+ ".dkp-btn.arm{color:var(--dsw-alias-state-error-primary);border-color:currentColor;}",
1101
+ ".dkp-btn.sm{height:28px;padding:0 10px;border-radius:var(--dsw-radius-sm,6px);font-size:12px;line-height:18px;}",
1102
+ ".dkp-btn[disabled]{opacity:.4;cursor:default;}",
1103
+ ".dkp-editor{border-radius:var(--dsw-radius-lg,10px);background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-1));padding:14px 16px;display:flex;flex-direction:column;gap:14px;}",
1104
+ ".dkp-editor-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;}",
1105
+ ".dkp-editor-title{font-size:14px;font-weight:500;line-height:22px;}",
1106
+ ".dkp-editor-route{color:var(--dsw-alias-label-tertiary);font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;line-height:18px;}",
1107
+ ".dkp-field{display:flex;flex-direction:column;gap:6px;min-width:0;}",
1108
+ ".dkp-flabel{display:inline-flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px;}",
1109
+ ".dkp-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4,var(--dsw-alias-border-l2));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;}",
1110
+ ".dkp-input:focus{border-color:var(--dsw-alias-state-business-primary,var(--dk-accent));outline:none;}",
1111
+ ".dkp-input::placeholder{color:var(--dsw-alias-label-dimmed,var(--dsw-alias-label-tertiary));}",
1112
+ ".dkp-input:disabled{opacity:.6;cursor:default;}",
1113
+ "select.dkp-input{appearance:auto;cursor:pointer;max-width:240px;}",
1114
+ ".dkp-select-sm{height:28px;max-width:160px;font-size:12px;line-height:18px;}",
1115
+ ".dkp-checks{display:flex;align-items:center;gap:16px;flex-wrap:wrap;min-height:32px;}",
1116
+ ".dkp-check{display:inline-flex;align-items:center;gap:6px;cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;}",
1117
+ ".dkp-check input{accent-color:var(--dk-accent);margin:0;}",
1118
+ ".dkp-mini{cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:transparent;color:var(--dsw-alias-label-tertiary);padding:1px 8px;font-family:inherit;font-size:11px;line-height:16px;flex:none;}",
1119
+ ".dkp-mini:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-secondary);}",
1120
+ ".dkp-customized{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:10px;}",
1121
+ ".dkp-summary{display:flex;align-items:center;gap:6px;width:fit-content;margin-left:-4px;padding:2px 4px;border-radius:var(--dsw-radius-sm,6px);list-style:none;cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px;}",
1122
+ ".dkp-summary::-webkit-details-marker{display:none;}",
1123
+ ".dkp-summary::before{content:\"\";width:5px;height:5px;border-right:1.5px solid;border-bottom:1.5px solid;transform:rotate(-45deg);transition:transform .12s;}",
1124
+ ".dkp-customized[open]>.dkp-summary::before{transform:rotate(45deg);}",
1125
+ ".dkp-summary:hover{color:var(--dsw-alias-label-primary);}",
1126
+ ".dkp-custombody{display:flex;flex-direction:column;gap:12px;padding-top:12px;}",
1127
+ ".dkp-catalog{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:12px;display:flex;flex-direction:column;gap:10px;}",
1128
+ ".dkp-catalog-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;}",
1129
+ ".dkp-catalog-titlewrap{display:flex;flex-direction:column;gap:2px;}",
1130
+ ".dkp-catalog-title{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px;}",
1131
+ ".dkp-catalog-meta{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;}",
1132
+ ".dkp-model-list{display:flex;flex-direction:column;gap:8px;}",
1133
+ ".dkp-model-entry{border:.5px solid var(--dsw-alias-border-l4,var(--dsw-alias-border-l2));border-radius:var(--dsw-radius-lg,10px);padding:6px;display:flex;flex-direction:column;gap:6px;}",
1134
+ ".dkp-model-row{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr) minmax(0,.7fr) minmax(0,.7fr) auto;gap:6px;align-items:center;}",
1135
+ ".dkp-model-row .dkp-input{height:30px;padding:0 8px;font-size:13px;}",
1136
+ ".dkp-model-opts{display:flex;flex-direction:column;gap:4px;padding:2px 2px 0;}",
1137
+ ".dkp-iconbtn{box-sizing:border-box;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:12px;line-height:1;}",
1138
+ ".dkp-iconbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
1139
+ ".dkp-iconbtn.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.1));color:var(--dsw-alias-state-error-primary);}",
1140
+ ".dkp-addmodel{box-sizing:border-box;align-self:flex-start;display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;}",
1141
+ ".dkp-addmodel:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1142
+ ".dkp-picker{max-height:320px;overflow-y:auto;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md,8px);padding:4px 6px;display:flex;flex-direction:column;gap:2px;}",
1143
+ ".dkp-picker label{display:flex;align-items:center;gap:8px;min-width:0;cursor:pointer;padding:6px 8px;border-radius:var(--dsw-radius-md,8px);}",
1144
+ ".dkp-picker label:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1145
+ ".dkp-picker input{accent-color:var(--dk-accent);margin:0;flex:none;}",
1146
+ ".dkp-picker-id{flex:none;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:13px;line-height:20px;}",
1147
+ ".dkp-picker-name{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
1148
+ ".dkp-empty{border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg,10px);padding:12px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;}",
1149
+ ".dkp-addblock{display:flex;align-items:stretch;}",
1150
+ ".dkp-addbtn{box-sizing:border-box;flex:1 1 auto;min-width:180px;height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg,10px);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;cursor:pointer;}",
1151
+ ".dkp-addbtn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
1152
+ ".dkp-addcard{flex:1 1 auto;padding:14px 16px;}",
1153
+ ".dkp-addcard .dkp-editor{background:transparent;padding:0;}",
1154
+ ".dkp-editor-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;border-top:.5px solid var(--dsw-alias-border-l2);padding-top:12px;}",
1155
+ ".dkp-editor-actions .dkp-hint{margin-right:auto;}",
1156
+ "@media (max-width:680px){.dkp-page{max-width:none;gap:14px}.dkp-intro{font-size:13px;line-height:20px}.dkp-model-row{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.dkp-model-row .dkp-iconbtn{justify-self:end}.dkp-editor{padding:12px}.dkp-addcard{padding:12px 14px}.dkp-btn{height:40px}.dkp-btn.sm{height:34px}.dkp-addbtn{height:48px}}"
487
1157
  ].join("\n"),
488
1158
  View: ModelsView,
489
1159
  HomeStat: ModelsStat,