dsh-dock 0.14.0 → 0.16.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.
@@ -0,0 +1,604 @@
1
+ // dsh-dock · 功能模块【模型提供商】· 客户端视图
2
+ //
3
+ // 管理自定义模型 Provider(OpenAI 兼容 / Anthropic 等):新增、编辑、删除、拉取上游
4
+ // 模型列表、写入 API Key。写回官方 settings 与本地凭据库,热生效。
5
+ // 与【模型设置】的分工:那边只管已配置模型的输入模态与思考强度;本页管 Provider 本身。
6
+ // 数据来自 /dsh-dock/providerconfig(GET 列表 / POST action=listModels|create|update|delete)。
7
+ import react from "react";
8
+ import {
9
+ DKM_APIS,
10
+ DKM_EFFORT_NAMES,
11
+ DKM_MODALITIES,
12
+ DKM_PI_LEVELS,
13
+ DKM_ROUTE_KEY_RE,
14
+ DKM_TAGS,
15
+ dkmAllInputs,
16
+ dkmAllLevels,
17
+ dkmEnvFromRoute,
18
+ dkmToggleIn,
19
+ emptyModelDraft,
20
+ emptyProviderForm,
21
+ providerFormFromProvider,
22
+ } from "../../src/model-draft.js";
23
+
24
+ // ---- Provider 列表快照:本模块独占(不与模型目录共享,两个功能可各自启停) ----
25
+ const providersStore = {
26
+ snap: { data: null, loading: false, error: null },
27
+ listeners: new Set(),
28
+ subscribe(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; },
29
+ set(patch) { this.snap = Object.assign({}, this.snap, patch); for (const fn of this.listeners) fn(); },
30
+ load() {
31
+ if (this.snap.loading) return;
32
+ this.set({ loading: true, error: null });
33
+ // Provider 可以在 DSH 原生模型设置里随时增删;不要复用浏览器缓存。
34
+ fetch("dsh-dock/providerconfig", { cache: "no-store", signal: AbortSignal.timeout(20000) })
35
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error("模型提供商接口 HTTP " + res.status))))
36
+ .then((data) => this.set({ data: data, loading: false, error: null }))
37
+ .catch((e) => this.set({ loading: false, error: (e && e.message) || String(e) }));
38
+ }
39
+ };
40
+ function useProviders() {
41
+ const [snap, setSnap] = react.useState(providersStore.snap);
42
+ react.useEffect(() => {
43
+ const off = providersStore.subscribe(() => setSnap(providersStore.snap));
44
+ if (!providersStore.snap.data && !providersStore.snap.loading) providersStore.load();
45
+ return off;
46
+ }, []);
47
+ return snap;
48
+ }
49
+
50
+ /**
51
+ * Provider 表单里的单个模型行(官方模型行同款:id / 名称 / 容量 一行网格,下方输入类型与思考强度)。
52
+ * props: { m, i, onPatch(i, patch), onDel(i) }
53
+ */
54
+ function ProviderModelRow(props) {
55
+ const m = props.m;
56
+ const i = props.i;
57
+ const check = (v, list, kind) => react.createElement("label", { key: v[0], className: "dkp-check" },
58
+ react.createElement("input", {
59
+ type: "checkbox",
60
+ checked: list.indexOf(v[0]) >= 0,
61
+ onChange: () => {
62
+ const next = dkmToggleIn(list, v[0]);
63
+ props.onPatch(i, kind === "input" ? { input: next.length > 0 ? next : ["text"] } : { tags: next });
64
+ }
65
+ }),
66
+ v[1]);
67
+ return react.createElement("div", { className: "dkp-model-entry" },
68
+ react.createElement("div", { className: "dkp-model-row" },
69
+ react.createElement("input", { className: "dkp-input", placeholder: "模型 id(必填)", value: m.id, onChange: (e) => props.onPatch(i, { id: e.target.value }) }),
70
+ react.createElement("input", { className: "dkp-input", placeholder: "显示名称(可选)", value: m.name, onChange: (e) => props.onPatch(i, { name: e.target.value }) }),
71
+ react.createElement("input", { className: "dkp-input", placeholder: "上下文窗口", inputMode: "numeric", value: m.contextWindow, onChange: (e) => props.onPatch(i, { contextWindow: e.target.value }) }),
72
+ react.createElement("input", { className: "dkp-input", placeholder: "最大输出", inputMode: "numeric", value: m.maxTokens, onChange: (e) => props.onPatch(i, { maxTokens: e.target.value }) }),
73
+ react.createElement("button", { type: "button", className: "dkp-iconbtn danger", title: "删除该模型", "aria-label": "删除模型", onClick: () => props.onDel(i) }, "✕")),
74
+ react.createElement("div", { className: "dkp-model-opts" },
75
+ react.createElement("div", { className: "dkp-checks" },
76
+ react.createElement("span", { className: "dkp-flabel" }, "输入类型:"),
77
+ DKM_MODALITIES.map((v) => check(v, m.input, "input")),
78
+ react.createElement("span", { className: "dkp-flabel dkp-dim", title: "标注仅在本面板展示与持久化,不参与请求路由(官方 schema 只接受文本/图片)" }, "标注:"),
79
+ DKM_TAGS.map((v) => check(v, m.tags, "tags")),
80
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键勾选全部输入类型与标注", onClick: () => props.onPatch(i, dkmAllInputs(true)) }, "全选"),
81
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键取消全部勾选", onClick: () => props.onPatch(i, dkmAllInputs(false)) }, "取消全选")),
82
+ react.createElement("div", { className: "dkp-checks" },
83
+ react.createElement("span", { className: "dkp-flabel" }, "思考强度:"),
84
+ react.createElement("select", {
85
+ className: "dkp-input dkp-select dkp-select-sm",
86
+ value: m.effortsMode,
87
+ onChange: (e) => props.onPatch(i, {
88
+ effortsMode: e.target.value,
89
+ effortLevels: e.target.value === "custom" && m.effortsMode !== "custom"
90
+ ? { off: true, low: true, high: true }
91
+ : m.effortLevels
92
+ })
93
+ },
94
+ react.createElement("option", { value: "inherit" }, "跟随目录默认"),
95
+ react.createElement("option", { value: "off" }, "不支持思考"),
96
+ react.createElement("option", { value: "custom" }, "自定义档位")),
97
+ m.effortsMode === "custom"
98
+ ? DKM_PI_LEVELS.map((lv) => react.createElement("label", { key: lv, className: "dkp-check" },
99
+ react.createElement("input", {
100
+ type: "checkbox",
101
+ checked: !!m.effortLevels[lv],
102
+ onChange: () => {
103
+ const nl = Object.assign({}, m.effortLevels);
104
+ nl[lv] = !nl[lv];
105
+ props.onPatch(i, { effortLevels: nl });
106
+ }
107
+ }),
108
+ DKM_EFFORT_NAMES[lv] || lv))
109
+ : null,
110
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键勾选全部强度档(切为自定义)", onClick: () => props.onPatch(i, dkmAllLevels(true)) }, "全选"),
111
+ react.createElement("button", { type: "button", className: "dkp-mini", title: "一键取消全部强度档", onClick: () => props.onPatch(i, dkmAllLevels(false)) }, "取消全选"))));
112
+ }
113
+
114
+ /**
115
+ * 模型提供商页:官方「模型」设置页同款布局——
116
+ * 标题 + 说明 + 行卡片列表(每行可展开同一个编辑卡片)+ 底部虚线「新增 Provider」。
117
+ * 只管自定义 pi-ai 路由的增删改(内置 DeepSeek 官方 Provider 在【模型设置】里编辑其模型目录)。
118
+ */
119
+ function ProvidersView(props) {
120
+ const snap = useProviders();
121
+ const data = snap.data;
122
+ const providers = data && Array.isArray(data.providers) ? data.providers : [];
123
+ const params = props && props.params;
124
+ const [openCreate, setOpenCreate] = react.useState(false);
125
+ const [pfMode, setPfMode] = react.useState(null);
126
+ const [pfDraft, setPfDraft] = react.useState(null);
127
+ const [pfDirty, setPfDirty] = react.useState(false);
128
+ const [pfSaving, setPfSaving] = react.useState(false);
129
+ const [pfMsg, setPfMsg] = react.useState(null);
130
+ const [pfDelArm, setPfDelArm] = react.useState(null);
131
+ const [pfFetching, setPfFetching] = react.useState(false);
132
+ const [pfFetchedList, setPfFetchedList] = react.useState(null);
133
+
134
+ // 每次进入本页都重新读取(原生设置里刚改过 Provider 也要看到最新的)
135
+ react.useEffect(() => { providersStore.load(); }, []);
136
+ // deep-link:params.openProviderForm 直接展开「新增 Provider」表单(渲染回归测试与设置页深链共用)
137
+ react.useEffect(() => {
138
+ if (params && params.openProviderForm) setOpenCreate(true);
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, []);
141
+
142
+ const reload = () => providersStore.load();
143
+ const openPfCreate = () => {
144
+ setPfMode("create"); setPfDraft(emptyProviderForm()); setPfDirty(false);
145
+ setPfMsg(null); setPfDelArm(null); setPfFetchedList(null);
146
+ };
147
+ const openPfEdit = (p) => {
148
+ setPfMode("edit"); setPfDraft(providerFormFromProvider(p)); setPfDirty(false);
149
+ setPfMsg(null); setPfDelArm(null); setPfFetchedList(null);
150
+ };
151
+ const closePf = () => {
152
+ setPfMode(null); setPfDraft(null); setPfDirty(false);
153
+ setPfMsg(null); setPfDelArm(null); setPfFetchedList(null);
154
+ };
155
+ const patchPf = (patch) => { setPfDraft(Object.assign({}, pfDraft, patch)); setPfDirty(true); setPfMsg(null); };
156
+ const patchPfModel = (i, patch) => {
157
+ const models = pfDraft.models.slice();
158
+ models[i] = Object.assign({}, models[i], patch);
159
+ patchPf({ models: models });
160
+ };
161
+ const delPfModel = (i) => patchPf({ models: pfDraft.models.filter((_, j) => j !== i) });
162
+ const addPfModel = () => patchPf({ models: pfDraft.models.concat([emptyModelDraft()]) });
163
+ // 勾选式选模型:checked 状态直接映射「该 id 是否在草稿模型列表里」
164
+ const togglePfModel = (id, name, on) => {
165
+ const base = pfDraft.models.filter((m) => String(m.id || "").trim());
166
+ if (on) {
167
+ if (base.some((m) => String(m.id).trim() === id)) return;
168
+ base.push(Object.assign(emptyModelDraft(), { id: id, name: name || "", input: pfDraft.defaultInput.slice() }));
169
+ } else {
170
+ const i = base.findIndex((m) => String(m.id).trim() === id);
171
+ if (i >= 0) base.splice(i, 1);
172
+ }
173
+ patchPf({ models: base });
174
+ };
175
+ const setAllPfModels = (on) => {
176
+ const base = [];
177
+ if (on && pfFetchedList) {
178
+ for (const mm of pfFetchedList) {
179
+ const id = String((mm && mm.id) || "").trim();
180
+ if (!id) continue;
181
+ base.push(Object.assign(emptyModelDraft(), { id: id, name: (mm && mm.name) || "", input: pfDraft.defaultInput.slice() }));
182
+ }
183
+ }
184
+ patchPf({ models: base });
185
+ };
186
+ const pfSelectedCount = () => pfDraft ? pfDraft.models.filter((m) => String(m.id || "").trim()).length : 0;
187
+ const pfModelsPayload = () => pfDraft.models.map((m) => ({
188
+ id: String(m.id || "").trim(),
189
+ name: String(m.name || "").trim(),
190
+ contextWindow: m.contextWindow === "" || m.contextWindow == null ? undefined : Number(m.contextWindow),
191
+ maxTokens: m.maxTokens === "" || m.maxTokens == null ? undefined : Number(m.maxTokens),
192
+ input: m.input,
193
+ tags: m.tags,
194
+ effortsMode: m.effortsMode,
195
+ effortLevels: m.effortLevels,
196
+ raw: m.raw
197
+ }));
198
+ // 自动获取模型列表:宿主代打 GET {baseURL}/models(Key 还没保存也能先拉)
199
+ const fetchPfModels = () => {
200
+ if (!pfDraft || pfFetching) return;
201
+ setPfFetching(true); setPfMsg(null);
202
+ fetch("dsh-dock/providerconfig", {
203
+ method: "POST",
204
+ headers: { "content-type": "application/json" },
205
+ body: JSON.stringify({
206
+ action: "listModels",
207
+ baseURL: pfDraft.baseURL,
208
+ api: pfDraft.api,
209
+ apiKeyEnv: pfDraft.apiKeyEnv,
210
+ apiKey: pfDraft.apiKey
211
+ }),
212
+ signal: AbortSignal.timeout(30000)
213
+ })
214
+ .then((res) => res.json()
215
+ .then((b) => ({ status: res.status, body: b }))
216
+ .catch(() => ({ status: res.status, body: {} })))
217
+ .then((r) => {
218
+ if (r.status === 200 && r.body && r.body.ok) {
219
+ setPfFetchedList(Array.isArray(r.body.models) ? r.body.models : []);
220
+ } else {
221
+ setPfMsg({ ok: false, text: (r.body && r.body.error) || ("HTTP " + r.status) });
222
+ }
223
+ })
224
+ .catch((e) => setPfMsg({ ok: false, text: (e && e.message) || String(e) }))
225
+ .then(() => setPfFetching(false));
226
+ };
227
+ const savePf = () => {
228
+ if (!pfDraft || pfSaving) return;
229
+ const route = pfDraft.route.trim();
230
+ const baseURL = pfDraft.baseURL.trim();
231
+ const apiKey = pfDraft.apiKey.trim();
232
+ // Key 填了但 apiKeyEnv 留空:按路由 key 自动生成凭据库条目名,不再逼用户现编一个
233
+ let apiKeyEnv = pfDraft.apiKeyEnv.trim();
234
+ if (apiKey && !apiKeyEnv) {
235
+ apiKeyEnv = dkmEnvFromRoute(route);
236
+ setPfDraft(Object.assign({}, pfDraft, { apiKeyEnv: apiKeyEnv }));
237
+ }
238
+ const models = pfModelsPayload();
239
+ if (!route) { setPfMsg({ ok: false, text: "路由 key 不能为空(如 modelscope)" }); return; }
240
+ if (!DKM_ROUTE_KEY_RE.test(route)) { setPfMsg({ ok: false, text: "路由 key 非法:只能含字母、数字与 . _ -,且以字母/数字开头" }); return; }
241
+ if (!/^https?:\/\//i.test(baseURL)) { setPfMsg({ ok: false, text: "baseURL 须以 http:// 或 https:// 开头" }); return; }
242
+ const ids = models.map((m) => m.id);
243
+ if (ids.length === 0 || ids.some((x) => !x)) { setPfMsg({ ok: false, text: "每个模型都要有 id(可点「获取模型列表」自动拉取)" }); return; }
244
+ if (new Set(ids).size !== ids.length) { setPfMsg({ ok: false, text: "模型 id 有重复" }); return; }
245
+ const profile = Object.assign({}, pfDraft.extra || {});
246
+ const dn = pfDraft.displayName.trim();
247
+ if (dn) profile.displayName = dn; else delete profile.displayName;
248
+ profile.api = pfDraft.api;
249
+ profile.baseURL = baseURL;
250
+ if (apiKeyEnv) profile.apiKeyEnv = apiKeyEnv; else delete profile.apiKeyEnv;
251
+ profile.defaultInput = pfDraft.defaultInput;
252
+ profile.models = models;
253
+ const payload = {
254
+ action: pfMode === "create" ? "create" : "update",
255
+ route: route,
256
+ profile: profile,
257
+ revisions: (data && data.revisions) || {}
258
+ };
259
+ if (apiKey) payload.apiKey = apiKey;
260
+ setPfSaving(true); setPfMsg(null);
261
+ fetch("dsh-dock/providerconfig", {
262
+ method: "POST",
263
+ headers: { "content-type": "application/json" },
264
+ body: JSON.stringify(payload),
265
+ signal: AbortSignal.timeout(30000)
266
+ })
267
+ .then((res) => res.json()
268
+ .then((b) => ({ status: res.status, body: b }))
269
+ .catch(() => ({ status: res.status, body: {} })))
270
+ .then((r) => {
271
+ if (r.status === 200 && r.body && r.body.ok) {
272
+ setPfMsg({ ok: true, text: "Provider「" + route + "」已" + (pfMode === "create" ? "新增" : "更新") + ";会话模型选择器即时可选" });
273
+ setPfMode(null); setPfDraft(null); setPfDirty(false);
274
+ reload();
275
+ } else {
276
+ setPfMsg({ ok: false, text: (r.body && r.body.error) || ("HTTP " + r.status) });
277
+ }
278
+ })
279
+ .catch((e) => setPfMsg({ ok: false, text: (e && e.message) || String(e) }))
280
+ .then(() => setPfSaving(false));
281
+ };
282
+ // 删除:两步确认(先点一次进入待确认态,再点一次真删;不用 window.confirm)
283
+ const deletePf = (p) => {
284
+ const route = p.route || p.id;
285
+ if (pfDelArm !== route) { setPfDelArm(route); setPfMsg(null); return; }
286
+ setPfDelArm(null); setPfSaving(true); setPfMsg(null);
287
+ fetch("dsh-dock/providerconfig", {
288
+ method: "POST",
289
+ headers: { "content-type": "application/json" },
290
+ body: JSON.stringify({ action: "delete", route: route, revisions: (data && data.revisions) || {} }),
291
+ signal: AbortSignal.timeout(20000)
292
+ })
293
+ .then((res) => res.json()
294
+ .then((b) => ({ status: res.status, body: b }))
295
+ .catch(() => ({ status: res.status, body: {} })))
296
+ .then((r) => {
297
+ if (r.status === 200 && r.body && r.body.ok) {
298
+ setPfMsg({ ok: true, text: "Provider「" + route + "」已删除" });
299
+ if (pfMode === "edit" && pfDraft && pfDraft.route === route) { setPfMode(null); setPfDraft(null); }
300
+ reload();
301
+ } else {
302
+ setPfMsg({ ok: false, text: (r.body && r.body.error) || ("HTTP " + r.status) });
303
+ }
304
+ })
305
+ .catch((e) => setPfMsg({ ok: false, text: (e && e.message) || String(e) }))
306
+ .then(() => setPfSaving(false));
307
+ };
308
+ // deep-link:带 openProviderForm 进入本页时直接展开新增表单(只认第一次,之后由用户自己开关)
309
+ const openedRef = react.useRef(false);
310
+ react.useEffect(() => {
311
+ if (openCreate && !openedRef.current) {
312
+ openedRef.current = true;
313
+ openPfCreate();
314
+ }
315
+ // eslint-disable-next-line react-hooks/exhaustive-deps
316
+ }, [openCreate]);
317
+
318
+ // 官方 ProviderEditor 同款字段:一行一个字段(标签在上、输入铺满),非必填项收进「自定义设置」
319
+ const input = (p) => react.createElement("input", Object.assign({ className: "dkp-input" }, p));
320
+ const field = (label, node, hint) => react.createElement("div", { className: "dkp-field", key: label },
321
+ react.createElement("label", { className: "dkp-flabel" }, label),
322
+ node,
323
+ hint ? react.createElement("p", { className: "dkp-hint" }, hint) : null);
324
+
325
+ const editor = !pfDraft ? null : react.createElement("div", { className: "dkp-editor" },
326
+ react.createElement("div", { className: "dkp-editor-head" },
327
+ react.createElement("span", { className: "dkp-editor-title" }, pfMode === "create" ? "新增 Provider" : "编辑 Provider"),
328
+ pfMode === "edit" ? react.createElement("span", { className: "dkp-editor-route" }, pfDraft.route) : null),
329
+ field("路由 key", input({
330
+ value: pfDraft.route,
331
+ disabled: pfMode === "edit",
332
+ placeholder: "acme-gateway",
333
+ onChange: (e) => patchPf({ route: e.target.value })
334
+ }), pfMode === "edit" ? "路由 key 是配置键,保存后不建议修改。" : "唯一标识:只能含字母、数字与 . _ -,且以字母/数字开头。"),
335
+ field("显示名称", input({
336
+ value: pfDraft.displayName,
337
+ placeholder: pfDraft.route || "如 魔搭 ModelScope",
338
+ onChange: (e) => patchPf({ displayName: e.target.value })
339
+ })),
340
+ field("baseURL", input({
341
+ value: pfDraft.baseURL,
342
+ placeholder: pfDraft.api === "anthropic-messages" ? "https://gateway.example" : "https://gateway.example/v1",
343
+ onChange: (e) => patchPf({ baseURL: e.target.value })
344
+ }), "OpenAI 兼容端点通常以 /v1 结尾;Anthropic Messages 由 SDK 自行追加 /v1/messages。"),
345
+ field("API 协议", react.createElement("select", {
346
+ className: "dkp-input dkp-select",
347
+ value: pfDraft.api,
348
+ onChange: (e) => patchPf({ api: e.target.value })
349
+ },
350
+ DKM_APIS.map(([v, label]) => react.createElement("option", { key: v, value: v }, label)))),
351
+ field("API Key", react.createElement("input", {
352
+ className: "dkp-input",
353
+ type: "password",
354
+ autoComplete: "new-password",
355
+ placeholder: pfMode === "edit" ? "留空 = 不改动已存密钥" : "sk-…(仅本次写入本机凭据库,不回显)",
356
+ value: pfDraft.apiKey,
357
+ onChange: (e) => patchPf({ apiKey: e.target.value })
358
+ })),
359
+ react.createElement("details", { className: "dkp-customized" },
360
+ react.createElement("summary", { className: "dkp-summary" }, "自定义设置"),
361
+ react.createElement("div", { className: "dkp-custombody" },
362
+ field("apiKeyEnv", input({
363
+ value: pfDraft.apiKeyEnv,
364
+ placeholder: "留空 = 按路由 key 自动生成(" + (dkmEnvFromRoute(pfDraft.route) || "ROUTE_API_KEY") + ")",
365
+ onChange: (e) => patchPf({ apiKeyEnv: e.target.value })
366
+ }), "凭据库条目名;留空时按路由 key 推导,一般无需填写。"),
367
+ react.createElement("div", { className: "dkp-field" },
368
+ react.createElement("span", { className: "dkp-flabel" }, "默认输入类型"),
369
+ react.createElement("div", { className: "dkp-checks" },
370
+ DKM_MODALITIES.map(([v, label]) => react.createElement("label", { key: v, className: "dkp-check" },
371
+ react.createElement("input", {
372
+ type: "checkbox",
373
+ checked: pfDraft.defaultInput.indexOf(v) >= 0,
374
+ onChange: () => {
375
+ const next = dkmToggleIn(pfDraft.defaultInput, v);
376
+ patchPf({ defaultInput: next.length > 0 ? next : ["text"] });
377
+ }
378
+ }),
379
+ label)),
380
+ react.createElement("span", { className: "dkp-hint" }, "模型未单独声明输入类型时继承这里。"))))),
381
+ react.createElement("section", { className: "dkp-catalog", "aria-label": "模型" },
382
+ react.createElement("div", { className: "dkp-catalog-head" },
383
+ react.createElement("div", { className: "dkp-catalog-titlewrap" },
384
+ react.createElement("span", { className: "dkp-catalog-title" }, "模型"),
385
+ react.createElement("span", { className: "dkp-catalog-meta" },
386
+ "已选 " + pfSelectedCount() + " 个" + (pfFetchedList ? " / 上游共 " + pfFetchedList.length + " 个" : ""))),
387
+ react.createElement("div", { className: "dkp-actions" },
388
+ react.createElement("button", {
389
+ type: "button",
390
+ className: "dkp-btn ghost sm",
391
+ disabled: pfFetching || !pfDraft.baseURL,
392
+ title: "调用 baseURL/models 拉取该端点可用模型,勾选后加入下方列表",
393
+ onClick: fetchPfModels
394
+ }, pfFetching ? "拉取中…" : "获取模型列表"),
395
+ pfFetchedList && pfFetchedList.length > 0
396
+ ? react.createElement("button", { type: "button", className: "dkp-btn ghost sm", title: "勾选上游返回的全部模型", onClick: () => setAllPfModels(true) }, "全选")
397
+ : null,
398
+ pfFetchedList && pfFetchedList.length > 0
399
+ ? react.createElement("button", { type: "button", className: "dkp-btn ghost sm", title: "清空已勾选的模型", onClick: () => setAllPfModels(false) }, "清空")
400
+ : null)),
401
+ pfFetchedList
402
+ ? react.createElement("div", { className: "dkp-picker" },
403
+ pfFetchedList.length === 0
404
+ ? react.createElement("div", { className: "dkp-hint" }, "上游没有返回任何模型")
405
+ : pfFetchedList.map((mm) => {
406
+ const id = String((mm && mm.id) || "").trim();
407
+ if (!id) return null;
408
+ const checked = pfDraft.models.some((m) => String(m.id || "").trim() === id);
409
+ return react.createElement("label", { key: id },
410
+ react.createElement("input", {
411
+ type: "checkbox",
412
+ checked: checked,
413
+ onChange: () => togglePfModel(id, (mm && mm.name) || "", !checked)
414
+ }),
415
+ react.createElement("span", { className: "dkp-picker-id" }, id),
416
+ (mm && mm.name) ? react.createElement("span", { className: "dkp-picker-name" }, mm.name) : null);
417
+ }))
418
+ : null,
419
+ pfDraft.models.length === 0
420
+ ? react.createElement("div", { className: "dkp-empty" }, "还没有模型——点「获取模型列表」勾选上游模型,或「添加模型」手动填写。")
421
+ : react.createElement("div", { className: "dkp-model-list" },
422
+ pfDraft.models.map((m, i) => react.createElement(ProviderModelRow, { key: i, m: m, i: i, onPatch: patchPfModel, onDel: delPfModel }))),
423
+ react.createElement("button", { type: "button", className: "dkp-addmodel", onClick: addPfModel }, "+ 添加模型")),
424
+ pfMsg && !pfMsg.ok ? react.createElement("p", { className: "dkp-error" }, pfMsg.text) : null,
425
+ react.createElement("div", { className: "dkp-editor-actions" },
426
+ react.createElement("span", { className: "dkp-hint" }, pfDirty ? "有未保存的修改" : ""),
427
+ react.createElement("button", { type: "button", className: "dkp-btn ghost", onClick: closePf }, "取消"),
428
+ react.createElement("button", { type: "button", className: "dkp-btn primary", disabled: pfSaving, onClick: savePf },
429
+ pfSaving ? "保存中…" : (pfMode === "create" ? "新增 Provider" : "保存修改"))));
430
+
431
+ const piProviders = providers.filter((p) => p.kind === "pi-ai");
432
+ const body = [
433
+ react.createElement("h2", { key: "title", className: "dkp-title" }, "模型提供商"),
434
+ react.createElement("p", { key: "intro", className: "dkp-intro" },
435
+ "新增 / 编辑 / 删除自定义模型 Provider(OpenAI 兼容、Anthropic 等),写入官方配置并热生效;API Key 存本机凭据库(文件仅 root 可读)。内置 DeepSeek 官方 Provider 在本页只读,其模型目录在「模型设置」里编辑。本功能可单独停用:停用后这些写配置与出站请求的路由一并关闭。")
436
+ ];
437
+ if (snap.error) {
438
+ body.push(react.createElement("p", { key: "err", className: "dkp-error" }, "模型目录拉取失败:" + snap.error));
439
+ }
440
+ if (pfMsg && !pfDraft) {
441
+ body.push(react.createElement("p", { key: "msg", className: "dkp-msg " + (pfMsg.ok ? "ok" : "err"), role: "status" }, pfMsg.text));
442
+ }
443
+ if (!data) {
444
+ body.push(react.createElement("p", { key: "loading", className: "dkp-hint" }, snap.loading ? "正在拉取模型目录…" : "等待拉取模型目录"));
445
+ } else if (piProviders.length === 0) {
446
+ body.push(react.createElement("div", { key: "empty", className: "dkp-empty" }, "还没有自定义 Provider——点下方「新增 Provider」添加。"));
447
+ } else {
448
+ body.push(react.createElement("ul", { key: "rows", className: "dkp-rows" },
449
+ piProviders.map((p) => {
450
+ const route = p.route || p.id;
451
+ const editing = pfMode === "edit" && !!pfDraft && pfDraft.route === route;
452
+ const keyMissing = !!(p.apiKeyEnv && !p.apiKeyConfigured);
453
+ return react.createElement("li", { key: p.id, className: "dkp-card" },
454
+ react.createElement("div", { className: "dkp-rowhead" },
455
+ react.createElement("span", { className: "dkp-identity" },
456
+ react.createElement("span", { className: "dkp-name" }, p.displayName || route),
457
+ react.createElement("span", { className: "dkp-tag" }, route),
458
+ react.createElement("span", { className: "dkp-tag" }, (p.models ? p.models.length : 0) + " 模型"),
459
+ react.createElement("span", {
460
+ className: "dkp-dot " + (keyMissing ? "missing" : "ok"),
461
+ role: "img",
462
+ title: p.apiKeyEnv ? (p.apiKeyConfigured ? p.apiKeyEnv + " 已配置" : p.apiKeyEnv + " 未配置") : "未设 apiKeyEnv",
463
+ "aria-label": p.apiKeyEnv ? (p.apiKeyConfigured ? "密钥已配置" : "密钥未配置") : "未设 apiKeyEnv"
464
+ }),
465
+ p.apiKeyEnv
466
+ ? react.createElement("span", { className: "dkp-tag" + (keyMissing ? " warn" : " ok") },
467
+ p.apiKeyConfigured ? p.apiKeyEnv + " 已配置" : p.apiKeyEnv + " 未配置")
468
+ : null),
469
+ react.createElement("span", { className: "dkp-actions" },
470
+ react.createElement("button", {
471
+ type: "button",
472
+ className: "dkp-btn ghost sm",
473
+ "aria-expanded": editing,
474
+ onClick: () => (editing ? closePf() : openPfEdit(p))
475
+ }, editing ? "收起" : "编辑"),
476
+ react.createElement("button", {
477
+ type: "button",
478
+ className: "dkp-btn danger sm" + (pfDelArm === route ? " arm" : ""),
479
+ title: pfDelArm === route ? "再点一次确认删除" : "删除该 Provider",
480
+ onClick: () => deletePf(p)
481
+ }, pfDelArm === route ? "确认删除?" : "删除"))),
482
+ react.createElement("div", { className: "dkp-sub" }, [p.api, p.baseURL].filter(Boolean).join(" · ")),
483
+ editing ? editor : null);
484
+ })));
485
+ }
486
+ body.push(react.createElement("div", { key: "add", className: "dkp-addblock" },
487
+ pfMode === "create" && pfDraft
488
+ ? react.createElement("div", { className: "dkp-card dkp-addcard" }, editor)
489
+ : react.createElement("button", { type: "button", className: "dkp-addbtn", onClick: openPfCreate }, "+ 新增 Provider")));
490
+ return react.createElement("div", { className: "dkp-page" }, body);
491
+ }
492
+
493
+ function ProvidersStat() {
494
+ const snap = useProviders();
495
+ const data = snap.data;
496
+ if (snap.error) return react.createElement("span", { className: "dockm-err" }, "模型提供商拉取失败(点击进入查看)");
497
+ if (!data) return react.createElement("span", null, snap.loading ? "正在拉取模型提供商…" : "等待拉取模型提供商");
498
+ const providers = Array.isArray(data.providers) ? data.providers : [];
499
+ const custom = providers.filter((p) => p.kind === "pi-ai");
500
+ const missing = custom.filter((p) => p.apiKeyEnv && !p.apiKeyConfigured).length;
501
+ return react.createElement("span", null, custom.length + " 个自定义 Provider" + (missing > 0 ? " · " + missing + " 个缺密钥" : ""));
502
+ }
503
+
504
+ export const feature = {
505
+ id: "providerconfig",
506
+ name: "模型提供商",
507
+ order: 101,
508
+ accent: "#8b5cf6",
509
+ description: "管理自定义模型 Provider:新增/编辑/删除、拉取上游模型列表、写入 API Key(可单独停用)",
510
+ css: [
511
+ // ---- 模型提供商页(dkp- 前缀):对齐官方「模型」设置页的排版与尺寸 ----
512
+ // 官方用量参考:section 限宽 720、行卡片 .5px 描边 + radius-xl + 12/14 内距、
513
+ // 输入高 32、按钮高 36(行内小按钮 28)、新增按钮 44 虚线、编辑器 bg-module-platform。
514
+ ".dkp-page{width:100%;max-width:720px;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);}",
515
+ ".dkp-title{margin:0;font-size:16px;font-weight:500;line-height:24px;}",
516
+ ".dkp-intro{margin:0;color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:22px;}",
517
+ ".dkp-rows{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:8px;}",
518
+ ".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;}",
519
+ ".dkp-rowhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}",
520
+ ".dkp-identity{display:inline-flex;align-items:center;gap:6px;min-width:0;flex-wrap:wrap;}",
521
+ ".dkp-name{font-size:14px;font-weight:500;line-height:22px;}",
522
+ ".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;}",
523
+ ".dkp-tag.ok{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
524
+ ".dkp-tag.warn{color:var(--dk-warn);border-color:currentColor;}",
525
+ ".dkp-dot{box-sizing:border-box;flex:none;width:8px;height:8px;border-radius:50%;display:inline-block;}",
526
+ ".dkp-dot.ok{background:var(--dsw-alias-state-success-primary);}",
527
+ ".dkp-dot.missing{background:var(--dsw-alias-state-error-primary);}",
528
+ ".dkp-dot.warn{background:var(--dk-warn);}",
529
+ ".dkp-sub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
530
+ ".dkp-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;}",
531
+ ".dkp-dim{color:var(--dsw-alias-label-tertiary);}",
532
+ ".dkp-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;}",
533
+ ".dkp-msg{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;}",
534
+ ".dkp-msg.ok{color:var(--dsw-alias-state-success-primary);}",
535
+ ".dkp-msg.err{color:var(--dsw-alias-state-error-primary);}",
536
+ ".dkp-actions{flex:none;margin-left:auto;display:inline-flex;align-items:center;gap:4px;}",
537
+ ".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;}",
538
+ ".dkp-btn.ghost{border:.5px solid var(--dsw-alias-border-l3);}",
539
+ ".dkp-btn.ghost:hover{background:var(--dsw-alias-interactive-bg-hover);}",
540
+ ".dkp-btn.primary{background:var(--dsw-alias-button-primary-fill,var(--dk-accent));color:var(--dsw-alias-label-primary-foreground,#fff);}",
541
+ ".dkp-btn.primary:hover{background:var(--dsw-alias-button-primary-hover,var(--dk-accent));}",
542
+ ".dkp-btn.danger{color:var(--dsw-alias-state-error-primary);}",
543
+ ".dkp-btn.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.1));}",
544
+ ".dkp-btn.arm{color:var(--dsw-alias-state-error-primary);border-color:currentColor;}",
545
+ ".dkp-btn.sm{height:28px;padding:0 10px;border-radius:var(--dsw-radius-sm,6px);font-size:12px;line-height:18px;}",
546
+ ".dkp-btn[disabled]{opacity:.4;cursor:default;}",
547
+ ".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;}",
548
+ ".dkp-editor-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;}",
549
+ ".dkp-editor-title{font-size:14px;font-weight:500;line-height:22px;}",
550
+ ".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;}",
551
+ ".dkp-field{display:flex;flex-direction:column;gap:6px;min-width:0;}",
552
+ ".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;}",
553
+ ".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;}",
554
+ ".dkp-input:focus{border-color:var(--dsw-alias-state-business-primary,var(--dk-accent));outline:none;}",
555
+ ".dkp-input::placeholder{color:var(--dsw-alias-label-dimmed,var(--dsw-alias-label-tertiary));}",
556
+ ".dkp-input:disabled{opacity:.6;cursor:default;}",
557
+ "select.dkp-input{appearance:auto;cursor:pointer;max-width:240px;}",
558
+ ".dkp-select-sm{height:28px;max-width:160px;font-size:12px;line-height:18px;}",
559
+ ".dkp-checks{display:flex;align-items:center;gap:16px;flex-wrap:wrap;min-height:32px;}",
560
+ ".dkp-check{display:inline-flex;align-items:center;gap:6px;cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;}",
561
+ ".dkp-check input{accent-color:var(--dk-accent);margin:0;}",
562
+ ".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;}",
563
+ ".dkp-mini:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-secondary);}",
564
+ ".dkp-customized{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:10px;}",
565
+ ".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;}",
566
+ ".dkp-summary::-webkit-details-marker{display:none;}",
567
+ ".dkp-summary::before{content:\"\";width:5px;height:5px;border-right:1.5px solid;border-bottom:1.5px solid;transform:rotate(-45deg);transition:transform .12s;}",
568
+ ".dkp-customized[open]>.dkp-summary::before{transform:rotate(45deg);}",
569
+ ".dkp-summary:hover{color:var(--dsw-alias-label-primary);}",
570
+ ".dkp-custombody{display:flex;flex-direction:column;gap:12px;padding-top:12px;}",
571
+ ".dkp-catalog{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:12px;display:flex;flex-direction:column;gap:10px;}",
572
+ ".dkp-catalog-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;}",
573
+ ".dkp-catalog-titlewrap{display:flex;flex-direction:column;gap:2px;}",
574
+ ".dkp-catalog-title{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:500;line-height:18px;}",
575
+ ".dkp-catalog-meta{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;}",
576
+ ".dkp-model-list{display:flex;flex-direction:column;gap:8px;}",
577
+ ".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;}",
578
+ ".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;}",
579
+ ".dkp-model-row .dkp-input{height:30px;padding:0 8px;font-size:13px;}",
580
+ ".dkp-model-opts{display:flex;flex-direction:column;gap:4px;padding:2px 2px 0;}",
581
+ ".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;}",
582
+ ".dkp-iconbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
583
+ ".dkp-iconbtn.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger,rgba(220,38,38,.1));color:var(--dsw-alias-state-error-primary);}",
584
+ ".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;}",
585
+ ".dkp-addmodel:hover{background:var(--dsw-alias-interactive-bg-hover);}",
586
+ ".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;}",
587
+ ".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);}",
588
+ ".dkp-picker label:hover{background:var(--dsw-alias-interactive-bg-hover);}",
589
+ ".dkp-picker input{accent-color:var(--dk-accent);margin:0;flex:none;}",
590
+ ".dkp-picker-id{flex:none;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:13px;line-height:20px;}",
591
+ ".dkp-picker-name{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
592
+ ".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;}",
593
+ ".dkp-addblock{display:flex;align-items:stretch;}",
594
+ ".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;}",
595
+ ".dkp-addbtn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
596
+ ".dkp-addcard{flex:1 1 auto;padding:14px 16px;}",
597
+ ".dkp-addcard .dkp-editor{background:transparent;padding:0;}",
598
+ ".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;}",
599
+ ".dkp-editor-actions .dkp-hint{margin-right:auto;}",
600
+ "@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}}"
601
+ ].join("\n"),
602
+ View: ProvidersView,
603
+ HomeStat: ProvidersStat,
604
+ };
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // 数据形状由客户端消费,字段改动要同时看:features/runstate/view.jsx、
11
11
  // features/animation/view.jsx、features/notify/view.jsx。
12
- import { sendJson, readBody } from '../../src/host-core.js'
12
+ import { sendJson, readBody, rejectUntrustedRequest } from '../../src/host-core.js'
13
13
  import { acquireTaskTracker } from '../../src/task-track.js'
14
14
 
15
15
  export const feature = {
@@ -20,6 +20,8 @@ export const feature = {
20
20
  setup(ctx) {
21
21
  const disposers = []
22
22
  const dispose = () => {
23
+ // 撤掉「本宿主注册了 runstate 路由」旗标(见下方注册处的说明)
24
+ try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ }
23
25
  while (disposers.length > 0) {
24
26
  const fn = disposers.pop()
25
27
  try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
@@ -33,10 +35,19 @@ export const feature = {
33
35
 
34
36
  // ===== RPC 路由 =====
35
37
  disposers.push(ctx.inject(['webServer'], (wsCtx) => {
38
+ // 路由注册成功后打旗标:【手机适配】的汉堡角标据此决定要不要轮询 status。
39
+ // 没有它的话,功能停用时角标会先打一次必然失败的请求 → 控制台一条 405/404,
40
+ // 与【用量记录】chip 当年同一个毛病(见 features/tokenlog/view.jsx 的 routeGone 闩锁)。
41
+ // 旗标与 __dshDockMobileDrawer / __dshDockWelcomeAck 同风格:宿主侧唯一事实,客户端只读。
42
+ wsCtx.effect(() => {
43
+ if (typeof window !== 'undefined') window.__dshDockRunstate = true
44
+ return () => { try { if (typeof window !== 'undefined' && window.__dshDockRunstate) delete window.__dshDockRunstate } catch { /* ignore */ } }
45
+ })
36
46
  wsCtx.effect(() => wsCtx.webServer.register({
37
47
  kind: 'prefix',
38
48
  path: '/dsh-dock/runstate',
39
49
  async handler(req, res) {
50
+ if (rejectUntrustedRequest(ctx, req, res)) return
40
51
  try {
41
52
  const url = new URL(req.url || '/', 'http://dsh.internal')
42
53
  const method = url.pathname.replace(/^\/dsh-dock\/runstate\/?/, '').split('/')[0] || ''