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.
@@ -8,7 +8,7 @@
8
8
  // 其编辑 UI 在模型设置页内(见 features/modelconfig/view.js)。
9
9
  import { BlockAssembler } from '@deepseek-ai/dsh-llm'
10
10
  import { readDockRoot } from '../../src/host-core.js'
11
- import { readModelDirectory } from '../modelconfig/host.js'
11
+ import { readModelDirectory } from '../../src/model-directory.js'
12
12
 
13
13
  /** 递归判断内容块里是否有图片(含 tool-result 嵌套,与官方 contentHasImage 同构)。 */
14
14
  function contentHasImageDeep(content) {
package/index.js CHANGED
@@ -15,17 +15,22 @@
15
15
  // - animation 任务动画(v0.5.0):会话任务追踪 + 动效配置持久化(参照 @wycto/dsh-task-pulse)
16
16
  // - notify 任务通知:完成/异常/需确认通知 + 提示音/系统通知/钉钉飞书推送(从任务动画拆出)
17
17
  // - runstate 运行状态:进行中任务与最近完成一览(从任务动画拆出;只读,无配置段)
18
+ // - tts 语音朗读:把会话回复读出来(浏览器 TTS;宿主半部只管配置 + 共享任务追踪)
19
+ // - modelconfig 模型设置:模型目录的输入模态与思考强度(写回官方配置)
20
+ // - providerconfig 模型提供商:Provider 增删改 + API Key + 拉取上游模型列表
18
21
  // - mobile 手机适配:窄屏排版/Tab 栏/抽屉/遮罩/键盘(宿主经 tapIndex 注入 index.html)
19
22
  // - mobile-relay 远程访问:账号密码登录的远程入口(网关 + 账号 + 浏览模式目录选择器)
20
- import { DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
23
+ import { DockConfig, sendJson, readBody, rejectUntrustedRequest, migrateNotifyConfig, migrateModelsFeatureId, migrateImportedFeatures, migrateImportedSections, bindDockConfig, readDockRoot, mutateDockSection } from './src/host-core.js'
21
24
  import { injectWelcomeAck } from './src/welcome-ack.js'
22
25
  import { feature as fModels } from './features/modelconfig/host.js'
26
+ import { feature as fProviderConfig } from './features/providerconfig/host.js'
23
27
  import { feature as fVisionProxy } from './features/visionproxy/host.js'
24
28
  import { feature as fBalance } from './features/balance/host.js'
25
29
  import { feature as fTokenlog } from './features/tokenlog/host.js'
26
30
  import { feature as fAnimation } from './features/animation/host.js'
27
31
  import { feature as fNotify } from './features/notify/host.js'
28
32
  import { feature as fRunState } from './features/runstate/host.js'
33
+ import { feature as fTts } from './features/tts/host.js'
29
34
  import { feature as fMobileRelay } from './features/mobile-relay/host.js'
30
35
  import { feature as fMobile } from './features/mobile/host.js'
31
36
 
@@ -62,12 +67,14 @@ export function apply(ctx, config) {
62
67
  // 规划中的功能缺省 false,等实现后移除 roadmap 并打开。
63
68
  const FEATURES = [
64
69
  fModels,
70
+ fProviderConfig,
65
71
  fVisionProxy,
66
72
  fBalance,
67
73
  fTokenlog,
68
74
  fAnimation,
69
75
  fNotify,
70
76
  fRunState,
77
+ fTts,
71
78
  fMobileRelay,
72
79
  fMobile,
73
80
  ]
@@ -193,6 +200,7 @@ export function apply(ctx, config) {
193
200
  kind: 'exact',
194
201
  path: '/dsh-dock/features',
195
202
  handler: async (req, res) => {
203
+ if (rejectUntrustedRequest(ctx, req, res)) return
196
204
  try {
197
205
  if (req.method === 'POST') {
198
206
  const body = await readBody(req)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.15.0",
4
- "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
3
+ "version": "0.16.0",
4
+ "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计,支持各模型自定义单价、多段分时价与周末/节假日谷价)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览)、语音朗读(浏览器 TTS 读回复:回合下方喇叭按钮 + 自动朗读);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -43,7 +43,7 @@
43
43
  "scripts": {
44
44
  "build:client": "node scripts/build-client.mjs",
45
45
  "test:client": "node scripts/test-client-views.mjs",
46
- "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-host.mjs && node scripts/test-mobile-relay-host.mjs && node scripts/test-mobile-relay.mjs && node scripts/test-welcome-ack.mjs",
46
+ "test:host": "node scripts/test-task-host.mjs && node scripts/test-tokenlog-host.mjs && node scripts/test-route-trust-host.mjs && node scripts/test-tts-host.mjs && node scripts/test-legacy-import.mjs && node scripts/test-mobile-host.mjs && node scripts/test-mobile-relay-host.mjs && node scripts/test-mobile-relay.mjs && node scripts/test-welcome-ack.mjs",
47
47
  "dev:client": "node scripts/watch-client.mjs",
48
48
  "dev:link": "node scripts/dev-link-deps.mjs"
49
49
  },
Binary file
package/src/client.jsx CHANGED
@@ -8,8 +8,8 @@
8
8
  // 在浏览器端 require("dsh-dock").dockBridge.register(def) 把自己的视图注册进本面板;
9
9
  // dock 未安装时该包走自己的独立面板——「单独成包发布」与「完美集成功能坞」两全。
10
10
  //
11
- // 面板挂载在 settings.section:设置 → 功能坞;侧栏底部另有「功能坞」入口按钮
12
- // (sidebar.footer.action),点击弹出居中功能面板(shell.overlay)。
11
+ // 面板挂载在 settings.section:设置 → 功能坞;侧栏「··· 更多」弹出菜单里另有「功能坞」
12
+ // 菜单项(settings.launcher.menu.item),点击弹出居中功能面板(shell.overlay)。
13
13
  // 弹层窗口:默认大窗,支持最大化/最小化、标题栏拖动、右下角缩放(几何页内记忆)。
14
14
  import react from "react";
15
15
  import {
@@ -20,21 +20,23 @@ import {
20
20
  } from "./shared.js";
21
21
  import { feature as fTokenlog } from "../features/tokenlog/view.jsx";
22
22
  import { feature as fModelconfig } from "../features/modelconfig/view.js";
23
+ import { feature as fProviderConfig } from "../features/providerconfig/view.js";
23
24
  import { feature as fHeartbeat } from "../features/heartbeat/view.js";
24
25
  import { feature as fTheme } from "../features/theme/view.js";
25
26
  import { feature as fBalance } from "../features/balance/view.js";
26
27
  import { feature as fAnimation } from "../features/animation/view.jsx";
27
28
  import { feature as fNotify } from "../features/notify/view.jsx";
28
29
  import { feature as fRunState } from "../features/runstate/view.jsx";
30
+ import { feature as fTts } from "../features/tts/view.jsx";
29
31
  import { feature as fGames } from "../features/games/view.jsx";
30
32
  import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
31
33
  import { feature as fMobile } from "../features/mobile/view.js";
32
34
 
33
35
  const name = "dsh-dock";
34
- const DOCK_VERSION = "0.15.0";
36
+ const DOCK_VERSION = "0.16.0";
35
37
 
36
38
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
37
- const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay, fMobile];
39
+ const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fProviderConfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fTts, fGames, fMobileRelay, fMobile];
38
40
  // 规划占位(路线图):接入后移除并建 features/<id>/ 模块
39
41
  const PLANNED_FEATURES = [];
40
42
  const PLANNED_NOTES = {};
@@ -123,15 +125,16 @@ const SHELL_CSS = [
123
125
  ".dock-switch.on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}",
124
126
  ".dock-badge{flex:none;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:3px 10px;font-size:12px;}",
125
127
  ".dock-body{border-top:1px solid var(--dsw-alias-border-l1);padding-top:8px;color:var(--dsw-alias-label-secondary);display:flex;flex-direction:column;gap:4px;}",
126
- // 侧栏入口按钮(docke2- 前缀)+ 功能坞弹出面板(dockm- 前缀,仿 dsh 设置的居中模态:遮罩 + 对话框,左导航 + 右内容)
127
- // 侧栏入口按钮(docke2- 前缀):与 dsh 设置按钮(.VOzbGW_trigger)几何逐条对齐——
128
- // 宽栏同为整宽左对齐行按钮(高 42、margin 4/-2、padding 0 10px 0 8px、圆角 12),窄栏同为 36 圆钮居中,
129
- // 使两按钮上下成列、左右边缘完全对齐(此前右对齐/叠放都会错位)。
130
- ".docke2-btn{box-sizing:border-box;cursor:pointer;display:flex;align-items:center;gap:8px;border:none;background:transparent;color:var(--dsw-alias-label-primary);border-radius:12px;width:calc(100% + 4px);height:42px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;transition:background .15s var(--ds-ease-in-out),color .15s var(--ds-ease-in-out);}",
131
- ".docke2-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}",
132
- ".docke2-btn.docke2-on{color:var(--dk-accent);background:var(--dk-accent-soft);}",
133
- ".docke2-btn.docke2-rail{width:36px;height:36px;margin:8px 0 10px;padding:0;border-radius:50%;justify-content:center;gap:0;}",
134
- ".docke2-label{white-space:nowrap;overflow:hidden;}",
128
+ // 「··· 更多」菜单里的功能坞菜单项(docke2-mi- 前缀)+ 功能坞弹出面板(dockm- 前缀,仿 dsh 设置的居中模态:遮罩 + 对话框,左导航 + 右内容)
129
+ // 菜单行指标仿宿主内置菜单项(ui-primitives Menu.module.css 的 .item/.itemIcon/.itemLabel):
130
+ // 整宽块级行(按钮直接落在菜单视口的 flex 列里)、gap 6、min-height 34、padding 6px 8px、
131
+ // 圆角 --dsw-radius-md、字号 13/行高 20;悬停与键盘聚焦(宿主菜单走真实焦点)同款高亮背景。
132
+ ".docke2-mi-item{display:flex;align-items:center;gap:6px;box-sizing:border-box;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:var(--dsw-radius-md);background:transparent;cursor:pointer;font-family:inherit;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);text-align:left;}",
133
+ ".docke2-mi-item:hover{background:var(--dsw-alias-interactive-bg-hover);}",
134
+ ".docke2-mi-item:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none;}",
135
+ ".docke2-mi-icon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center;color:var(--dsw-alias-menu-icon);}",
136
+ ".docke2-mi-icon svg{width:14px;height:14px;}",
137
+ ".docke2-mi-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
135
138
  // 遮罩固定用深色半透明:亮色主题下宿主 bg-layer-1 是纯白,白底遮罩会让弹窗与页面完全无层次
136
139
  ".dockm-backdrop{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:rgba(15,17,21,.45);backdrop-filter:blur(4px);pointer-events:auto;animation:dockm-fade .15s var(--ds-ease-in-out);}",
137
140
  "@keyframes dockm-fade{from{opacity:0}to{opacity:1}}",
@@ -300,18 +303,24 @@ function DockIcon(props) {
300
303
  react.createElement("rect", { x: 9, y: 9, width: 4.4, height: 4.4, rx: 1.2 }));
301
304
  }
302
305
 
303
- function DockEntry(props) {
304
- const wide = !!props.wide;
306
+ // 「··· 更多」菜单里的功能坞菜单项(settings.launcher.menu.item)。
307
+ // 协议约束:恰好一个 role="menuitem" 按钮;宿主 Menu 不会因点击自动关闭,激活时先
308
+ // dismiss()(关闭菜单,宿主负责焦点回还),再切换功能坞弹层;它不是 disclosure 触发器,
309
+ // 不带 aria-expanded/aria-haspopup。开合状态与旧侧栏入口同源:panelNav.open + subscribePanel。
310
+ function DockMenuItem(props) {
305
311
  const [open, setOpen] = react.useState(panelNav.open);
306
312
  react.useEffect(() => subscribePanel(() => setOpen(panelNav.open)), []);
307
313
  return react.createElement("button", {
308
314
  type: "button",
309
- className: "docke2-btn" + (open ? " docke2-on" : "") + (wide ? "" : " docke2-rail"),
310
- title: "功能坞",
311
- "aria-label": "功能坞",
312
- "aria-expanded": open,
313
- onClick: () => setPanelOpen(!open)
314
- }, react.createElement(DockIcon, { size: wide ? 16 : 18 }), wide ? react.createElement("span", { className: "docke2-label" }, "功能坞") : null);
315
+ role: "menuitem",
316
+ className: "docke2-mi-item",
317
+ onClick: () => {
318
+ if (typeof props.dismiss === "function") props.dismiss();
319
+ setPanelOpen(!open);
320
+ }
321
+ }, react.createElement("span", { className: "docke2-mi-icon" },
322
+ react.createElement(DockIcon, { size: 14 })),
323
+ react.createElement("span", { className: "docke2-mi-label" }, "功能坞"));
315
324
  }
316
325
 
317
326
  // ---- 功能坞弹出面板:shell.overlay(仿 dsh 设置:居中模态 = 遮罩 + 对话框) ----
@@ -651,6 +660,23 @@ function FeatureOverlays() {
651
660
  return react.createElement(react.Fragment, null, items);
652
661
  }
653
662
 
663
+ // ---- 回合尾(会话每条回复下方):已启用功能挂在这儿的组件(如【语音朗读】的喇叭按钮) ----
664
+ // 宿主 ui-chat 的 conversation.chat.turnTail 是 list 座位,只对「已完成回合」渲染,
665
+ // 每个条目收到 { turn, seq, openFile } + 会话标准 kit(sessionId / useChat ...)。
666
+ // 组件自己判断有没有内容可渲染,没内容返回 null(宿主不会留空隙)。
667
+ function FeatureTurnTail(props) {
668
+ const [, force] = react.useReducer((n) => n + 1, 0);
669
+ react.useEffect(() => subscribeFeatureState(() => force()), []);
670
+ const items = [];
671
+ for (const f of allModules()) {
672
+ if (f.planned || !stateOf(f.id).enabled || typeof f.TurnTail !== "function") continue;
673
+ items.push(react.createElement(FeatureBoundary, { key: f.id, label: f.name },
674
+ react.createElement(f.TurnTail, Object.assign({}, props, { ctx: ctxRef.current, feature: f }))));
675
+ }
676
+ if (items.length === 0) return null;
677
+ return react.createElement(react.Fragment, null, items);
678
+ }
679
+
654
680
  // ---- 插件应用:注册四处 UI(入口按钮 / 弹层 / 设置页 / 会话区 chips),注入样式 ----
655
681
  // ctx 以 ref 供视图组件使用(timer/theme 等服务按需自取;重装/HMR 时更新)
656
682
  const ctxRef = { current: null };
@@ -665,9 +691,9 @@ export function apply(ctx) {
665
691
  const slots = ctx.get("slots");
666
692
  if (slots === undefined) return;
667
693
 
668
- slots.inject("sidebar.footer.action", () => slots.register(
669
- { name: "sidebar.footer.action", id: "dsh-dock", order: 1, label: "功能坞" },
670
- (props) => react.createElement(DockEntry, props)));
694
+ slots.inject("settings.launcher.menu.item", () => slots.register(
695
+ { name: "settings.launcher.menu.item", id: "dsh-dock", order: 1, label: "功能坞" },
696
+ (props) => react.createElement(DockMenuItem, props)));
671
697
  slots.inject("shell.overlay", () => slots.register(
672
698
  { name: "shell.overlay", id: "dsh-dock-panel", order: 21, label: "功能坞面板" },
673
699
  () => react.createElement(DockModal, null)));
@@ -682,6 +708,10 @@ export function apply(ctx) {
682
708
  slots.inject("conversation.input.left", () => slots.register(
683
709
  { name: "conversation.input.left", id: "dsh-dock-chips", order: 10, label: "功能坞" },
684
710
  (zone) => react.createElement(DockChips, Object.assign({}, zone, { ctx: ctxRef.current }))));
711
+ // 已完成回合的下方(回复动作区之前):已启用功能挂的组件(【语音朗读】的喇叭)
712
+ slots.inject("conversation.chat.turnTail", () => slots.register(
713
+ { name: "conversation.chat.turnTail", id: "dsh-dock-turn-tail", order: 40, label: "功能坞" },
714
+ (owner) => react.createElement(FeatureTurnTail, owner)));
685
715
  }
686
716
 
687
717
  export const inject = ["timer"];
package/src/host-core.js CHANGED
@@ -118,6 +118,36 @@ export const DockConfig = z.object({
118
118
  // 一次性迁移标记:animation 段的旧通知配置已搬到本段(搬完置 true,避免每次启动重复写)
119
119
  migratedFromAnimation: z.boolean().default(false),
120
120
  }).default({}).volatile(),
121
+ // 【语音朗读】模块:把会话回复用浏览器语音合成(TTS)读出来。
122
+ // 行为配置(是否自动朗读、朗读哪些内容)放这里跨设备共享;
123
+ // 音色(voiceURI)虽然也存这一份,但**音色列表是每台设备/浏览器各自的**,
124
+ // 客户端用之前会拿本机 getVoices() 校验,取不到就按语言回落默认音色(见 features/tts/view.jsx)。
125
+ tts: z.object({
126
+ /** 会话(回合)结束后自动朗读最后一条回复。 */
127
+ autoPlay: z.boolean().default(false),
128
+ /** 任务以出错/中止结束时是否也朗读(默认读,方便不看屏幕也知道挂了)。 */
129
+ autoPlayOnFinish: z.boolean().default(true),
130
+ /** 新任务结束时打断正在读的内容,改读新的。 */
131
+ interruptOnNew: z.boolean().default(true),
132
+ /** 语速(0.5~2.0)。 */
133
+ rate: z.number().default(1),
134
+ /** 音调(0~2)。 */
135
+ pitch: z.number().default(1),
136
+ /** 音量(0~1)。 */
137
+ volume: z.number().default(1),
138
+ /** 指定音色(speechSynthesis voiceURI;空 = 按语言自动挑)。 */
139
+ voiceURI: z.string().default(''),
140
+ /** 强制朗读语言(如 zh-CN / en-US;空 = 按文本自动判定)。 */
141
+ lang: z.string().default(''),
142
+ /** 朗读时跳过代码块(``` 围栏与 `行内代码` 只留文字)。 */
143
+ skipCode: z.boolean().default(true),
144
+ /** 朗读时跳过 Markdown 表格(逐格念出来没有意义)。 */
145
+ skipTables: z.boolean().default(true),
146
+ /** 单次朗读字数上限(0 = 不限;自动朗读建议设限,手动点喇叭一般读全文)。 */
147
+ maxChars: z.number().default(0),
148
+ /** 自动朗读时只读回复的前 N 句(0 = 不限)。眼睛不舒服想听结论的人通常只要前几句。 */
149
+ maxSentences: z.number().default(0),
150
+ }).default({}).volatile(),
121
151
  })
122
152
 
123
153
  // ── 本地配置镜像(替代已移除的 settings.get) ─────────────────────────────
@@ -448,6 +478,60 @@ export function sendJson(res, status, value) {
448
478
  res.end(JSON.stringify(value))
449
479
  }
450
480
 
481
+ /** 「信任栅栏缺失」只告警一次,避免每个请求刷一条日志。 */
482
+ let warnedNoTrustSurface = false
483
+
484
+ /**
485
+ * 浏览器信任栅栏:**每个自行注册的 `webServer` 路由都必须先调它**。
486
+ *
487
+ * 为什么必须:`webServer` 的命名路由由载体直接派发给 handler(见 dsh 的
488
+ * `packages/host/webserver` —— 匹配到路由就 `route.handler(req, res)`),载体本身
489
+ * 「不拥有认证或 Origin 策略」;`/api` 的 Host/Origin 校验与会话认证由 Connection 在
490
+ * 它自己的 FetchHandler 上做,**管不到插件注册的命名路由**。所以不检查的插件路由就是
491
+ * 一扇对任何能连到端口的人都敞开的门,dsh-access 这类门禁插件也拦不到(它只有
492
+ * exact 表 / fallback / connection-request / upgrades 四个缝)。
493
+ *
494
+ * 做法与官方插件一致:`dsh-host-open-in-app`、`dsh-experimental-inspector` 都在自己的
495
+ * 路由入口调 `connection.requestRejection(req)`,值即 401(Host 可信但无会话)或
496
+ * 403(Host/Origin 栅栏拒绝)。Connection 不可用时按 fail-closed 处理——dsh-dock 的
497
+ * 路由本身就是 web 专用的。
498
+ *
499
+ * @returns {boolean} true = 已拒绝并写完响应,调用方必须立即 return。
500
+ */
501
+ export function rejectUntrustedRequest(ctx, req, res) {
502
+ const connection = ctx && typeof ctx.get === 'function' ? ctx.get('connection') : null
503
+ const requestRejection = connection && typeof connection.requestRejection === 'function'
504
+ ? connection.requestRejection.bind(connection)
505
+ : null
506
+ if (requestRejection === null) {
507
+ if (!warnedNoTrustSurface) {
508
+ warnedNoTrustSurface = true
509
+ console.warn('[dsh-dock] connection 服务不可用:/dsh-dock 路由将按未受信请求一律拒绝(401)')
510
+ }
511
+ sendJson(res, 401, { ok: false, error: { code: 'untrusted', message: '浏览器信任栅栏不可用,已拒绝请求' } })
512
+ return true
513
+ }
514
+ let rejection
515
+ try {
516
+ rejection = requestRejection(req)
517
+ } catch (error) {
518
+ // 栅栏自身异常:宁可拒绝,也不放行一个身份未知的请求。
519
+ console.warn('[dsh-dock] 浏览器信任栅栏判定异常,已按拒绝处理:', (error && error.message) || String(error))
520
+ sendJson(res, 403, { ok: false, error: { code: 'untrusted', message: '请求来源无法判定,已拒绝' } })
521
+ return true
522
+ }
523
+ if (rejection === undefined) return false
524
+ sendJson(res, rejection, {
525
+ ok: false,
526
+ error: {
527
+ code: 'untrusted',
528
+ message: rejection === 401 ? '未登录或浏览器会话已失效' : '请求来源不被信任',
529
+ },
530
+ })
531
+ if (typeof req.resume === 'function') req.resume()
532
+ return true
533
+ }
534
+
451
535
  /** 解析 POST 请求体 JSON(限长 2MB)。 */
452
536
  export function readBody(req) {
453
537
  return new Promise((resolve, reject) => {
@@ -0,0 +1,275 @@
1
+ // dsh-dock · 模型目录共享层(宿主侧)
2
+ //
3
+ // 把官方 settings 里的 llm-deepseek / llm-pi-ai 两个命名空间归一成一份「Provider →
4
+ // 模型目录」视图,并承担写回时的条目整形。之所以独立成 src/ 而不是留在某个功能模块里:
5
+ // - 【模型设置】只编辑单个模型的模态类型与思考强度(用目录视图 + 写模型条目);
6
+ // - 【模型提供商】管理 Provider 本身的增删改与凭据(用目录视图 + 写 Provider 配置);
7
+ // - 【图片理解代理】要按目录挑视觉模型(只读目录)。
8
+ // 三个模块共用同一份归一化逻辑,避免各自复刻一遍口径。
9
+ import { walkPath } from './host-core.js'
10
+
11
+ /** 官方 schema 接受的输入模态(写回配置的 input 只允许这两个)。 */
12
+ export const HOST_MODALITIES = ['text', 'image']
13
+ /** pi-ai 路由支持的 API 协议。 */
14
+ export const PI_AI_APIS = ['openai-completions', 'openai-responses', 'anthropic-messages']
15
+ /** Provider 路由 key(= settings 里 providers.<route> 的 key),须能安全用作对象键与凭据寻址。 */
16
+ export const ROUTE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
17
+ /** pi-ai 支持的思考档位(升序)。 */
18
+ export const PI_AI_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
19
+ /** deepseek 官方支持的思考档位。 */
20
+ export const DEEPSEEK_LEVELS = ['off', 'low', 'high', 'max']
21
+
22
+ /** deepseek-official 内置默认值(与 balance 模块同款兜底)。 */
23
+ export const DEEPSEEK_DEFAULTS = {
24
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
25
+ baseURL: 'https://api.deepseek.com',
26
+ }
27
+
28
+ function providerKindOf(entry) {
29
+ if (entry.settingsNs === 'llm-deepseek') return 'deepseek'
30
+ if (entry.settingsNs === 'llm-pi-ai') return 'pi-ai'
31
+ return 'other'
32
+ }
33
+
34
+ /** 正整数或 undefined(目录数字字段透传)。 */
35
+ function posInt(v) {
36
+ return Number.isInteger(v) && v > 0 ? v : undefined
37
+ }
38
+
39
+ /** 字符串数组透传(仅保留非空字符串)。 */
40
+ export function strList(v) {
41
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []
42
+ }
43
+
44
+ /** 把 pi-ai 模型条目归一为面板视图(raw = 原始条目,保存时原样回传保留未知字段)。 */
45
+ function piAiModelView(entry, routeDefaultInput) {
46
+ const input = strList(entry.input).filter((m) => HOST_MODALITIES.includes(m))
47
+ const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
48
+ return {
49
+ id: typeof entry.id === 'string' ? entry.id : '',
50
+ name: typeof entry.name === 'string' ? entry.name : undefined,
51
+ contextWindow: posInt(entry.contextWindow),
52
+ maxTokens: posInt(entry.maxTokens),
53
+ input: input.length > 0 ? input : (strList(routeDefaultInput).length > 0 ? strList(routeDefaultInput) : ['text']),
54
+ inputInherited: input.length === 0,
55
+ efforts: raw.reasoningEfforts === undefined ? 'inherit' : raw.reasoningEfforts === false ? 'off' : 'custom',
56
+ effortMap: raw.reasoningEfforts && typeof raw.reasoningEfforts === 'object' ? raw.reasoningEfforts : null,
57
+ tags: strList(entry.dockTags),
58
+ raw,
59
+ }
60
+ }
61
+
62
+ /** 把 deepseek 目录模型归一为面板视图。 */
63
+ function deepSeekModelView(entry) {
64
+ const input = strList(entry.inputModalities).filter((m) => HOST_MODALITIES.includes(m))
65
+ const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
66
+ return {
67
+ id: typeof entry.id === 'string' ? entry.id : '',
68
+ name: typeof entry.name === 'string' ? entry.name : undefined,
69
+ contextWindow: posInt(entry.contextWindow),
70
+ maxTokens: posInt(entry.maxTokens),
71
+ input: input.length > 0 ? input : ['text'],
72
+ inputInherited: input.length === 0,
73
+ tags: strList(entry.dockTags),
74
+ raw,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 读模型目录:按"已配置"口径枚举 Provider,
80
+ * 输出各 Provider 的模型列表(含输入类型 / 思考强度)与各 settings 命名空间的 revision。
81
+ */
82
+ export function readModelDirectory(ctx) {
83
+ const llm = ctx.get('llm')
84
+ const settings = ctx.get('settings')
85
+ const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
86
+ let views = []
87
+ try {
88
+ views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
89
+ } catch {
90
+ // settings 未挂载
91
+ }
92
+ const descOf = (ns) => views.find((v) => v && String(v.ns) === ns)
93
+
94
+ let sel = null
95
+ try {
96
+ sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
97
+ } catch {
98
+ // 服务缺失
99
+ }
100
+
101
+ const providers = []
102
+ for (const e of entries) {
103
+ const kind = providerKindOf(e)
104
+ if (kind === 'other') continue // 官方之外的适配器暂不编辑(无稳定 schema)
105
+ const desc = descOf(e.settingsNs)
106
+ if (!desc) continue
107
+ const profileUser = desc.user ? walkPath(desc.user, e.settingsPath) : undefined
108
+ const profileBase = desc.base ? walkPath(desc.base, e.settingsPath) : undefined
109
+ const isDefaultProvider = !!(sel && sel.provider === e.provider)
110
+ // deepseek 官方始终可编辑(schema 默认目录兜底);其余按"已配置"口径
111
+ const configured = kind === 'deepseek' || isDefaultProvider
112
+ || (profileUser && typeof profileUser === 'object')
113
+ || (profileBase && typeof profileBase === 'object')
114
+ if (!configured) continue
115
+
116
+ const resolved = walkPath(desc.value, e.settingsPath) // schema 默认已套上的合成视图
117
+ if (kind === 'deepseek') {
118
+ const section = resolved && typeof resolved === 'object' ? resolved : {}
119
+ const models = Array.isArray(section.models) ? section.models : []
120
+ providers.push({
121
+ id: e.provider,
122
+ displayName: (profileUser && profileUser.displayName) || e.displayName || e.provider,
123
+ api: 'deepseek',
124
+ baseURL: (profileUser && profileUser.baseURL) || section.baseURL || DEEPSEEK_DEFAULTS.baseURL,
125
+ apiKeyEnv: (profileUser && profileUser.apiKeyEnv) || DEEPSEEK_DEFAULTS.apiKeyEnv,
126
+ kind,
127
+ settingsNs: e.settingsNs,
128
+ settingsPath: [...e.settingsPath],
129
+ thinking: section.thinking === 'disabled' ? 'disabled' : 'enabled',
130
+ defaultEffort: DEEPSEEK_LEVELS.includes(section.reasoningEffort) ? section.reasoningEffort : undefined,
131
+ effortLevels: DEEPSEEK_LEVELS.slice(),
132
+ models: models.map(deepSeekModelView),
133
+ })
134
+ } else {
135
+ const route = e.settingsPath[1] || e.provider
136
+ const profile = (profileUser && typeof profileUser === 'object' ? profileUser
137
+ : (profileBase && typeof profileBase === 'object' ? profileBase : resolved)) || {}
138
+ const resolvedProfile = (resolved && typeof resolved === 'object' ? resolved : {}) || {}
139
+ const models = Array.isArray(profile.models) ? profile.models : []
140
+ providers.push({
141
+ id: e.provider,
142
+ route,
143
+ displayName: profile.displayName || e.displayName || e.provider,
144
+ api: typeof profile.api === 'string' ? profile.api : undefined,
145
+ baseURL: typeof profile.baseURL === 'string' ? profile.baseURL : undefined,
146
+ apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined,
147
+ kind,
148
+ settingsNs: e.settingsNs,
149
+ settingsPath: [...e.settingsPath],
150
+ // 用户层原始配置:编辑表单按它回填,未知字段(自配 compat/description 等)原样保留。
151
+ rawProfile: profileUser && typeof profileUser === 'object' && !Array.isArray(profileUser) ? profileUser : {},
152
+ apis: PI_AI_APIS.slice(),
153
+ defaultInput: strList(resolvedProfile.defaultInput),
154
+ models: models.map((m) => piAiModelView(m && typeof m === 'object' ? m : { id: String(m) }, resolvedProfile.defaultInput)),
155
+ })
156
+ }
157
+ }
158
+
159
+ const revisions = {}
160
+ for (const ns of ['llm-pi-ai', 'llm-deepseek']) {
161
+ const d = descOf(ns)
162
+ if (d && d.revision !== undefined) revisions[ns] = d.revision
163
+ }
164
+ return { generatedAt: Date.now(), default: sel, revisions, providers }
165
+ }
166
+
167
+ /**
168
+ * 给目录视图补运行时视角的输入模态(llm.resolveModelInfo,与官方投影同源:
169
+ * entry.input → 内置目录 → 路由默认)。面板据此展示「原图直发 vs 走视觉代理」,
170
+ * 避免"条目留空但目录继承多模态"的模型被误分组。
171
+ * 注意:功能启用后 llm.resolveModelInfo 被本插件包装(虚拟多模态),此处须取原函数保真。
172
+ */
173
+ export async function enrichRuntimeInput(ctx, dir) {
174
+ const llm = ctx.get('llm')
175
+ const resolve = llm && (llm.__dockOrigResolveModelInfo
176
+ || (typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null))
177
+ if (!resolve) return
178
+ await Promise.all((dir.providers || []).flatMap((p) => (p.models || []).map(async (m) => {
179
+ try {
180
+ const info = await resolve(p.id, m.id)
181
+ if (info && Array.isArray(info.inputModalities)) {
182
+ m.runtimeInput = info.inputModalities.filter((x) => HOST_MODALITIES.includes(x))
183
+ }
184
+ } catch {
185
+ // 单模型解析失败不影响整表(面板回退用 input 展示)
186
+ }
187
+ })))
188
+ }
189
+
190
+ /** 校验面板数字草稿并落到条目上(空 = 删除字段,继承默认)。 */
191
+ function setPosInt(entry, field, value) {
192
+ if (value === undefined || value === null || value === '') {
193
+ delete entry[field]
194
+ return
195
+ }
196
+ const n = Number(value)
197
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`模型 ${entry.id} 的 ${field} 须为正整数`)
198
+ entry[field] = n
199
+ }
200
+
201
+ /**
202
+ * 校验面板提交的模型草稿,产出写回 pi-ai 的模型条目(不合法直接抛错)。
203
+ * 基于原条目(raw)合并编辑:未知字段(用户自配 compat/description 等)原样保留。
204
+ * 启用思考(custom)时的兜底(2026-08-22 事故修复):
205
+ * 不在 pi-ai 内置目录里的模型没有 catalog compat,检测默认 supportsDeveloperRole=true,
206
+ * 而 pi-ai 对 reasoning 模型会把 system 改写成 developer 角色 → 百炼等 OpenAI 兼容端点 400。
207
+ * 故凡启用思考一律显式 supportsDeveloperRole:false(system 全端点通用);
208
+ * 百炼/阿里云家族 baseURL 再补 thinkingFormat:'qwen'(与目录内模型一致,思考参数用 qwen 格式)。
209
+ *
210
+ * 【模型设置】(编辑已有 Provider 的模型)与【模型提供商】(新建/更新 Provider 时随带模型)
211
+ * 共用这一份整形逻辑,两条入口写出的条目必须一致。
212
+ */
213
+ export function piAiModelWrite(m, p) {
214
+ if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
215
+ const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
216
+ const entry = Object.assign({}, raw)
217
+ entry.id = m.id
218
+ if (typeof m.name === 'string' && m.name) entry.name = m.name
219
+ else delete entry.name
220
+ setPosInt(entry, 'contextWindow', m.contextWindow)
221
+ setPosInt(entry, 'maxTokens', m.maxTokens)
222
+ const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
223
+ if (input.length > 0) entry.input = input // 留空 = 继承路由/目录默认
224
+ else delete entry.input
225
+ const tags = strList(m.tags)
226
+ if (tags.length > 0) entry.dockTags = tags // 标注(如 video),官方校验忽略、随配置持久化
227
+ else delete entry.dockTags
228
+ if (m.effortsMode === 'off') {
229
+ entry.reasoningEfforts = false
230
+ } else if (m.effortsMode === 'custom') {
231
+ const levels = m.effortLevels || {}
232
+ const map = {}
233
+ let real = 0
234
+ for (const lv of PI_AI_LEVELS) {
235
+ if (!levels[lv]) continue
236
+ if (lv === 'off') map.off = null // 支持关闭:不发参数
237
+ else { map[lv] = lv; real += 1 }
238
+ }
239
+ if (real === 0) throw new Error(`模型 ${m.id} 至少要勾选一个思考档位(off 之外)`)
240
+ entry.reasoningEfforts = map
241
+ // 目录路由(用户层未写 api/baseURL,靠 pi-ai 内置目录提供)视作 openai 兼容
242
+ const apiOk = !p.api || (typeof p.api === 'string' && p.api.indexOf('openai') === 0)
243
+ if (apiOk) {
244
+ const compat = Object.assign({}, entry.compat)
245
+ compat.supportsDeveloperRole = false
246
+ // 百炼/阿里云家族(路由 id 或 baseURL 命中)思考参数用 qwen 格式,与目录内模型一致
247
+ if (/qwen|dashscope|aliyuncs/i.test(String(p.route || '') + ' ' + String(p.baseURL || ''))) {
248
+ compat.thinkingFormat = 'qwen'
249
+ }
250
+ entry.compat = compat
251
+ }
252
+ } else {
253
+ delete entry.reasoningEfforts // inherit = 不写该字段,跟随内置目录
254
+ }
255
+ return entry
256
+ }
257
+
258
+ /** 校验并产出写回 deepseek 官方的目录模型条目(raw 未知字段保留)。 */
259
+ export function deepSeekModelWrite(m) {
260
+ if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
261
+ const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
262
+ const entry = Object.assign({}, raw)
263
+ entry.id = m.id
264
+ if (typeof m.name === 'string' && m.name) entry.name = m.name
265
+ else delete entry.name
266
+ setPosInt(entry, 'contextWindow', m.contextWindow)
267
+ setPosInt(entry, 'maxTokens', m.maxTokens)
268
+ const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
269
+ if (input.includes('image')) entry.inputModalities = input // 纯文本留空走 schema 默认 ['text']
270
+ else delete entry.inputModalities
271
+ const tags = strList(m.tags)
272
+ if (tags.length > 0) entry.dockTags = tags
273
+ else delete entry.dockTags
274
+ return entry
275
+ }