@leonhardthust/dsh-decision-engine 1.11.2 → 1.13.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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ main model acts.
30
30
  **Modes**: off · observe · enforce
31
31
 
32
32
  **Providers**: system one · bamboonode · 硅基流动 SiliconFlow (CN / Global) ·
33
- Perplexity · OpenRouter · custom base URL
33
+ Alibaba Bailian · TokenHub · Perplexity · OpenRouter · custom base URL
34
34
 
35
35
  **Safety level (how often to ask)**: Don't ask · Ask less · Default · Ask more
36
36
  (none of the four changes permission-mandated approval or denial)
package/README.zh.md CHANGED
@@ -29,7 +29,7 @@
29
29
  **三态**:关闭 · 观察 · 生效
30
30
 
31
31
  **决策服务**:system one · bamboonode · 硅基流动 SiliconFlow(国内 / 国际)·
32
- Perplexity · OpenRouter · 自定义 base URL
32
+ 阿里云百炼 · TokenHub · Perplexity · OpenRouter · 自定义 base URL
33
33
 
34
34
  **安全档位(询问频率)**:不问 · 少问 · 默认 · 多问(四档都不改变权限强制的人工批准与拒绝)
35
35
 
@@ -42,13 +42,20 @@ mode: shadow
42
42
  # ============================================================================
43
43
  # 服务提供方(provider)
44
44
  # ============================================================================
45
- # 七选一:
45
+ # 九选一:
46
46
  # bamboonode 内置。StartLux 官方端点 POST /v1/decisions,本插件的原有默认值。
47
47
  # systemone 内置。任意 System One 协议服务 POST /v1/systemone,
48
48
  # 默认指向 TypeSafe(https://api.typesafe.ai/v1)。
49
49
  # siliconflow 内置。硅基流动(国内站)https://api.siliconflow.cn/v1/systemone
50
50
  # siliconflow-intl 内置。Silicon Flow 国际站 https://api.siliconflow.com/v1/systemone
51
51
  # (与国内站是两套账号,key 互不通用)
52
+ # bailian 内置。阿里云百炼决策模型(POST /compatible-mode/v1/systemone)。
53
+ # 需要 Workspace ID + 地域,端点域名由二者拼出;model 必填
54
+ # decision-model-preview;key 用 DASHSCOPE_API_KEY。
55
+ # tokenhub 内置。TokenHub(tokenhub.com)的 System One 端点
56
+ # https://us-api.tokenhub.com/v1/systemone。
57
+ # 注意:腾讯云 TokenHub 只有 OpenAI/Anthropic 兼容的文本/图像/
58
+ # 视频/3D 接口,**没有决策端点**;两者同名,不是同一个产品。
52
59
  # perplexity 内置。Perplexity Decider(api.perplexity.ai);
53
60
  # 地址与默认模型待实测,端点风格 auto(404 自动换路径重试)
54
61
  # openrouter 内置。OpenRouter(https://openrouter.ai/api/v1),
@@ -96,6 +103,25 @@ systemoneModel: ""
96
103
  siliconflowModel: ""
97
104
  siliconflowIntlModel: ""
98
105
 
106
+ # ---- 阿里云百炼(provider=bailian)------------------------------------------
107
+ # 决策模型:POST /compatible-mode/v1/systemone,model 必填 decision-model-preview。
108
+ # 文档:https://help.aliyun.com/zh/model-studio/decision-model-api
109
+ # 端点域名包含 Workspace ID,因此必须填 workspace 与地域:
110
+ # https://{workspaceId}.{region}.maas.aliyuncs.com/compatible-mode/v1/systemone
111
+ # 地域只支持 cn-beijing(默认)与 ap-southeast-1;API Key 必须与地域匹配。
112
+ # 鉴权用 DASHSCOPE_API_KEY(或下方的 bailianApiKeyEnv 指定的变量)。
113
+ # bailianWorkspaceId: ws_xxx
114
+ # bailianRegion: cn-beijing
115
+ # bailianModel: decision-model-preview
116
+
117
+ # ---- TokenHub(provider=tokenhub)-------------------------------------------
118
+ # tokenhub.com(TypeSafe)的 System One 决策端点:
119
+ # https://us-api.tokenhub.com/v1/systemone
120
+ # 模型可选 jev-latest / jev-1-13;鉴权用 TOKENHUB_API_KEY。
121
+ # 再次提醒:腾讯云 TokenHub(tokenhub.tencentmaas.com)没有决策端点,
122
+ # 不要拿腾讯云的 key 打这里(会 401)。
123
+ # tokenhubModel: jev-latest
124
+
99
125
  # ---- custom 预设(provider=custom 时才生效)--------------------------------
100
126
  # base URL 必填,且必须是 http/https 地址;不带 scheme 会被拒绝(内置预设
101
127
  # 允许省略 scheme,custom 不允许——自定义地址必须显式,避免猜错协议)。
@@ -387,15 +413,17 @@ guardFallbackReasoningEffort: ""
387
413
  # ============================================================================
388
414
  # 匿名使用统计(可选)
389
415
  # ============================================================================
390
- # 只上报「装了多少台、每天有多少人在用」,用来改进插件。字段只有七个:
391
- # 匿名安装 id(首次运行随机生成的 UUIDv4,与账号无关)、插件版本、平台、
392
- # 架构、宿主版本、当日决策数、事件类型。
416
+ # 用途只有一个:让作者知道还有多少人在用、哪些版本/平台在用、每天判了多少
417
+ # 次,用来判断这个插件值不值得继续维护(以及优先修哪个平台的问题)。
418
+ #
419
+ # 上报字段(全部):随机安装 id(首次运行生成的 UUIDv4,与账号无关)、插件版本、
420
+ # 平台、架构、宿主版本、当天决策数、事件类型(install / daily heartbeat)。
393
421
  #
394
- # 绝不上报:API key、prompt / 工具参数、文件路径、provider 地址、会话内容、
395
- # 用户名;服务端也不记录 IP。删掉 analytics.json 即换新身份。
422
+ # 绝不上报:API key、prompt / 工具参数、文件路径、provider 地址、模型名、会话
423
+ # 内容、用户名;服务端也不记录 IP。删掉 analytics.json 即换新身份。
396
424
  #
397
- # off 完全联网都不做(连版本检查都没有)
398
- # install 仅首次运行上报一次「装了」
425
+ # off 完全关闭:一个请求都不发
426
+ # install 仅首次运行上报一次「装了」,之后不再联网
399
427
  # daily 每天上报一次,附带当日决策数(默认)
400
428
  #
401
429
  # telemetryLevel: daily
package/docs/releasing.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # npm 发布
2
2
 
3
- 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.11.2`,公开包。
3
+ 发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.13.0`,公开包。
4
4
 
5
5
  本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
6
6
 
@@ -9,7 +9,7 @@
9
9
  - `.github/workflows/ci.yml`:main push、PR、手动运行或发布工作流复用;Ubuntu 24.04 + Node.js 24 + Vite+ 1.6.0。执行 frozen install、完整测试与接线、隔离情景、实际打包和 tarball 检查。失败不会被忽略。
10
10
  - `.github/workflows/publish.yml`:每次 `main` push 自动检测版本。先比较整个 push 的 `before` 与当前 manifest,拒绝错误包名、非法或倒退版本;再只读查询 npmjs。当前版本已上传则跳过;当前版本高于 npmjs latest 且未上传则进入完整验证及发布。多提交 push 比较整个 push 前后,不仅比较 HEAD 的父提交。
11
11
  - 后续发版只需修改 `package.json` 版本并提交到 main;无需创建 tag 或手动点运行。发布依赖检测和本次提交的完整 CI 均成功,不跳过测试。稳定版本发布到 `latest`,预发布版本自动进入 `next`。
12
- - 当前 `1.11.2` 包含 DEC-071:审批策略为 `never` 时,模型给出的软性询问(medium / 档位升级)交给 `guardFallback` 复核,而不是静默 deny;高危、注入、未知副作用与强制权限约束仍保持失败关闭。`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-067 ~ DEC-069 与 DEC-062 ~ DEC-066。
12
+ - 当前 `1.13.0` 包含 DEC-074:匿名使用统计的用途/字段/边界重写;连通性测试从页脚前移到连接方式组,同时探测决策端点与回退语言模型,结果按链路分卡展示(摘要 + 可折叠详情)。`1.12.0` 的 DEC-072 / DEC-073、`1.11.2` 的 never 回退、`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-062 ~ DEC-073。
13
13
  - checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
14
14
  - 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
15
15
 
@@ -58,7 +58,7 @@ Windows 全量测试最近仍有 3 个既有 POSIX 文件权限断言失败;
58
58
  授权及完整验证均满足后:
59
59
 
60
60
  ```powershell
61
- npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.11.2.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
61
+ npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.13.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
62
62
  npm view @leonhardthust/dsh-decision-engine version --registry "https://registry.npmjs.org/" --json
63
63
  ```
64
64
 
@@ -66,4 +66,4 @@ npm view @leonhardthust/dsh-decision-engine version --registry "https://registry
66
66
 
