dsh-dock 0.15.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.
@@ -14,7 +14,7 @@
14
14
  // systemNotify / soundNotify / soundEffect / dingtalkEnabled / dingtalkWebhook /
15
15
  // feishuEnabled / feishuWebhook
16
16
  // 模块启停由功能坞的 features.notify 开关负责(独立菜单项),本模块不再有第二个总开关。
17
- import { SOUND_EFFECTS, sendJson, readBody, readDockRoot, mutateDockSection } from '../../src/host-core.js'
17
+ import { SOUND_EFFECTS, sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
18
18
  import { acquireTaskTracker } from '../../src/task-track.js'
19
19
 
20
20
  // 默认配置(schema 默认值一致;settings 未挂载时的兜底)
@@ -244,6 +244,7 @@ export const feature = {
244
244
  kind: 'prefix',
245
245
  path: '/dsh-dock/notify',
246
246
  async handler(req, res) {
247
+ if (rejectUntrustedRequest(ctx, req, res)) return
247
248
  try {
248
249
  const url = new URL(req.url || '/', 'http://dsh.internal')
249
250
  const method = url.pathname.replace(/^\/dsh-dock\/notify\/?/, '').split('/')[0] || ''
@@ -5,6 +5,7 @@
5
5
  // 2. HomeStat —— 首页总揽概要(当前通知事件与推送通道);
6
6
  // 3. Overlay —— 全局浮层(shell.overlay 常驻,功能启用即挂载):轮询 Host 状态,
7
7
  // 任务结束弹通知卡片(可选提示音与浏览器系统通知),工具等待确认时弹常驻提醒卡。
8
+ // 结束卡常驻到「看过」(点卡直达会话 / 点进会话 / 手动 ✕),≥3 张叠放收纳。
8
9
  //
9
10
  // 与【任务动画】【运行状态】完全独立:动效在 features/animation/、任务状态一览在
10
11
  // features/runstate/,本模块只管通知(页内卡片 / 提示音 / 系统通知 / 钉钉飞书群机器人推送)。
@@ -165,16 +166,68 @@ function useNotify() {
165
166
  }
166
167
 
167
168
  // ---------- 通知卡片 ----------
169
+ // 需确认卡片没有任何计时器:审批没处理完它就一直在(处理完由 approvalKeys
170
+ // 对账自动收起,也可手动 ✕)。曾经的「前台看过 8 秒也收」害死人:审批还挂着
171
+ // 等人批,提醒先没了(2026-10-20 实测漏提醒)。堆叠问题交给叠放收纳解决。
172
+ function pageVisible() {
173
+ return typeof document === "undefined" || document.visibilityState === "visible";
174
+ }
175
+
176
+ // 当前正在看的会话标题(侧栏高亮行)。与【任务动画】的 currentSessionTitle 同款判定:
177
+ // dsh 会话树行 = role="treeitem" + aria-selected,行文本即标题;侧栏在左,
178
+ // 跳过正文区可能出现的同名高亮节点(右上搜索结果等)。
179
+ function currentSelectedSessionTitle() {
180
+ if (typeof document === "undefined" || typeof window === "undefined") return "";
181
+ if (typeof document.querySelectorAll !== "function") return "";
182
+ for (const el of document.querySelectorAll('[role="treeitem"][aria-selected="true"]')) {
183
+ const r = el.getBoundingClientRect();
184
+ if (r.width > 0 && r.height > 0 && r.right <= window.innerWidth * .35) {
185
+ const text = (el.textContent || "").trim();
186
+ if (text) return text;
187
+ }
188
+ }
189
+ return "";
190
+ }
191
+ // 标题模糊对齐(与任务动画 taskOnScreen 同规则):相等或互为子串即算同一会话
192
+ function titleMatches(a, b) {
193
+ return !!a && !!b && (a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1);
194
+ }
195
+
196
+ // 点卡片跳转会话:在侧栏会话树里找标题对上的一行,点它(优先行内的 a/button)。
197
+ // 判定与 currentSelectedSessionTitle 同口径(左缘 35vw 内、可见);找不到可见行
198
+ // (抽屉收起 / 手机端 / 会话被删)返回 false,调用方保持卡片不收起。
199
+ function openSessionByTitle(title) {
200
+ if (typeof document === "undefined" || typeof window === "undefined") return false;
201
+ if (typeof document.querySelectorAll !== "function" || !title) return false;
202
+ for (const el of document.querySelectorAll('[role="treeitem"]')) {
203
+ const r = typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
204
+ if (!r || r.width <= 0 || r.height <= 0 || r.right > window.innerWidth * .35) continue;
205
+ if (!titleMatches((el.textContent || "").trim(), title)) continue;
206
+ const link = typeof el.querySelector === "function" ? el.querySelector("a,button") : null;
207
+ const target = link || el;
208
+ if (typeof target.click === "function") { target.click(); return true; }
209
+ return false;
210
+ }
211
+ return false;
212
+ }
213
+
168
214
  function Toast(props) {
169
215
  const t = props.t;
170
- const [closing, setClosing] = useState(false);
171
- const close = useCallback(() => { if (!closing) setClosing(true); }, [closing]);
172
- // 停留时长(stayMs=0 常驻,仅手动关闭)
173
- useEffect(() => {
174
- if (!t.stayMs) return;
175
- const timer = setTimeout(close, t.stayMs);
176
- return () => clearTimeout(timer);
177
- }, []);
216
+ const [closing, setClosing] = useState(!!t.closing);
217
+ const close = useCallback(() => { setClosing(true); }, []);
218
+ // 点卡片 = 跳转到对应会话。结束卡跳成功就地收起(这一看就把通知的活干完了);
219
+ // 需确认卡只跳转不收——审批没处理完它就该在。找不到可见会话行(抽屉收起/
220
+ // 手机端/会话已删)不收起,卡片原样保留。
221
+ const jump = useCallback(() => {
222
+ if (!t.sessionTitle) return;
223
+ if (openSessionByTitle(t.sessionTitle) && t.kind !== "confirm") setClosing(true);
224
+ }, [t.sessionTitle, t.kind]);
225
+ // 外部要求收起(审批已处理 / 已点进会话)走同一套淡出动画
226
+ useEffect(() => { if (t.closing && !closing) setClosing(true); }, [t.closing, closing]);
227
+ // 所有卡片都没有计时器:结束卡常驻到「看过」(点卡跳转 / 点进会话 / 手动 ✕),
228
+ // 需确认卡常驻到审批处理完(approvalKeys 对账收起,或手动 ✕)。
229
+ // 曾经的 lingerMs「前台看过 8 秒也收」实测会把还挂着的审批提醒先弄丢,已移除;
230
+ // 卡片多了靠叠放收纳(.stacked 样式),不靠丢。
178
231
  useEffect(() => {
179
232
  if (!closing) return;
180
233
  const timer = setTimeout(() => props.onClose(t.id), 240);
@@ -185,14 +238,23 @@ function Toast(props) {
185
238
  : t.kind === "error"
186
239
  ? "var(--dk-err)"
187
240
  : "var(--dk-warn)";
241
+ // 叠放收起态(collapsed):只留标题条,正文隐藏;悬停卡片栈时整体展开
188
242
  return (
189
- <div className={"dknt-toast" + (t.kind === "confirm" ? " dknt-toast-confirm" : "") + (closing ? " out" : "")}>
243
+ <div className={"dknt-toast" + (t.kind === "confirm" ? " dknt-toast-confirm" : "")
244
+ + (t.sessionTitle ? " clickable" : "")
245
+ + (props.collapsed ? " collapsed" : "")
246
+ + (closing ? " out" : "")}
247
+ onClick={t.sessionTitle ? jump : undefined}>
190
248
  <div className="dknt-toast-head">
191
249
  <span className="dknt-toast-mark" style={{ background: markColor }} />
192
- <span className="dknt-toast-title" title={t.title}>{t.kind === "confirm" ? "✋ " : ""}{t.title}</span>
193
- <button type="button" className="dknt-toast-close" aria-label="关闭" onClick={close}>✕</button>
250
+ <span className="dknt-toast-title" title={t.sessionTitle || t.title}>
251
+ {t.kind === "confirm" ? "✋ " : ""}{props.collapsed ? (t.sessionTitle || t.title) : t.title}
252
+ {!props.collapsed && t.sessionTitle && t.kind !== "confirm" ? " · " + t.sessionTitle : ""}
253
+ </span>
254
+ <button type="button" className="dknt-toast-close" aria-label="关闭"
255
+ onClick={(e) => { if (e && e.stopPropagation) e.stopPropagation(); close(); }}>✕</button>
194
256
  </div>
195
- {t.body ? <div className="dknt-toast-body">{t.body}</div> : null}
257
+ {!props.collapsed && t.body ? <div className="dknt-toast-body">{t.body}</div> : null}
196
258
  </div>
197
259
  );
198
260
  }
@@ -201,12 +263,14 @@ function Toast(props) {
201
263
  export function NotifyOverlay(props) {
202
264
  const snap = useNotify();
203
265
  const [toasts, setToasts] = useState([]);
266
+ const [stackHover, setStackHover] = useState(false); // 卡片栈被悬停(叠放时整体展开)
204
267
  const prevActiveRef = useRef(null);
205
268
  const remindedApprovalsRef = useRef(new Map()); // sessionId -> 提醒过的 approvalId 集合
206
269
  const toastSeqRef = useRef(0);
207
270
 
208
271
  const pushToast = useCallback((toast) => {
209
- setToasts((prev) => prev.concat([toast]).slice(-4));
272
+ // 上限 12:结束卡现在常驻到看过才收,4 张容易见底;多了靠叠放收纳而不是丢弃。
273
+ setToasts((prev) => prev.concat([toast]).slice(-12));
210
274
  }, []);
211
275
 
212
276
  // 轮询:任务中 850ms,实时捕捉结束/待确认;空闲 6s / 出错 15s;页面切回立即刷新。
@@ -277,8 +341,11 @@ export function NotifyOverlay(props) {
277
341
  kind: "confirm",
278
342
  title: "任务需要确认",
279
343
  body: lines.join("\n"),
280
- // 需确认常驻到用户处理(notifyStayMs=0 同款语义),不会自动消失
281
- stayMs: 0,
344
+ // 点卡片直达该会话去处理审批(需确认卡跳转后不收起——审批没处理完它就该在)
345
+ sessionTitle: task.title || "",
346
+ // 卡片与这批审批项绑定:approval/decided 后宿主把它们移出 approvals,
347
+ // 下面的对账 effect 据此自动收起卡片(处理完就不该再占着右上角)。
348
+ approvalKeys: fresh.filter((a) => a && a.id).map((a) => task.sessionId + "|" + a.id),
282
349
  });
283
350
  if (cfg.soundNotify !== false) playAskSound(cfg.soundEffect);
284
351
  if (cfg.systemNotify && typeof document !== "undefined" && document.hidden
@@ -293,6 +360,54 @@ export function NotifyOverlay(props) {
293
360
  }
294
361
  }, [snap.status]);
295
362
 
363
+ // 审批项消失(approval/decided 后宿主把它移出 approvals)→ 对应需确认卡自动收起。
364
+ // 只标记 closing 走淡出,不直接删:与手动点 ✕ 的退场一致。
365
+ // 卡片绑定的 approvalKeys 全部不在仍待确认的集合里才收;空 keys(老数据)不动。
366
+ useEffect(() => {
367
+ const st = snap.status;
368
+ if (!st || !st.active) return;
369
+ const live = new Set();
370
+ for (const task of st.active) {
371
+ const approvals = Array.isArray(task.approvals) ? task.approvals : [];
372
+ for (const a of approvals) if (a && a.id) live.add(task.sessionId + "|" + a.id);
373
+ }
374
+ setToasts((prev) => {
375
+ let changed = false;
376
+ const next = prev.map((x) => {
377
+ if (x.kind !== "confirm" || x.closing) return x;
378
+ const keys = Array.isArray(x.approvalKeys) ? x.approvalKeys : [];
379
+ if (keys.length === 0 || keys.some((k) => live.has(k))) return x;
380
+ changed = true;
381
+ return Object.assign({}, x, { closing: true });
382
+ });
383
+ return changed ? next : prev;
384
+ });
385
+ }, [snap.status]);
386
+
387
+ // 结束卡(完成/异常)的"看过即收":用户点进那个会话(侧栏高亮行标题与卡片
388
+ // 绑定的 sessionTitle 对上)就视为已看到结果,卡片自动收起。结束卡没有超时
389
+ // 自毁——没看过就一直挂着;这条巡检是它唯一的自动出口(另有手动 ✕ 与点卡跳转)。
390
+ // 侧栏选中没有事件可订阅,用轻量轮询(500ms 一次 querySelectorAll);页面在
391
+ // 后台不检查(回来时若仍停在该会话,收起发生在切回后)。
392
+ useEffect(() => {
393
+ const timer = setInterval(() => {
394
+ if (!pageVisible()) return;
395
+ const current = currentSelectedSessionTitle();
396
+ if (!current) return;
397
+ setToasts((prev) => {
398
+ let changed = false;
399
+ const next = prev.map((x) => {
400
+ if (x.kind === "confirm" || x.closing || !x.sessionTitle) return x;
401
+ if (!titleMatches(x.sessionTitle, current)) return x;
402
+ changed = true;
403
+ return Object.assign({}, x, { closing: true });
404
+ });
405
+ return changed ? next : prev;
406
+ });
407
+ }, 500);
408
+ return () => clearInterval(timer);
409
+ }, []);
410
+
296
411
  const handleTaskEnd = (task, record, cfg) => {
297
412
  const reason = (record && record.endReason) || "";
298
413
  const success = isSuccessReason(reason);
@@ -323,7 +438,9 @@ export function NotifyOverlay(props) {
323
438
  kind: success ? "success" : (info.cls === "err" ? "error" : "warn"),
324
439
  title,
325
440
  body: lines.join("\n"),
326
- stayMs: typeof cfg.notifyStayMs === "number" ? cfg.notifyStayMs : 8000,
441
+ // 绑定会话标题:点卡片直达该会话;用户点进这个会话(侧栏选中)也视为
442
+ // 看到了结果,巡检 effect 据此自动收起卡片。没看过就一直挂着。
443
+ sessionTitle: (record && record.title) || task.title || "",
327
444
  });
328
445
  // 提示音:任务结束时播放(完成/异常配套音);与系统通知独立开关
329
446
  if (cfg.soundNotify !== false) playDoneSound(success, cfg.soundEffect);
@@ -342,9 +459,17 @@ export function NotifyOverlay(props) {
342
459
  // Host 不可用(旧宿主未重启等):浮层整体静默,面板页会给提示
343
460
  if (!snap.status) return null;
344
461
  if (toasts.length === 0) return null;
462
+ // 卡片多了叠放收纳:≥3 张时只完整展开最新一张,其余收成"只剩标题条";
463
+ // 鼠标移到卡片栈上整体展开,移开收回。跳转/收起对每张卡照常可用。
464
+ const stack = toasts.length >= 3 && !stackHover;
345
465
  return (
346
- <div className="dknt-toasts">
347
- {toasts.map((t) => <Toast key={t.id} t={t} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />)}
466
+ <div className={"dknt-toasts" + (stack ? " stacked" : "")}
467
+ onMouseEnter={() => setStackHover(true)}
468
+ onMouseLeave={() => setStackHover(false)}>
469
+ {toasts.map((t, i) => (
470
+ <Toast key={t.id} t={t} collapsed={stack && i < toasts.length - 1}
471
+ onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
472
+ ))}
348
473
  </div>
349
474
  );
350
475
  }
@@ -516,7 +641,7 @@ function NotifyView(props) {
516
641
  onClick={() => patch({ notifyOnConfirm: cfg.notifyOnConfirm === false })}>
517
642
  {cfg.notifyOnConfirm !== false ? "开" : "关"}
518
643
  </button>
519
- <span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音(常驻不自动消失)</span>
644
+ <span className="dknt-row-sub">工具等待你批准时弹卡片并响确认音;审批处理完自动收起,没处理完就一直显示(点卡片直达会话)</span>
520
645
  </div>
521
646
  </div>
522
647
  </div>,
@@ -527,16 +652,8 @@ function NotifyView(props) {
527
652
  </div>
528
653
  <div className="dknt-rows-narrow">
529
654
  <div className="dknt-row">
530
- <span className="dknt-row-label">停留时长</span>
531
- <select className="dknt-select" value={String(cfg.notifyStayMs)}
532
- onChange={(e) => patch({ notifyStayMs: Number(e.target.value) })}>
533
- <option value="4000">4 秒</option>
534
- <option value="8000">8 秒</option>
535
- <option value="15000">15 秒</option>
536
- <option value="30000">30 秒</option>
537
- <option value="0">常驻(手动关闭)</option>
538
- </select>
539
- <span className="dknt-row-sub">卡片自动消失的时间(需确认提醒恒为常驻)</span>
655
+ <span className="dknt-row-label">卡片收纳</span>
656
+ <span className="dknt-row-sub">所有卡片都不超时自动消失:结束卡片没看过就一直挂着(后台跑完的切回来也在),点卡片直达对应会话,或点进该会话,卡片自动收起;需确认卡片挂到审批处理完。3 张及以上自动叠放成标题条,鼠标移上去整体展开</span>
540
657
  </div>
541
658
  <div className="dknt-row">
542
659
  <span className="dknt-row-label">系统通知</span>
@@ -700,6 +817,13 @@ const css = [
700
817
  ".dknt-toast-confirm{border-color:color-mix(in srgb,var(--dk-warn) 55%,transparent);animation:dknt-toast-in .32s var(--ds-ease-in-out),dknt-confirm-glow 2.4s ease-in-out 0.4s infinite;}",
701
818
  "@keyframes dknt-confirm-glow{0%,100%{box-shadow:0 10px 36px rgb(0 0 0 / .22),0 0 0 0 color-mix(in srgb,var(--dk-warn) 30%,transparent)}50%{box-shadow:0 10px 36px rgb(0 0 0 / .22),0 0 14px 2px color-mix(in srgb,var(--dk-warn) 32%,transparent)}}",
702
819
  "@media (prefers-reduced-motion:reduce){.dknt-toast,.dknt-toast-confirm{animation:none}}",
820
+ // 可跳转卡片:指针 + 悬停提亮(点卡片直达对应会话)
821
+ ".dknt-toast.clickable{cursor:pointer;}",
822
+ ".dknt-toast.clickable:hover{border-color:var(--dk-accent);}",
823
+ // 叠放收纳:≥3 张时旧卡收成"只剩标题条",悬停卡片栈整体展开
824
+ ".dknt-toasts.stacked{gap:4px;}",
825
+ ".dknt-toast.collapsed{padding:6px 14px;opacity:.85;margin-left:14px;}",
826
+ ".dknt-toast.collapsed .dknt-toast-head{margin-bottom:0;}",
703
827
  // 面板页布局
704
828
  ".dknt-root{display:flex;flex-direction:column;gap:10px;}",
705
829
  ".dknt-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
@@ -719,7 +843,6 @@ const css = [
719
843
  ".dknt-row-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);}",
720
844
  ".dknt-miniswitch{cursor:pointer;flex:none;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 12px;font-family:inherit;font-size:11px;line-height:18px;}",
721
845
  ".dknt-miniswitch.on{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
722
- ".dknt-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:3px 8px;font-size:12px;font-family:inherit;}",
723
846
  // 音效选择卡(名称 + 播放键;选中态描边)
724
847
  ".dknt-sounds{display:flex;gap:6px;flex-wrap:wrap;}",
725
848
  ".dknt-sound{flex:1;min-width:104px;display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;font-family:inherit;transition:border-color .15s var(--ds-ease-in-out);}",
@@ -0,0 +1,235 @@
1
+ // dsh-dock · 功能模块【模型提供商】· 宿主半部
2
+ //
3
+ // GET /dsh-dock/providerconfig —— Provider 列表(端点 / API Key 条目 / 密钥是否已配置 / 模型数)
4
+ // POST /dsh-dock/providerconfig —— action=listModels 拉取上游模型列表(GET {baseURL}/models)
5
+ // action=create|update|delete 维护 llm-pi-ai 的 providers 字典
6
+ //
7
+ // 为什么单独成模块:Provider 的增删改与凭据写入是**配置面**的操作(会写官方 settings
8
+ // 与本地凭据库),与「调模型的模态类型/思考强度」是两件事。拆开后可以只开【模型设置】
9
+ // 而不开【模型提供商】——不需要改 Provider 的部署(例如只用官方 DeepSeek 端点)就不必
10
+ // 把写凭据、发起出站请求的路由挂在进程里,攻击面随功能一起关掉。
11
+ //
12
+ // 模型条目的整形(piAiModelWrite)与目录读取(readModelDirectory)都在 src/model-directory.js,
13
+ // 与【模型设置】共用同一份口径,两条入口写出的条目必须一致。
14
+ import { sendJson, readBody, rejectUntrustedRequest } from '../../src/host-core.js'
15
+ import { PI_AI_APIS, ROUTE_KEY_RE, readModelDirectory, piAiModelWrite } from '../../src/model-directory.js'
16
+ // 凭据引用(apiKeyEnv → 本地凭据库条目)。注意:只从本模块取 credentialRef;
17
+ // 不同 dsh 版本导出的辅助函数不一致(isCredentialRefName 在运行期并不存在),
18
+ // 非法环境变量名一律用 try/catch + credentialRef 兜住。
19
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
+
21
+ /**
22
+ * 并行补 Provider 的 apiKeyConfigured:apiKeyEnv 指向的条目是否已在本地凭据库配置。
23
+ * 凭据库不可用 / 环境变量名非法时一律降级为 false(不抛错,避免整表 500)。
24
+ */
25
+ async function enrichApiKeyStatus(ctx, dir) {
26
+ const credentials = ctx.get('credentials')
27
+ if (!credentials || typeof credentials.describe !== 'function') return
28
+ await Promise.all((dir.providers || []).filter((p) => p.kind === 'pi-ai').map(async (p) => {
29
+ if (typeof p.apiKeyEnv !== 'string' || !p.apiKeyEnv) { p.apiKeyConfigured = false; return }
30
+ try {
31
+ const d = await credentials.describe(credentialRef(p.apiKeyEnv))
32
+ p.apiKeyConfigured = !!(d && d.configured)
33
+ } catch {
34
+ p.apiKeyConfigured = false
35
+ }
36
+ }))
37
+ }
38
+
39
+ /**
40
+ * 拉取 Provider 的可用模型列表:OpenAI 兼容端点 GET {baseURL}/models。
41
+ * Key 优先用请求里刚填的明文(还没保存就能先拉列表),否则回退到已配置的凭据库条目。
42
+ * 只做「列目录」,不代表该模型真能调用(额度/权限以上游为准)。
43
+ *
44
+ * 注意:baseURL 由调用方给出(新建 Provider 时还没落盘),因此这是**有意的出站请求**;
45
+ * 提供它的前提是它已经被浏览器信任栅栏挡过一道(见 rejectUntrustedRequest)。
46
+ */
47
+ async function fetchProviderModels(ctx, body) {
48
+ const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
49
+ const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
50
+ if (!/^https?:\/\//i.test(baseURL)) throw bad('请先填写合法的 baseURL(http:// 或 https://)')
51
+ let key = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
52
+ if (!key && typeof body.apiKeyEnv === 'string' && body.apiKeyEnv.trim()) {
53
+ const credentials = ctx.get('credentials')
54
+ if (credentials && typeof credentials.resolve === 'function') {
55
+ try {
56
+ const got = await credentials.resolve(credentialRef(body.apiKeyEnv.trim()))
57
+ key = typeof got === 'string' ? got : (got && typeof got.value === 'string' ? got.value : '')
58
+ } catch { key = '' }
59
+ }
60
+ }
61
+ const headers = { accept: 'application/json' }
62
+ if (key) {
63
+ if (body.api === 'anthropic-messages') {
64
+ headers['x-api-key'] = key
65
+ headers['anthropic-version'] = '2023-06-01'
66
+ } else {
67
+ headers.authorization = 'Bearer ' + key
68
+ }
69
+ }
70
+ let res
71
+ try {
72
+ res = await fetch(baseURL + '/models', { headers, signal: AbortSignal.timeout(20000) })
73
+ } catch (e) {
74
+ const err = new Error(`请求 ${baseURL}/models 失败:${(e && e.message) || String(e)}`)
75
+ err.statusCode = 400
76
+ throw err
77
+ }
78
+ let text = ''
79
+ try { text = await res.text() } catch { text = '' }
80
+ if (!res.ok) {
81
+ const err = new Error(`上游返回 HTTP ${res.status}${text ? ':' + text.slice(0, 240) : ''}`)
82
+ err.statusCode = 400
83
+ throw err
84
+ }
85
+ let json = null
86
+ try { json = JSON.parse(text) } catch { /* 非 JSON:下面按空列表处理 */ }
87
+ let list = []
88
+ if (Array.isArray(json)) list = json
89
+ else if (json && Array.isArray(json.data)) list = json.data
90
+ else if (json && Array.isArray(json.models)) list = json.models
91
+ const models = list.map((x) => {
92
+ if (typeof x === 'string') return { id: x }
93
+ const o = x && typeof x === 'object' ? x : {}
94
+ return { id: String(o.id || o.model || o.name || ''), name: typeof o.name === 'string' ? o.name : undefined }
95
+ }).filter((m) => m.id)
96
+ return { ok: true, count: models.length, models, fetchedAt: Date.now() }
97
+ }
98
+
99
+ /**
100
+ * Provider 级增 / 改 / 删(走 llm-pi-ai 的 providers 字典,不改内核)。
101
+ * body.action = 'create' | 'update' | 'delete'
102
+ * body.route = providers 字典的 key(= 路由 id)
103
+ * body.profile = { displayName, api, baseURL, apiKeyEnv, defaultInput, models: [草稿...] }
104
+ * body.apiKey = 可选明文 Key(写本地凭据库,不落到配置文件)
105
+ * body.revisions['llm-pi-ai'] = GET 拿到的 revision(乐观锁,撞车 409)
106
+ * 未在 profile 里出现的未知字段?——不会:base 以原 profile 为基础合并,用户自配的
107
+ * compat / description 等原样保留,只覆盖面板负责的那几个键。
108
+ */
109
+ async function writeProviderConfig(ctx, body) {
110
+ const settings = ctx.get('settings')
111
+ if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
112
+ const action = body.action
113
+ const route = typeof body.route === 'string' ? body.route.trim() : ''
114
+ const revision = body.revisions && typeof body.revisions['llm-pi-ai'] === 'number'
115
+ ? body.revisions['llm-pi-ai'] : undefined
116
+ const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
117
+
118
+ if (action === 'delete') {
119
+ if (!route) throw bad('请求缺少 route')
120
+ const dir = readModelDirectory(ctx)
121
+ const p = (dir.providers || []).find((x) => (x.route || x.id) === route)
122
+ if (!p) throw bad(`未找到 Provider:${route}`)
123
+ if (p.kind !== 'pi-ai') throw bad('内置 Provider(如 DeepSeek 官方)不能删除,只能编辑其模型')
124
+ try {
125
+ await settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', p.route || route] }], revision)
126
+ } catch (e) {
127
+ const msg = e && e.message ? e.message : String(e)
128
+ const err = new Error(`删除被拒绝:${msg}`)
129
+ err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
130
+ throw err
131
+ }
132
+ return { ok: true, deleted: p.route || route, savedAt: Date.now() }
133
+ }
134
+
135
+ if (action !== 'create' && action !== 'update') throw bad(`不支持的 action:${action}`)
136
+ if (!route || !ROUTE_KEY_RE.test(route)) throw bad('路由 key 非法:须以字母数字开头,只能含字母、数字与 . _ -')
137
+ const src = body.profile && typeof body.profile === 'object' && !Array.isArray(body.profile) ? body.profile : {}
138
+ const api = typeof src.api === 'string' ? src.api : ''
139
+ if (PI_AI_APIS.indexOf(api) < 0) throw bad(`api 非法:只支持 ${PI_AI_APIS.join(' / ')}`)
140
+ const baseURL = typeof src.baseURL === 'string' ? src.baseURL.trim() : ''
141
+ if (!/^https?:\/\//i.test(baseURL)) throw bad('baseURL 非法:须以 http:// 或 https:// 开头')
142
+ let apiKeyEnv = typeof src.apiKeyEnv === 'string' ? src.apiKeyEnv.trim() : ''
143
+ if (apiKeyEnv) {
144
+ try { credentialRef(apiKeyEnv) } catch { throw bad('apiKeyEnv 非法:须是合法的环境变量名(如 MODELSCOPE_API_KEY)') }
145
+ }
146
+ const models = Array.isArray(src.models) ? src.models : []
147
+ if (models.length === 0) throw bad('至少需要一个模型')
148
+ const seen = new Set()
149
+ for (const m of models) {
150
+ const id = m && typeof m.id === 'string' ? m.id.trim() : ''
151
+ if (!id) throw bad('模型 id 不能为空')
152
+ if (seen.has(id)) throw bad(`模型 id 重复:${id}`)
153
+ seen.add(id)
154
+ }
155
+
156
+ const base = Object.assign({}, src)
157
+ delete base.models
158
+ const displayName = typeof src.displayName === 'string' ? src.displayName.trim() : ''
159
+ if (displayName) base.displayName = displayName; else delete base.displayName
160
+ base.api = api
161
+ base.baseURL = baseURL
162
+ if (apiKeyEnv) base.apiKeyEnv = apiKeyEnv; else delete base.apiKeyEnv
163
+ base.models = models.map((m) => piAiModelWrite(
164
+ Object.assign({}, m, { id: String(m.id).trim() }),
165
+ { api, route, baseURL }
166
+ ))
167
+
168
+ const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
169
+ if (apiKey) {
170
+ if (!apiKeyEnv) {
171
+ // Key 填了但 apiKeyEnv 留空:按路由 key 自动生成凭据库条目名(与客户端 dkmEnvFromRoute 一致),
172
+ // 不再要求用户现编一个环境变量名
173
+ const s = String(route || '').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
174
+ apiKeyEnv = (s ? (/^[0-9]/.test(s) ? 'KEY_' + s : s) : 'PROVIDER') + '_API_KEY'
175
+ base.apiKeyEnv = apiKeyEnv
176
+ }
177
+ const credentials = ctx.get('credentials')
178
+ if (!credentials || typeof credentials.set !== 'function') throw bad('凭据库不可用,无法保存 API Key')
179
+ try {
180
+ await credentials.set(credentialRef(apiKeyEnv), apiKey)
181
+ } catch (e) {
182
+ const msg = e && e.message ? e.message : String(e)
183
+ const err = new Error(`写入本地凭据库失败:${msg}`)
184
+ err.statusCode = 400
185
+ throw err
186
+ }
187
+ }
188
+
189
+ try {
190
+ await settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', route], value: base }], revision)
191
+ } catch (e) {
192
+ const msg = e && e.message ? e.message : String(e)
193
+ const err = new Error(`写回官方配置被拒绝:${msg}`)
194
+ err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
195
+ throw err
196
+ }
197
+ return { ok: true, route, created: action === 'create', savedAt: Date.now() }
198
+ }
199
+
200
+ /** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
201
+ export const feature = {
202
+ id: 'providerconfig',
203
+ name: '模型提供商',
204
+ description: '管理模型 Provider 的端点与 API Key(增删改 + 拉取上游模型列表)',
205
+ defaultEnabled: false,
206
+ setup(ctx) {
207
+ const webServer = ctx.get('webServer')
208
+ if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型提供商路由')
209
+ const handler = async (req, res) => {
210
+ if (rejectUntrustedRequest(ctx, req, res)) return
211
+ try {
212
+ if (req.method === 'GET') {
213
+ const payload = readModelDirectory(ctx)
214
+ await enrichApiKeyStatus(ctx, payload)
215
+ sendJson(res, 200, payload)
216
+ return
217
+ }
218
+ if (req.method === 'POST') {
219
+ const body = await readBody(req)
220
+ const action = body && typeof body === 'object' ? body.action : undefined
221
+ const result = action === 'listModels'
222
+ ? await fetchProviderModels(ctx, body)
223
+ : await writeProviderConfig(ctx, body)
224
+ sendJson(res, 200, result)
225
+ return
226
+ }
227
+ sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
228
+ } catch (e) {
229
+ const status = e && e.statusCode ? e.statusCode : 500
230
+ sendJson(res, status, { ok: false, error: e instanceof Error ? e.message : String(e) })
231
+ }
232
+ }
233
+ return webServer.register({ kind: 'exact', path: '/dsh-dock/providerconfig', handler })
234
+ },
235
+ }