67
67
  ## 安装
68
68
 
69
- DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.11.2`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
69
+ DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.13.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
package/lib/client.js CHANGED
@@ -102,6 +102,11 @@ window.__ModuleLoader__.load({
102
102
  "服务端未返回 provider 列表;当前选择仍会生效": "The server returned no provider list; your current choice still applies",
103
103
  "自定义决策服务:自建 llama.cpp / vLLM、企业网关或第三方中转": "Custom decision service: self-hosted llama.cpp / vLLM, corporate gateway, or relay",
104
104
  "服务提供方": "Provider",
105
+ "免费模型(服务端下发)": "Free model (server-provided)",
106
+ "Workspace ID": "Workspace ID",
107
+ "地域": "Region",
108
+ "百炼决策端点的域名是 {template},Workspace ID 是域名的一部分,必填。填写业务空间 ID(形如 ws_xxx)": "Bailian's decision endpoint host is {template}; the Workspace ID is part of the host and is required. Enter the business workspace ID (for example ws_xxx)",
109
+ "百炼只提供 cn-beijing 与 ap-southeast-1 两个决策地域;API Key 必须与地域匹配": "Bailian offers only the cn-beijing and ap-southeast-1 decision regions; the API key must match the region",
105
110
  "生效端点": "Effective endpoint",
106
111
  "由上方 base URL + 端点风格拼接;已带 /decisions 或 /systemone 时原样使用": "Built from the base URL above plus the endpoint style; kept as-is when it already contains /decisions or /systemone",
107
112
  "内置预设的默认地址(可用 config.yml 的 baseUrl 覆盖 bamboonode)": "Built-in preset default address (config.yml's baseUrl can override bamboonode)",
@@ -139,11 +144,15 @@ window.__ModuleLoader__.load({
139
144
  "auto|由服务端决定(实测约 7.2s,比 never 慢)": "auto | decided by the server (measured ~7.2s, slower than never)",
140
145
  "always|总是思考(约 +5s,质量收益未实测)": "always | always think (about +5s; quality gain not measured)",
141
146
  // —— 匿名使用统计(DEC-020)——
142
- "匿名统计": "Anonymous stats",
147
+ "匿名使用统计": "Anonymous usage stats",
143
148
  "每天上报一次:匿名安装 id、版本、平台与当日决策数": "Reports once a day: anonymous install id, version, platform, and today's decision count",
149
+ "每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、当天决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。": "Once a day, used only so the author can tell how many people still use the plugin and whether it is worth maintaining: a random install ID, plugin version, platform/arch, host version, and today's decision count. No conversation content, file paths, keys, model names, or identity information.",
144
150
  "已关闭:任何情况下都不联网": "Off: never touches the network",
151
+ "完全关闭:不发送任何请求。": "Fully off: no request is ever sent.",
145
152
  "仅首次运行上报一次「安装了」,之后不再联网": "Reports \"installed\" once on first run, then never again",
153
+ "仅在第一次运行时上报一次「安装了」(字段同上),之后不再联网。": "Reports \"installed\" once on the first run (same fields as above), then never touches the network again.",
146
154
  "off(不联网)": "off (no network)",
155
+ "off(完全关闭)": "off (fully disabled)",
147
156
  "install(仅首次)": "install (first run only)",
148
157
  "daily(每天一次,默认)": "daily (once a day, default)",
149
158
  "auto(先 /decisions)": "auto (/decisions first)",
@@ -183,6 +192,10 @@ window.__ModuleLoader__.load({
183
192
  "重试": "Retry",
184
193
  "测试中…": "Testing…",
185
194
  "测试端点": "Test endpoint",
195
+ "连通性测试": "Connectivity test",
196
+ "分别向决策端点与回退语言模型各发一次最小请求;仅在你点击时发生,会消耗少量 token": "Sends one minimal request to the decision endpoint and to the fallback language model. Only when you click; uses a few tokens.",
197
+ "已跳过": "Skipped",
198
+ "详情": "Details",
186
199
  "未保存:密钥太短(至少 10 个字符)": "Not saved: the key is too short (at least 10 characters)",
187
200
  "测试端点失败": "Endpoint test failed",
188
201
  // —— 使用免费模型(DEC-030,原名「跟随服务端下发」,DEC-037 改名) ——
@@ -3117,6 +3130,12 @@ window.__ModuleLoader__.load({
3117
3130
  // 分开提交会让中间状态被当成最终配置。
3118
3131
  customHeaders: (provider.customHeaders ?? "").trim(),
3119
3132
  customApiKeyEnv: (provider.customApiKeyEnv ?? "").trim(),
3133
+ // 百炼的两格:workspace 空串也提交(用户可能清空,服务端会判 provider
3134
+ // 不可用并给出「需要 Workspace ID」),地域只在拿到字符串时提交。
3135
+ ...(typeof draft.bailianWorkspaceId === "string"
3136
+ ? { bailianWorkspaceId: draft.bailianWorkspaceId.trim() }
3137
+ : {}),
3138
+ ...(typeof draft.bailianRegion === "string" ? { bailianRegion: draft.bailianRegion } : {}),
3120
3139
  };
3121
3140
  // customBaseUrl 只在**合法**时提交。空串对内置 provider 是合法的
3122
3141
  //(等于不覆盖),对 custom 则是必填缺失——那种情况整笔延后,见 flush()。
@@ -3175,6 +3194,10 @@ window.__ModuleLoader__.load({
3175
3194
  ["customEndpointStyle", view.customEndpointStyle ?? "auto"],
3176
3195
  ["customHeaders", view.customHeaders ?? ""],
3177
3196
  ["customApiKeyEnv", view.customApiKeyEnv ?? ""],
3197
+ // 百炼的两格:与服务端回显比,否则「改了 workspace 但不触发保存」
3198
+ // ——输入框看起来能编辑,实际永远写不回 config。
3199
+ ["bailianWorkspaceId", view.bailianWorkspaceId ?? ""],
3200
+ ["bailianRegion", view.bailianRegion ?? "cn-beijing"],
3178
3201
  ["keyStorage", view.keyStorage ?? "credentials"],
3179
3202
  // 决策历史(DEC-022):与服务端的生效值比。服务端回传的是
3180
3203
  // `cfg.decisionsTabEnabled !== false` 这种**生效值**(不是原值),
@@ -6626,7 +6649,7 @@ window.__ModuleLoader__.load({
6626
6649
  }, []);
6627
6650
  // 语言变化时重绘本页。放在所有 useState 之后:测试是按「第 n 个
6628
6651
  // useState」注入状态的,插在中间会把后面的槽位全部挪位。
6629
- useLang();
6652
+ const uiLang = useLang();
6630
6653
  /**
6631
6654
  * 待落盘值的唯一真源。
6632
6655
  *
@@ -6680,6 +6703,10 @@ window.__ModuleLoader__.load({
6680
6703
  // 跟随服务端下发(DEC-030):默认关;与服务端保持一致,
6681
6704
  // 否则保存后开关会回弹。
6682
6705
  serverProvider: next.serverProvider?.enabled === true,
6706
+ // 百炼的 workspace / 地域:服务端状态里有就必须回填,否则切回
6707
+ // 百炼时输入框是空的(用户会以为自己没填过)。
6708
+ bailianWorkspaceId: next.bailianWorkspaceId ?? "",
6709
+ bailianRegion: next.bailianRegion ?? "cn-beijing",
6683
6710
  // 安全档位(DEC-035):同样从服务端派生,否则点滑块后回弹。
6684
6711
  tolerance: next.tolerance?.level ?? "default",
6685
6712
  // 强制安全核心 / 工具授权三态 / 自动审查回退:整组从服务端派生
@@ -6850,6 +6877,9 @@ window.__ModuleLoader__.load({
6850
6877
  providerError: "",
6851
6878
  providerCatalog: [],
6852
6879
  baseUrl: "",
6880
+ // 百炼的两格:status 缺失时给安全默认,界面不会读到 undefined。
6881
+ bailianWorkspaceId: "",
6882
+ bailianRegion: "cn-beijing",
6853
6883
  // 探测出的可用路径:status 失败时留空(界面不显示那一行)。
6854
6884
  detectedEndpoint: "",
6855
6885
  model: "",
@@ -6915,6 +6945,9 @@ window.__ModuleLoader__.load({
6915
6945
  approvalDetailReadable: view.approvalDetailReadable !== false,
6916
6946
  // 跟随服务端下发(DEC-030):默认关(与服务端 DEFAULTS 一致)。
6917
6947
  serverProvider: view.serverProvider?.enabled === true,
6948
+ // 百炼的两格:首屏缺了它们,输入框会先空一帧再跳值(看起来像没保存)。
6949
+ bailianWorkspaceId: view.bailianWorkspaceId ?? "",
6950
+ bailianRegion: view.bailianRegion ?? "cn-beijing",
6918
6951
  // 强制安全核心 / 工具授权三态 / 自动审查回退:缺任何一格,首屏就会
6919
6952
  // 在服务端说「开」的同时把控件画成关/空——用户顺手拨一下就把
6920
6953
  // 错误的值存下去了(与上面两个开关同一类事故)。
@@ -7026,6 +7059,9 @@ window.__ModuleLoader__.load({
7026
7059
  approvalDetailReadable: next.approvalDetailReadable !== false,
7027
7060
  // 跟随服务端下发(DEC-030):同样是「生效值」,必须从服务端派生。
7028
7061
  serverProvider: next.serverProvider?.enabled === true,
7062
+ // 百炼两格同样是生效值:保存成功后必须对齐,否则输入框会回弹。
7063
+ bailianWorkspaceId: next.bailianWorkspaceId ?? "",
7064
+ bailianRegion: next.bailianRegion ?? "cn-beijing",
7029
7065
  // 安全档位(DEC-035):同样必须从服务端派生,否则保存后回弹。
7030
7066
  tolerance: next.tolerance?.level ?? "default",
7031
7067
  // 强制安全核心 / 工具授权三态 / 自动审查回退:保存成功后必须整组
@@ -7245,10 +7281,11 @@ window.__ModuleLoader__.load({
7245
7281
  setProviderError("");
7246
7282
  try {
7247
7283
  const result = await call("/test", { method: "POST" });
7248
- // 服务端返回的整段诊断(provider / 端点 / HTTP 码 / 请求 id / 响应体)
7249
- // 原样展示:只报「失败」而不给细节,用户无法判断该改地址还是改 key。
7284
+ // DEC-074:服务端现在返回分链路的 `results`(决策端点 / 回退模型),
7285
+ // 界面优先渲染成两张卡片;`text` 只作为旧服务端的兜底与纯文本记录。
7250
7286
  setMsg({
7251
7287
  ok: result.ok === true,
7288
+ results: Array.isArray(result.results) ? result.results : null,
7252
7289
  text: result.ok === true
7253
7290
  ? t("端点连通 ✅\n{text}", { text: result.text ?? "" })
7254
7291
  : tr(result.text ?? "") || t("端点返回失败"),
@@ -7616,84 +7653,147 @@ window.__ModuleLoader__.load({
7616
7653
  //
7617
7654
  // 页脚(保存状态 / 测试端点 / 状态信息)**不进任何分组**:它是所有
7618
7655
  // 分组的共同结论(刚才存没存、端点通不通),收进某一组反而会被误读。
7656
+ const serverSelected = draft.serverProvider === true;
7657
+ /**
7658
+ * provider 下拉的选中值:免费模式选中的是虚拟项 `server`。
7659
+ *
7660
+ * 服务端没有 `provider: "server"` 这种配置(`serverProvider` 仍是独立
7661
+ * 的布尔键),所以这里只做**展示层合并**:选中 server ≠ 改写 provider,
7662
+ * 取消免费模式时还能回到上次选的 provider。
7663
+ */
7664
+ const providerSelectValue = serverSelected ? "server" : providerDraft.provider;
7619
7665
  const connectionItems = [
7620
7666
  // ① 连接方式:地址从哪来、发给谁、用谁的 key、怎么存。
7621
7667
  // 第一行的「使用免费模型」是这一组的总开关:它决定下面这些项
7622
7668
  // 是服务端下发还是用户自己填,所以必须排在最前。
7623
- // 1) 使用免费模型(DEC-030,原名「跟随服务端下发」;DEC-037 改名并置顶):
7624
- // 地址 / 模型 / key 都改由服务端下发,不必自己填。
7669
+ // 1) 服务提供方(含「免费模型」选项)。
7625
7670
  //
7626
- // 放在整页的最上面:它是**地址来源**的决定——「用免费的,还是自己配」。
7627
- // 下面每一组的每一项都取决于它(发给谁、用谁的 key、守卫跑在哪家模型上),
7628
- // 所以它必须先被读到。
7671
+ // 免费模型以前是上面的一条独立开关:它和 provider 下拉是两个控件,
7672
+ // 却表达同一件事——「地址/模型/key 从哪来」。两个入口意味着两种
7673
+ // 不一致的状态(开关开着而 provider 指向别处、或反之),用户看不出
7674
+ // 最终到底用哪一套。现在合并成一个下拉:`server` 是第一项,选中它
7675
+ // 等于打开下发;选任何真实 provider 等于关闭下发。
7629
7676
  //
7630
- // 说明下面那行小字「可能会失效」是这件事的代价,不是附注:免费通道随时
7631
- // 可能停用,用户得知道本地手动配置仍是可靠后路。改名也是为了让用户先看到
7632
- // 「免费」这个能感知的事实,而不是「服务端下发」这个实现细节。
7633
- //
7634
- // 默认关:不打开时本插件的行为与之前逐字一致(全部走本地手动配置)。
7635
- row(
7636
- "使用免费模型",
7637
- serverHint,
7638
- h(
7639
- "div",
7640
- { style: { display: "flex", gap: "8px", alignItems: "center", flex: "none" } },
7641
- // 「重新获取」只在开着时出现:关着的时候它什么也不会变,摆在那里
7642
- // 只会让人以为这个功能坏了。
7643
- view.serverProvider?.enabled === true
7644
- ? h(
7645
- "button",
7646
- {
7647
- type: "button",
7648
- key: "server-refetch",
7649
- onClick: reloadServerProvider,
7650
- disabled: busy !== "",
7651
- style: { ...S.saveBtn, flexShrink: 0, padding: "6px 10px" },
7652
- },
7653
- busy === "serverProvider" ? "…" : t("重新获取"),
7654
- )
7655
- : null,
7656
- toggle(draft.serverProvider === true, () => {
7657
- const next = draft.serverProvider !== true;
7658
- const fresh = { ...draft, serverProvider: next };
7659
- setDraft(fresh);
7660
- // 开关是离散控件:拨一下就落盘。服务端据它决定是否去取下发值。
7661
- scheduleSave({ draft: fresh }, true);
7662
- }),
7663
- ),
7664
- "serverProvider",
7665
- "可能会失效",
7666
- ),
7667
- // 2a) 服务提供方:最高层的选择,决定地址与鉴权从哪来
7677
+ // 配置键仍是 `serverProvider`(已落盘契约),行为不变,只是入口从
7678
+ // 开关变成选项。
7668
7679
  row(
7669
7680
  "服务提供方",
7670
- providerHint,
7681
+ serverSelected
7682
+ ? h(
7683
+ "div",
7684
+ { style: { display: "flex", flexDirection: "column", gap: "4px" } },
7685
+ h("span", null, serverHint),
7686
+ view.serverProvider?.enabled === true
7687
+ ? h(
7688
+ "button",
7689
+ {
7690
+ type: "button",
7691
+ key: "server-refetch",
7692
+ onClick: reloadServerProvider,
7693
+ disabled: busy !== "",
7694
+ style: { ...S.saveBtn, alignSelf: "flex-start", padding: "4px 8px", marginTop: "2px" },
7695
+ },
7696
+ busy === "serverProvider" ? "…" : t("重新获取"),
7697
+ )
7698
+ : null,
7699
+ )
7700
+ : providerHint,
7671
7701
  h(
7672
7702
  "select",
7673
7703
  {
7674
- style: { ...S.input, minWidth: "180px", flex: "none" },
7675
- value: providerDraft.provider,
7704
+ style: { ...S.input, minWidth: "200px", flex: "none" },
7705
+ value: providerSelectValue,
7676
7706
  disabled: busy !== "",
7677
7707
  onChange: (event) => {
7678
7708
  const next = event.target.value;
7679
- setProviderDraft({ ...providerDraft, provider: next });
7709
+ if (next === "server") {
7710
+ // 保留 provider 原值:用户取消免费模式时回到上次选的 provider。
7711
+ const fresh = { ...draft, serverProvider: true };
7712
+ setDraft(fresh);
7713
+ scheduleSave({ draft: fresh }, true);
7714
+ return;
7715
+ }
7716
+ const fresh = { ...draft, serverProvider: false };
7717
+ const nextProviderDraft = { ...providerDraft, provider: next };
7718
+ setDraft(fresh);
7719
+ setProviderDraft(nextProviderDraft);
7680
7720
  // 切换 provider 只改「当前用哪个」,不动其它 provider 已保存的值:
7681
7721
  // custom 的字段留在 providerDraft 里,切回来还在。
7682
7722
  setProviderError("");
7683
7723
  // 下拉是离散控件:选完即存。切到 custom 但地址还没填时,
7684
7724
  // flush 会整笔延后并提示(见 flush 里的 urlProblem),
7685
7725
  // 地址补全后自动落盘,不需要再点一次。
7686
- scheduleSave({ providerDraft: { ...providerDraft, provider: next } }, true);
7726
+ scheduleSave({ draft: fresh, providerDraft: nextProviderDraft }, true);
7687
7727
  },
7688
7728
  },
7729
+ h("option", { value: "server", key: "server" }, t("免费模型(服务端下发)")),
7689
7730
  ...(catalog.length === 0
7690
7731
  ? [h("option", { value: providerDraft.provider, key: providerDraft.provider }, providerDraft.provider)]
7691
7732
  : catalog.map((item) =>
7692
- h("option", { value: item.id, key: item.id }, item.label),
7733
+ h(
7734
+ "option",
7735
+ { value: item.id, key: item.id },
7736
+ // 预设显示名由服务端下发:英文界面取 labelEn,中文取 label。
7737
+ // 只取 label 会让「阿里云百炼」「硅基流动」这类中文名在
7738
+ // 英文界面里原样出现。
7739
+ uiLang === "en" && typeof item.labelEn === "string" && item.labelEn.length > 0
7740
+ ? item.labelEn
7741
+ : item.label,
7742
+ ),
7693
7743
  )),
7694
7744
  ),
7695
7745
  "provider",
7746
+ serverSelected ? t("可能会失效") : null,
7696
7747
  ),
7748
+ // 1b) 百炼:workspace 参与域名拼接,不能塞进「自定义 provider」区
7749
+ // (那不是用户自建服务,只是地址需要两个参数)。两格都属于普通
7750
+ // 配置字段,跟 custom 的五个字段分开存。
7751
+ activeProvider?.dynamicBaseUrl === true && !serverSelected
7752
+ ? row(
7753
+ "Workspace ID",
7754
+ t(
7755
+ "百炼决策端点的域名是 {template},Workspace ID 是域名的一部分,必填。填写业务空间 ID(形如 ws_xxx)",
7756
+ { template: activeProvider.baseUrlTemplate ?? "" },
7757
+ ),
7758
+ h("input", {
7759
+ type: "text",
7760
+ style: S.input,
7761
+ placeholder: "ws_…",
7762
+ value: typeof draft.bailianWorkspaceId === "string" ? draft.bailianWorkspaceId : "",
7763
+ onChange: (event) => {
7764
+ const next = { ...draft, bailianWorkspaceId: event.target.value };
7765
+ setDraft(next);
7766
+ scheduleSave({ draft: next });
7767
+ },
7768
+ onBlur: () => scheduleSave(null, true),
7769
+ }),
7770
+ "bailianWorkspaceId",
7771
+ )
7772
+ : null,
7773
+ activeProvider?.dynamicBaseUrl === true && !serverSelected
7774
+ ? row(
7775
+ "地域",
7776
+ t("百炼只提供 cn-beijing 与 ap-southeast-1 两个决策地域;API Key 必须与地域匹配"),
7777
+ h(
7778
+ "select",
7779
+ {
7780
+ style: { ...S.input, minWidth: "190px", flex: "none" },
7781
+ value: typeof draft.bailianRegion === "string" ? draft.bailianRegion : "cn-beijing",
7782
+ disabled: busy !== "",
7783
+ onChange: (event) => {
7784
+ const next = { ...draft, bailianRegion: event.target.value };
7785
+ setDraft(next);
7786
+ scheduleSave({ draft: next }, true);
7787
+ },
7788
+ },
7789
+ ...(Array.isArray(activeProvider.regions) && activeProvider.regions.length > 0
7790
+ ? activeProvider.regions
7791
+ : ["cn-beijing", "ap-southeast-1"]
7792
+ ).map((region) => h("option", { key: region, value: region }, region)),
7793
+ ),
7794
+ "bailianRegion",
7795
+ )
7796
+ : null,
7697
7797
  // 2b) 模型行:custom 时移入下方「自定义 provider」区(见 modelRow 的说明)。
7698
7798
  // 每个 provider 用自己那一格(modelField 由服务端下发),分开存才能做到
7699
7799
  // 「切 provider 不覆盖其它 provider 已保存的模型」。
@@ -7774,6 +7874,109 @@ window.__ModuleLoader__.load({
7774
7874
  ),
7775
7875
  "endpoint",
7776
7876
  ),
7877
+ // 4b2) 连通性测试:从页脚前移到它要验证的对象旁边(provider / 模型 /
7878
+ // 端点 / key 都在上面几行)。一次点击同时测两条链路:决策端点
7879
+ // (自拼 System One)与回退语言模型(宿主 llm.stream)。结果就
7880
+ // 显示在下面,不再需要滚到页脚找「测试」的结论。
7881
+ row(
7882
+ "连通性测试",
7883
+ t("分别向决策端点与回退语言模型各发一次最小请求;仅在你点击时发生,会消耗少量 token"),
7884
+ h(
7885
+ "button",
7886
+ {
7887
+ type: "button",
7888
+ onClick: runTest,
7889
+ disabled: busy !== "",
7890
+ style: { ...S.saveBtn, flexShrink: 0 },
7891
+ },
7892
+ busy === "test" ? "测试中…" : "测试端点",
7893
+ ),
7894
+ "connectivity",
7895
+ ),
7896
+ msg !== null
7897
+ ? h(
7898
+ "div",
7899
+ {
7900
+ style: { display: "flex", flexDirection: "column", gap: "8px", marginTop: "10px" },
7901
+ key: "test-msg",
7902
+ },
7903
+ // 结构化结果(DEC-074):每条链路一张小卡片——先给结论(通/不通/
7904
+ // 跳过)+ 一行摘要,原始诊断折叠进「详情」。旧服务端只回 `text`
7905
+ // 时退回整段文本,不至于空白。
7906
+ ...(Array.isArray(msg.results)
7907
+ ? msg.results.map((item) => {
7908
+ const tone = item.skipped === true ? "#fbbf24" : item.ok === true ? "#4ade80" : "#f87171";
7909
+ const icon = item.skipped === true ? "–" : item.ok === true ? "✓" : "✕";
7910
+ return h(
7911
+ "div",
7912
+ {
7913
+ key: `test-${item.key ?? item.label}`,
7914
+ style: {
7915
+ border: `1px solid ${tone}55`,
7916
+ borderRadius: "8px",
7917
+ padding: "8px 10px",
7918
+ background: `${tone}0d`,
7919
+ },
7920
+ },
7921
+ h(
7922
+ "div",
7923
+ { style: { display: "flex", alignItems: "center", gap: "8px" } },
7924
+ h("span", { style: { color: tone, fontWeight: 600 } }, icon),
7925
+ h("span", { style: { fontWeight: 600 } }, item.label),
7926
+ item.skipped === true
7927
+ ? h("span", { style: { ...S.meta, marginTop: 0, color: tone } }, t("已跳过"))
7928
+ : null,
7929
+ ),
7930
+ h(
7931
+ "div",
7932
+ { style: { ...S.meta, marginTop: "4px", whiteSpace: "pre-wrap", wordBreak: "break-all" } },
7933
+ item.summary ?? "",
7934
+ ),
7935
+ typeof item.detail === "string" && item.detail.length > 0 && item.detail !== item.summary
7936
+ ? h(
7937
+ "details",
7938
+ { style: { marginTop: "4px" } },
7939
+ h(
7940
+ "summary",
7941
+ { style: { cursor: "pointer", ...S.meta, marginTop: 0, opacity: 0.7 } },
7942
+ t("详情"),
7943
+ ),
7944
+ h(
7945
+ "pre",
7946
+ {
7947
+ style: {
7948
+ margin: "6px 0 0",
7949
+ whiteSpace: "pre-wrap",
7950
+ wordBreak: "break-all",
7951
+ fontSize: "12px",
7952
+ opacity: 0.8,
7953
+ fontFamily: "inherit",
7954
+ },
7955
+ },
7956
+ item.detail,
7957
+ ),
7958
+ )
7959
+ : null,
7960
+ );
7961
+ })
7962
+ : [
7963
+ h(
7964
+ "div",
7965
+ {
7966
+ style: {
7967
+ ...S.meta,
7968
+ marginTop: 0,
7969
+ color: msg.ok ? "#4ade80" : "#f87171",
7970
+ whiteSpace: "pre-wrap",
7971
+ fontFamily: "inherit",
7972
+ fontSize: "13px",
7973
+ },
7974
+ },
7975
+ msg.text,
7976
+ ),
7977
+ ]),
7978
+ )
7979
+ : null,
7777
7980
  // 4c) 密钥保存位置:默认托管到 .credentials.yaml(DEC-037 从 API Key 紧邻处
7778
7981
  // 下移到这里:它讲的是「这把 key 存到哪个文件」,不是「怎么连上服务」,
7779
7982
  // 夹在连接组中间会打断 provider → 模型 → key → 端点这条阅读线;
@@ -8572,12 +8775,14 @@ window.__ModuleLoader__.load({
8572
8775
  // 放在「思考模式」之后:它同样是「一次性选择、改完即存」的下拉,
8573
8776
  // 与上面的开关/下拉一组,不与 provider 表格混在一起。
8574
8777
  row(
8575
- "匿名统计",
8576
- draft.telemetryLevel === "daily"
8577
- ? "每天上报一次:匿名安装 id、版本、平台与当日决策数"
8778
+ "匿名使用统计",
8779
+ // 与下拉的兜底保持一致:undefined 时下拉显示 daily,说明也必须说 daily,
8780
+ // 否则首帧会出现「选中的是 daily、解释却写仅首次」的自相矛盾。
8781
+ (draft.telemetryLevel ?? "daily") === "daily"
8782
+ ? t("每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、当天决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。")
8578
8783
  : draft.telemetryLevel === "off"
8579
- ? "已关闭:任何情况下都不联网"
8580
- : "仅首次运行上报一次「安装了」,之后不再联网",
8784
+ ? t("完全关闭:不发送任何请求。")
8785
+ : t("仅在第一次运行时上报一次「安装了」(字段同上),之后不再联网。"),
8581
8786
  h(
8582
8787
  "select",
8583
8788
  {
@@ -8598,10 +8803,10 @@ window.__ModuleLoader__.load({
8598
8803
  "option",
8599
8804
  { key: value, value },
8600
8805
  value === "off"
8601
- ? "off(不联网)"
8806
+ ? t("off(完全关闭)")
8602
8807
  : value === "install"
8603
- ? "install(仅首次)"
8604
- : "daily(每天一次,默认)",
8808
+ ? t("install(仅首次)")
8809
+ : t("daily(每天一次,默认)"),
8605
8810
  ),
8606
8811
  ),
8607
8812
  ),
@@ -8610,8 +8815,10 @@ window.__ModuleLoader__.load({
8610
8815
  ];
8611
8816
 
8612
8817
  const footerItems = [
8613
- // 页脚常驻在所有分组之下:保存状态、测试端点、状态信息与诊断结果。
8818
+ // 页脚常驻在所有分组之下:保存状态与状态信息。
8614
8819
  // 它回答的是「刚才那一下存没存」,与页面哪一组展开无关。
8820
+ // 连通性测试已前移到「连接方式」组(DEC-074)——它要验证的对象都在
8821
+ // 那里,结论也该贴着对象显示。
8615
8822
  // 4g) 自动保存状态行:**没有「保存」按钮**,改动即落盘。
8616
8823
  // 这一行回答的是「刚才那一下到底存没存」:进行中 / 已存 /
8617
8824
  // 失败原因(附重试)。原来那个按钮的「保存中…」搬到这里。
@@ -8664,16 +8871,6 @@ window.__ModuleLoader__.load({
8664
8871
  "重试",
8665
8872
  )
8666
8873
  : null,
8667
- h(
8668
- "button",
8669
- {
8670
- type: "button",
8671
- disabled: busy !== "",
8672
- onClick: runTest,
8673
- style: { ...S.saveBtn, background: "transparent", color: "inherit" },
8674
- },
8675
- busy === "test" ? "测试中…" : "测试端点",
8676
- ),
8677
8874
  ),
8678
8875
  // 4h) 状态信息(紧凑)
8679
8876
  h(
@@ -8704,20 +8901,6 @@ window.__ModuleLoader__.load({
8704
8901
  }) + (activeKeyStatus.configured === true ? "" : t("(未读到)"))
8705
8902
  : null,
8706
8903
  ),
8707
- msg !== null
8708
- ? h(
8709
- "div",
8710
- {
8711
- style: {
8712
- ...S.meta,
8713
- marginTop: "10px",
8714
- color: msg.ok ? "#4ade80" : "#f87171",
8715
- },
8716
- key: "msg",
8717
- },
8718
- msg.text,
8719
- )
8720
- : null,
8721
8904
  ];
8722
8905
 
8723
8906
  // 配置 tab 的全部内容。
package/lib/index.js CHANGED
@@ -82,6 +82,8 @@ import {
82
82
  // key 只活在 ServerConfigSource 实例里,本文件只负责把它接进各解析点。
83
83
  import { SERVER_CONFIG_ENDPOINT, ServerConfigSource } from "./server-config.js";
84
84
  import {
85
+ BAILIAN_REGIONS,
86
+ DEFAULT_BAILIAN_REGION,
85
87
  DEFAULT_PROVIDER,
86
88
  DEFAULT_THINK_MODE,
87
89
  ENDPOINT_STYLES,
@@ -90,6 +92,7 @@ import {
90
92
  SERVER_PROVIDER_ID,
91
93
  SERVER_PROVIDER_LABEL,
92
94
  THINK_MODES,
95
+ bailianWorkspaceOf,
93
96
  buildEndpoint,
94
97
  buildHeaders,
95
98
  buildRequestBody,
@@ -153,6 +156,18 @@ const DEFAULTS = {
153
156
  */
154
157
  perplexityModel: "",
155
158
  openrouterModel: "",
159
+ /**
160
+ * 阿里云百炼决策模型:workspace + 地域拼出专用 endpoint。
161
+ *
162
+ * workspace 是域名的一部分(`https://{WorkspaceId}.{region}.maas.aliyuncs.com`),
163
+ * 因此它是**必填**,不像其它预设那样有公共默认地址;留空时 provider 直接
164
+ * 判为不可用并给出「需要 Workspace ID」。
165
+ */
166
+ bailianWorkspaceId: "",
167
+ bailianRegion: DEFAULT_BAILIAN_REGION,
168
+ bailianModel: "",
169
+ /** TokenHub(tokenhub.com)的决策模型;留空用预设默认 jev-latest。 */
170
+ tokenhubModel: "",
156
171
  /** 自定义 provider:地址、端点风格、鉴权、请求头、模型。 */
157
172
  customBaseUrl: "",
158
173
  customEndpointStyle: "auto",
@@ -165,6 +180,8 @@ const DEFAULTS = {
165
180
  siliconflowIntlApiKey: "",
166
181
  perplexityApiKey: "",
167
182
  openrouterApiKey: "",
183
+ bailianApiKey: "",
184
+ tokenhubApiKey: "",
168
185
  customModel: "",
169
186
  customHeaders: "",
170
187
  /** 各预设的密钥来源变量名,留空即用预设内置值。 */
@@ -174,6 +191,8 @@ const DEFAULTS = {
174
191
  siliconflowIntlApiKeyEnv: "",
175
192
  perplexityApiKeyEnv: "",
176
193
  openrouterApiKeyEnv: "",
194
+ bailianApiKeyEnv: "",
195
+ tokenhubApiKeyEnv: "",
177
196
  /**
178
197
  * 密钥保存位置:
179
198
  * credentials = ~/.dsh/.credentials.yaml 的 refs 段(默认,与其他服务统一托管)
@@ -448,6 +467,8 @@ export const Config = z.object({
448
467
  + "bamboonode = StartLux 官方端点(POST /v1/decisions,原有默认);"
449
468
  + "siliconflow = 硅基流动(api.siliconflow.cn,POST /v1/systemone);"
450
469
  + "siliconflow-intl = Silicon Flow 国际站(api.siliconflow.com);"
470
+ + "bailian = 阿里云百炼决策模型(workspace 专用域名,POST /compatible-mode/v1/systemone);"
471
+ + "tokenhub = TokenHub(tokenhub.com)的 System One 决策端点;腾讯云 TokenHub 没有决策端点;"
451
472
  + "perplexity = Perplexity Decider(api.perplexity.ai,待实测);"
452
473
  + "openrouter = OpenRouter(openrouter.ai/api/v1,待实测);"
453
474
  + "custom = 自己填地址(任意提供决策端点的服务,路径可自动回退)",
@@ -466,6 +487,16 @@ export const Config = z.object({
466
487
  .description("Perplexity Decider 预设的模型 id;留空用预设默认 sonar(端点 model 必填)"),
467
488
  openrouterModel: z.string().default(DEFAULTS.openrouterModel)
468
489
  .description("OpenRouter 预设的模型 slug;留空用预设默认 openai/gpt-4o-mini(请求必带 model)"),
490
+ bailianWorkspaceId: z.string().default(DEFAULTS.bailianWorkspaceId)
491
+ .description(
492
+ "阿里云百炼的 Workspace ID(如 ws_xxx):决策端点的域名就是 https://{workspaceId}.{region}.maas.aliyuncs.com,必填",
493
+ ),
494
+ bailianRegion: z.union([...BAILIAN_REGIONS]).default(DEFAULTS.bailianRegion)
495
+ .description("阿里云百炼地域:cn-beijing(默认)或 ap-southeast-1"),
496
+ bailianModel: z.string().default(DEFAULTS.bailianModel)
497
+ .description("阿里云百炼的模型 id;留空用预设默认 decision-model-preview(该端点 model 必填)"),
498
+ tokenhubModel: z.string().default(DEFAULTS.tokenhubModel)
499
+ .description("TokenHub 的模型 id;留空用预设默认 jev-latest(该端点 model 必填)"),
469
500
  customBaseUrl: z.string().default(DEFAULTS.customBaseUrl)
470
501
  .description("provider=custom 时的 base URL,必须是 http/https 地址"),
471
502
  customEndpointStyle: z.union([...ENDPOINT_STYLES]).default(DEFAULTS.customEndpointStyle)
@@ -484,6 +515,10 @@ export const Config = z.object({
484
515
  .description("Perplexity Decider 的 API key;留空则读 PERPLEXITY_API_KEY"),
485
516
  openrouterApiKey: z.string().role("secret").default(DEFAULTS.openrouterApiKey)
486
517
  .description("OpenRouter 的 API key;留空则读 OPENROUTER_API_KEY"),
518
+ bailianApiKey: z.string().role("secret").default(DEFAULTS.bailianApiKey)
519
+ .description("阿里云百炼的 API key;留空则读 DASHSCOPE_API_KEY"),
520
+ tokenhubApiKey: z.string().role("secret").default(DEFAULTS.tokenhubApiKey)
521
+ .description("TokenHub 的 API key;留空则读 TOKENHUB_API_KEY"),
487
522
  customApiKeyEnv: z.string().default(DEFAULTS.customApiKeyEnv)
488
523
  .description("custom 的密钥来源变量名(环境变量或 .credentials.yaml 的 refs 键)"),
489
524
  customModel: z.string().default(DEFAULTS.customModel)
@@ -502,6 +537,10 @@ export const Config = z.object({
502
537
  .description("perplexity 预设的密钥变量名覆盖;留空用 PERPLEXITY_API_KEY"),
503
538
  openrouterApiKeyEnv: z.string().default(DEFAULTS.openrouterApiKeyEnv)
504
539
  .description("openrouter 预设的密钥变量名覆盖;留空用 OPENROUTER_API_KEY"),
540
+ bailianApiKeyEnv: z.string().default(DEFAULTS.bailianApiKeyEnv)
541
+ .description("bailian 预设的密钥变量名覆盖;留空用 DASHSCOPE_API_KEY"),
542
+ tokenhubApiKeyEnv: z.string().default(DEFAULTS.tokenhubApiKeyEnv)
543
+ .description("tokenhub 预设的密钥变量名覆盖;留空用 TOKENHUB_API_KEY"),
505
544
  apiKey: z.string().role("secret")
506
545
  .description("当前 provider 的 API key;留空则读 apiKeyEnv 指定的环境变量"),
507
546
  apiKeyEnv: z.string().default(DEFAULTS.apiKeyEnv)
@@ -615,9 +654,10 @@ export const Config = z.object({
615
654
  .description("缓存存活时间;改策略或配置后旧裁决不应继续被复用"),
616
655
  telemetryLevel: z.union([...TELEMETRY_LEVELS]).default(DEFAULTS.telemetryLevel)
617
656
  .description(
618
- "匿名使用统计:off 完全不联网 / install 仅首次运行报一次(默认)/ "
619
- + "daily 每天报一次(含当日决策数)。只上报插件版本、平台、架构与计数,"
620
- + "永不上报 prompt、密钥、路径或会话内容",
657
+ "匿名使用统计:off 完全不联网 / install 仅首次运行报一次 / daily 每天一次(默认,含当天决策数)。"
658
+ + "用途只有「判断还有多少人在用、哪些版本与平台在用、值不值得继续维护」;"
659
+ + "上报随机安装 ID、插件版本、平台/架构、宿主版本与决策计数,"
660
+ + "永不上报 prompt、密钥、路径、模型名或会话内容",
621
661
  ),
622
662
  telemetryEndpoint: z.string().default(DEFAULTS.telemetryEndpoint)
623
663
  .description("统计上报地址;留空用内置端点(https://dsh.zuos.us/api/e)"),
@@ -783,6 +823,25 @@ export function validateProviderPatch(patch, current) {
783
823
  if (!checked.ok) return { ok: false, error: `base URL 不合法:${checked.error}` };
784
824
  }
785
825
 
826
+ // 百炼:workspace 会拼进 hostname,非法字符必须在这里拦下(否则会拼出
827
+ // 另一个域名,而不是得到一条用户能懂的报错)。允许留空——用户往往先选
828
+ // provider、再填 workspace;此时 provider 会显示「需要 Workspace ID」。
829
+ if (Object.hasOwn(patch, "bailianRegion") && !BAILIAN_REGIONS.includes(patch.bailianRegion)) {
830
+ return {
831
+ ok: false,
832
+ error: `未知百炼地域: ${String(patch.bailianRegion)}(可选 ${BAILIAN_REGIONS.join(" / ")})`,
833
+ };
834
+ }
835
+ if (Object.hasOwn(patch, "bailianWorkspaceId")) {
836
+ if (typeof patch.bailianWorkspaceId !== "string" || /[\r\n\0]/.test(patch.bailianWorkspaceId)) {
837
+ return { ok: false, error: "百炼 Workspace ID 必须是单行字符串" };
838
+ }
839
+ const workspace = patch.bailianWorkspaceId.trim();
840
+ if (workspace.length > 0 && !/^[A-Za-z0-9_-]+$/.test(workspace)) {
841
+ return { ok: false, error: "百炼 Workspace ID 只能包含字母、数字、下划线和短横线(形如 ws_xxx)" };
842
+ }
843
+ }
844
+
786
845
  // 请求头格式:只要写了内容就必须至少解析出一项,否则用户会以为头已生效。
787
846
  if (Object.hasOwn(patch, "customHeaders") && typeof patch.customHeaders === "string") {
788
847
  const raw = patch.customHeaders.trim();
@@ -3092,6 +3151,108 @@ export function apply(ctx, config) {
3092
3151
  return { ok: false, text: `provider: ${provider.label}(${provider.id})\nPOST ${usedEndpoint}\n请求失败(${Date.now() - started}ms)` };
3093
3152
  };
3094
3153
 
3154
+ /**
3155
+ * 探测回退语言模型(guardFallback = model 时)。
3156
+ *
3157
+ * 为什么要单独测:决策端点与回退模型是两条完全不同的链路——前者是自己拼的
3158
+ * System One 请求,后者走宿主的 `llm.stream`。决策端点通、回退模型 401 的
3159
+ * 情况完全可能发生;只测前者会让用户以为整套守卫都在工作,而真正需要兜底
3160
+ * 时才发现回退模型不可用。
3161
+ *
3162
+ * 刻意只测「显式指定 provider/model」这一种策略:
3163
+ * - session 用的是当前会话模型,设置页没有会话上下文,测不了(也不该猜);
3164
+ * - allow / deny 根本不调模型,没有可测的链路。
3165
+ *
3166
+ * 这是一次最小的真实模型调用(maxTokens 16),只在用户点「测试」时发生;
3167
+ * 失败/超时都折叠成一段可读诊断,绝不让测试按钮本身抛错。
3168
+ *
3169
+ * @returns `{ ok: boolean|null, skipped?: boolean, text }`;null = 本策略不适用。
3170
+ */
3171
+ const probeFallbackModel = async () => {
3172
+ const policy = cfg.guardFallback;
3173
+ if (policy !== "model") {
3174
+ return {
3175
+ ok: null,
3176
+ skipped: true,
3177
+ text: `回退语言模型: 当前策略为 ${policy},不调用独立模型(${policy === "session" ? "使用会话当前模型,设置页无法离线测试" : "没有可测的模型链路"})`,
3178
+ };
3179
+ }
3180
+ const provider = typeof cfg.guardFallbackProvider === "string" ? cfg.guardFallbackProvider.trim() : "";
3181
+ const model = typeof cfg.guardFallbackModel === "string" ? cfg.guardFallbackModel.trim() : "";
3182
+ if (provider.length === 0 || model.length === 0) {
3183
+ return { ok: false, text: "回退语言模型: 策略为 model,但 provider / model 未填全" };
3184
+ }
3185
+ const llm = ctx.get?.("llm");
3186
+ if (typeof llm?.stream !== "function") {
3187
+ return { ok: false, text: "回退语言模型: 宿主未提供 llm.stream,无法测试(请确认 DSH 版本支持该服务)" };
3188
+ }
3189
+ const configuredTimeout = Number.isFinite(cfg.timeoutMs) ? cfg.timeoutMs : 20000;
3190
+ const timeoutMs = Math.max(8000, Math.min(20000, configuredTimeout));
3191
+ const effort = typeof cfg.guardFallbackReasoningEffort === "string" ? cfg.guardFallbackReasoningEffort.trim() : "";
3192
+ const started = Date.now();
3193
+ try {
3194
+ const stream = llm.stream({
3195
+ provider,
3196
+ model,
3197
+ ...(effort.length > 0 ? { reasoningEffort: effort } : {}),
3198
+ // 只要求一个词:这是连通性测试,不是质量评测;输出越短越省钱。
3199
+ messages: [{ role: "user", content: [{ type: "text", text: "Reply with the single word: pong" }] }],
3200
+ tools: [],
3201
+ maxTokens: 16,
3202
+ signal: AbortSignal.timeout(timeoutMs),
3203
+ purpose: "decision-engine-connectivity",
3204
+ });
3205
+ let finish = null;
3206
+ let text = "";
3207
+ for await (const chunk of stream) {
3208
+ if (chunk?.type === "text-delta") text += chunk.text;
3209
+ if (chunk?.type === "finish") finish = chunk.reason?.kind ?? "unknown";
3210
+ }
3211
+ // length 也算连通:模型确实回应了,只是 16 token 被截断(例如它先输出了
3212
+ // 一段思考)。只有「没有 finish 或 finish 不是正常结束」才算失败。
3213
+ const ok = finish === "stop" || finish === "length";
3214
+ return {
3215
+ ok,
3216
+ text:
3217
+ `回退语言模型: ${provider} / ${model}${effort.length > 0 ? `(effort ${effort})` : ""}\n` +
3218
+ `耗时 ${Date.now() - started}ms finish: ${finish ?? "(无)"}\n` +
3219
+ `响应: ${text.trim().slice(0, 120) || "(空)"}`,
3220
+ };
3221
+ } catch (error) {
3222
+ const message = error instanceof Error ? error.message : String(error);
3223
+ return {
3224
+ ok: false,
3225
+ text: `回退语言模型: ${provider} / ${model}\n请求失败(${Date.now() - started}ms): ${message}`,
3226
+ };
3227
+ }
3228
+ };
3229
+
3230
+ /**
3231
+ * 把 probe() 的多行诊断压成一句「人先看这个」的结论。
3232
+ *
3233
+ * 原始输出是给排查用的(request id、尝试过的路径、响应体),但测试按钮的
3234
+ * 第一眼需要一个能判断「通没通、多快」的短句;详情仍然保留,折叠起来。
3235
+ *
3236
+ * @param text - probe() 返回的完整文本。
3237
+ * @returns 一行摘要(不抛错,解析不出来就取前两行)。
3238
+ */
3239
+ const summarizeProbe = (text) => {
3240
+ const lines = String(text ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
3241
+ const provider = lines.find((line) => line.startsWith("provider: "));
3242
+ const post = lines.find((line) => line.startsWith("POST "));
3243
+ const http = lines.find((line) => line.startsWith("HTTP "));
3244
+ const failed = lines.find((line) => line.startsWith("请求失败"));
3245
+ const reason = lines.find((line) => line.startsWith("失败原因: "));
3246
+ const parts = [];
3247
+ if (provider) parts.push(provider.slice("provider: ".length));
3248
+ if (post) parts.push(post);
3249
+ if (http) parts.push(http.replace(/\s+/g, " "));
3250
+ else if (failed) parts.push(failed);
3251
+ if (reason) parts.push(reason.slice("失败原因: ".length));
3252
+ if (parts.length === 0) return lines.slice(0, 2).join(" · ") || "(无结果)";
3253
+ return parts.join(" · ");
3254
+ };
3255
+
3095
3256
  // ---- 面板 API:给前端 UI 用(不经过 settings 服务)-------------------------
3096
3257
  ctx.inject?.(["connection"], () => {
3097
3258
  const connection = ctx.get?.("connection");
@@ -3301,6 +3462,10 @@ export function apply(ctx, config) {
3301
3462
  // custom 的字段原样回显(除 key 明文):用户保存后再打开必须看到自己填的值。
3302
3463
  customBaseUrl: cfg.customBaseUrl ?? "",
3303
3464
  customEndpointStyle: cfg.customEndpointStyle ?? "auto",
3465
+ // 百炼的两个「非密钥/模型」配置:workspace 参与域名拼接,必须回显,
3466
+ // 否则用户切回百炼时看不到自己填过的值。
3467
+ bailianWorkspaceId: cfg.bailianWorkspaceId ?? "",
3468
+ bailianRegion: cfg.bailianRegion ?? DEFAULT_BAILIAN_REGION,
3304
3469
  // 实际用得通的那条路径(探测出来的)。路径后缀由服务商决定,
3305
3470
  // 实测 SiliconFlow 只通 /systemone;界面必须显示「实际打哪」,
3306
3471
  // 否则用户看到 404 完全猜不到是路径选错了。
@@ -3504,6 +3669,10 @@ export function apply(ctx, config) {
3504
3669
  "customBaseUrl",
3505
3670
  "customEndpointStyle",
3506
3671
  "customHeaders",
3672
+ // 百炼:workspace 与地域不是预设表里的 model/key/keyEnv 三格,
3673
+ // 必须显式列进白名单,否则「下拉能选、输入框能填,刷新后没了」。
3674
+ "bailianWorkspaceId",
3675
+ "bailianRegion",
3507
3676
  "keyStorage",
3508
3677
  ...Object.values(PROVIDER_PRESETS).flatMap((preset) => [
3509
3678
  preset.modelField,
@@ -3781,8 +3950,42 @@ export function apply(ctx, config) {
3781
3950
  methods: ["POST"],
3782
3951
  requestBody: "buffered",
3783
3952
  fetch: async () => {
3784
- const result = await probe();
3785
- return json({ ok: result.ok, text: result.text });
3953
+ // 两条链路各测一次:决策端点是自拼的 System One 请求,回退模型走宿主
3954
+ // 的 llm.stream。只测前者会让「决策端点通、回退模型不可用」被误报成
3955
+ // 整体健康——而回退模型恰好是审批通道不可用时的兜底裁判。
3956
+ const decision = await probe();
3957
+ await reloadOwnConfig();
3958
+ const fallback = await probeFallbackModel();
3959
+ // fallback.ok 为 null 表示该策略不适用(session / allow / deny),
3960
+ // 不能把「没有这条链路」算成失败;但明确失败(false)必须让整体变红。
3961
+ const ok = decision.ok === true && fallback.ok !== false;
3962
+ // 给界面一份结构化结果:每条链路一个短摘要 + 可折叠的原始诊断。
3963
+ // 摘要与详情分开,是因为用户第一眼只需要「通没通、多快」,排查时才
3964
+ // 展开 request id / 响应体。
3965
+ const results = [
3966
+ {
3967
+ key: "decision",
3968
+ label: "决策端点",
3969
+ ok: decision.ok === true,
3970
+ skipped: false,
3971
+ summary: summarizeProbe(decision.text),
3972
+ detail: decision.text,
3973
+ },
3974
+ {
3975
+ key: "fallback",
3976
+ label: "回退语言模型",
3977
+ ok: fallback.ok,
3978
+ skipped: fallback.skipped === true,
3979
+ summary: fallback.text,
3980
+ detail: fallback.detail ?? null,
3981
+ },
3982
+ ];
3983
+ return json({
3984
+ ok,
3985
+ // `text` 保留给旧前端/日志:纯文本仍然可读。
3986
+ text: [decision.text, fallback.text].filter((line) => typeof line === "string" && line.length > 0).join("\n\n"),
3987
+ results,
3988
+ });
3786
3989
  },
3787
3990
  });
3788
3991
 
package/lib/providers.js CHANGED
@@ -43,6 +43,8 @@ export const PROVIDER_IDS = [
43
43
  "bamboonode",
44
44
  "siliconflow",
45
45
  "siliconflow-intl",
46
+ "bailian",
47
+ "tokenhub",
46
48
  "perplexity",
47
49
  "openrouter",
48
50
  "custom",
@@ -124,6 +126,44 @@ export function normalizeThinkMode(raw) {
124
126
  return THINK_MODES.includes(value) ? value : DEFAULT_THINK_MODE;
125
127
  }
126
128
 
129
+ /**
130
+ * 阿里云百炼决策模型支持的地域。
131
+ *
132
+ * 文档核对(2026-11-15):决策模型只提供 cn-beijing 与 ap-southeast-1 两个
133
+ * 地域,workspace id 直接出现在域名里。这里导出给配置校验和界面下拉共用,
134
+ * 避免三处各写一份枚举。
135
+ */
136
+ export const BAILIAN_REGIONS = ["cn-beijing", "ap-southeast-1"];
137
+
138
+ /** 百炼默认地域(国内站)。 */
139
+ export const DEFAULT_BAILIAN_REGION = "cn-beijing";
140
+
141
+ /**
142
+ * 取百炼 Workspace ID(只接受能安全放进域名的字符)。
143
+ *
144
+ * 为什么校验字符:workspace 会拼进 hostname。带空格、斜杠、点或 `@` 的值
145
+ * 会拼出另一个域名(甚至把请求发到别处),必须在进入 URL 之前就拒掉,
146
+ * 而不是等 `new URL()` 报一个用户看不懂的错。
147
+ *
148
+ * @param cfg - 配置对象。
149
+ * @returns 合法 workspace;缺失或非法返回空串。
150
+ */
151
+ export function bailianWorkspaceOf(cfg = {}) {
152
+ const raw = typeof cfg?.bailianWorkspaceId === "string" ? cfg.bailianWorkspaceId.trim() : "";
153
+ return /^[A-Za-z0-9_-]+$/.test(raw) ? raw : "";
154
+ }
155
+
156
+ /**
157
+ * 取百炼地域;非法值回落默认地域。
158
+ *
159
+ * @param cfg - 配置对象。
160
+ * @returns `cn-beijing` 或 `ap-southeast-1`。
161
+ */
162
+ export function bailianRegionOf(cfg = {}) {
163
+ const raw = typeof cfg?.bailianRegion === "string" ? cfg.bailianRegion.trim() : "";
164
+ return BAILIAN_REGIONS.includes(raw) ? raw : DEFAULT_BAILIAN_REGION;
165
+ }
166
+
127
167
  /**
128
168
  * 内置预设。
129
169
  *
@@ -299,6 +339,80 @@ export const PROVIDER_PRESETS = {
299
339
  hint: "OpenRouter 端点(openrouter.ai/api/v1);模型填 OpenRouter 的 slug,地址与默认模型待实测",
300
340
  hintEn: "OpenRouter endpoint (openrouter.ai/api/v1); model takes an OpenRouter slug — base URL and default model are placeholders until verified",
301
341
  },
342
+ /**
343
+ * 阿里云百炼决策模型(System One 协议)。
344
+ *
345
+ * 本预设有两格**其它 provider 没有**的配置:Workspace ID 与地域。原因是
346
+ * 百炼的决策端点不是固定的公共域名,而是
347
+ * `https://{WorkspaceId}.{region}.maas.aliyuncs.com/compatible-mode/v1/systemone`
348
+ * ——workspace 是域名的一部分,所以 base URL 必须**现拼**(`baseUrlFrom`),
349
+ * 不能像其它预设那样写死。`baseUrl` 因此留空;界面通过 `baseUrlTemplate`
350
+ * 展示形状。
351
+ *
352
+ * 文档核对(2026-11-15):POST /compatible-mode/v1/systemone,model 必填且
353
+ * 当前取 `decision-model-preview`,鉴权 `Bearer $DASHSCOPE_API_KEY`,支持
354
+ * cn-beijing 与 ap-southeast-1。请求/响应形状与本插件的 systemone 协议一致。
355
+ */
356
+ bailian: {
357
+ id: "bailian",
358
+ label: "阿里云百炼",
359
+ labelEn: "Alibaba Bailian",
360
+ // 动态地址:workspace 或地域缺一不可,因此不能给静态 baseUrl。
361
+ baseUrl: "",
362
+ baseUrlTemplate: "https://{workspaceId}.{region}.maas.aliyuncs.com/compatible-mode/v1",
363
+ baseUrlFrom(cfg) {
364
+ const workspace = bailianWorkspaceOf(cfg);
365
+ if (workspace.length === 0) return "";
366
+ return `https://${workspace}.${bailianRegionOf(cfg)}.maas.aliyuncs.com/compatible-mode/v1`;
367
+ },
368
+ endpointStyle: "systemone",
369
+ // 百炼把 model 列为必填;留空必须回落到默认值,否则是注定 400 的请求。
370
+ requiresModel: true,
371
+ defaultModel: "decision-model-preview",
372
+ knownModels: ["decision-model-preview"],
373
+ apiKeyEnv: "DASHSCOPE_API_KEY",
374
+ legacyApiKeyEnvs: [],
375
+ keyField: "bailianApiKey",
376
+ modelField: "bailianModel",
377
+ keyEnvField: "bailianApiKeyEnv",
378
+ // 这两格不是「密钥/模型」,但界面必须知道字段名才能写回配置。
379
+ workspaceField: "bailianWorkspaceId",
380
+ regionField: "bailianRegion",
381
+ regions: BAILIAN_REGIONS,
382
+ // 决策模型本身不生成文本;think_mode 是 startlux 家族的字段,百炼文档
383
+ // 没有提到它,因此按「未实测」处理,不在界面上承诺效果。
384
+ thinkModeSupport: "unknown",
385
+ hint: "阿里云百炼决策模型(POST /compatible-mode/v1/systemone,model=decision-model-preview)。需要 Workspace ID 与地域;鉴权用 DASHSCOPE_API_KEY",
386
+ hintEn: "Alibaba Bailian decision model (POST /compatible-mode/v1/systemone, model=decision-model-preview). Needs a Workspace ID and region; authenticate with DASHSCOPE_API_KEY",
387
+ },
388
+ /**
389
+ * TokenHub 的 System One 决策端点。
390
+ *
391
+ * 先澄清一个容易混的事实:**腾讯云 TokenHub**(tokenhub.tencentmaas.com)
392
+ * 官方文档只提供 OpenAI / Anthropic 兼容的文本、图像、视频、3D 接口,
393
+ * 没有决策 / System One 端点。带 Jev 决策模型的是 **tokenhub.com**
394
+ * (TypeSafe)的 TokenHub,文档给出的地址是 `https://us-api.tokenhub.com/v1/systemone`。
395
+ * 因此这个预设指向后者,并在 hint 里把区别写清——否则用户会拿腾讯云的
396
+ * key 打到 tokenhub.com,得到 401 却不知道为什么。
397
+ */
398
+ tokenhub: {
399
+ id: "tokenhub",
400
+ label: "TokenHub",
401
+ labelEn: "TokenHub",
402
+ baseUrl: "https://us-api.tokenhub.com/v1",
403
+ endpointStyle: "systemone",
404
+ requiresModel: true,
405
+ defaultModel: "jev-latest",
406
+ knownModels: ["jev-latest", "jev-1-13"],
407
+ apiKeyEnv: "TOKENHUB_API_KEY",
408
+ legacyApiKeyEnvs: [],
409
+ keyField: "tokenhubApiKey",
410
+ modelField: "tokenhubModel",
411
+ keyEnvField: "tokenhubApiKeyEnv",
412
+ thinkModeSupport: "unknown",
413
+ hint: "TokenHub(tokenhub.com,TypeSafe)的 System One 决策端点(/v1/systemone,模型 jev-latest)。注意:腾讯云 TokenHub 没有决策端点,两者同名但不是同一个产品",
414
+ hintEn: "TokenHub (tokenhub.com, TypeSafe) System One decision endpoint (/v1/systemone, model jev-latest). Note: Tencent Cloud TokenHub has no decision endpoint — same name, different product",
415
+ },
302
416
  custom: {
303
417
  id: "custom",
304
418
  label: "custom",
@@ -704,7 +818,12 @@ export function resolveProvider(cfg = {}, options = {}) {
704
818
  }
705
819
 
706
820
  // 内置预设:预设默认打底,配置覆盖。
707
- const presetBase = preset.baseUrl;
821
+ //
822
+ // 动态预设(目前只有百炼)的地址由配置拼出(workspace + region),拼不出
823
+ // 来时 `baseUrlFrom` 返回空串——这不是「配置没填好」的普通无效,界面要给出
824
+ // 「需要 Workspace ID」而不是一句 URL 不合法。
825
+ const presetBase = typeof preset.baseUrlFrom === "function" ? preset.baseUrlFrom(cfg) : preset.baseUrl;
826
+ const dynamicMissing = typeof preset.baseUrlFrom === "function" && presetBase.trim().length === 0;
708
827
  const legacyBase =
709
828
  id === "bamboonode" && typeof cfg.baseUrl === "string" ? normalizeBaseUrl(cfg.baseUrl) : "";
710
829
  let baseUrl = legacyBase.length > 0 ? legacyBase : presetBase;
@@ -765,8 +884,12 @@ export function resolveProvider(cfg = {}, options = {}) {
765
884
  id === "bamboonode" ? cfg.apiKey : undefined,
766
885
  ),
767
886
  headers: {},
768
- valid: checked.ok,
769
- error: checked.ok ? undefined : `provider ${id} 的 base URL 不可用:${checked.error}`,
887
+ valid: checked.ok && !dynamicMissing,
888
+ error: dynamicMissing
889
+ ? `provider ${id} 需要 ${preset.workspaceField ? "Workspace ID" : "必填地址"}`
890
+ : checked.ok
891
+ ? undefined
892
+ : `provider ${id} 的 base URL 不可用:${checked.error}`,
770
893
  };
771
894
  }
772
895
 
@@ -947,6 +1070,13 @@ export function providerCatalog() {
947
1070
  label: preset.label,
948
1071
  labelEn: preset.labelEn,
949
1072
  baseUrl: preset.baseUrl,
1073
+ // 动态地址的预设(百炼):静态 baseUrl 为空,形状由模板表达。界面用它
1074
+ // 渲染占位/端点示例;`dynamicBaseUrl` 决定要不要显示 Workspace/地域两格。
1075
+ baseUrlTemplate: preset.baseUrlTemplate,
1076
+ dynamicBaseUrl: typeof preset.baseUrlFrom === "function",
1077
+ workspaceField: preset.workspaceField,
1078
+ regionField: preset.regionField,
1079
+ regions: [...(preset.regions ?? [])],
950
1080
  endpointStyle: preset.endpointStyle,
951
1081
  defaultModel: preset.defaultModel,
952
1082
  apiKeyEnv: preset.apiKeyEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leonhardthust/dsh-decision-engine",
3
- "version": "1.11.2",
3
+ "version": "1.13.0",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — a small decision model answers structured questions before the main model acts, so calls can be allowed, escalated, rerouted to a cheaper model, or skipped. All eight decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, COT translation) are implemented and individually switchable. Works against any service exposing a decision endpoint: built-in presets (system one, bamboonode, SiliconFlow, Silicon Flow) or a custom base URL.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",