cortico-world-workbuddy 0.1.6 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.1.7
4
+
5
+ 按 WorkBuddy 官方对话给出的合规口径,给桥接加「安全清单」闸门(仍只在本机,未推送 / 未发布)。
6
+
7
+ - **新增 `src/safety.js`**:把 WorkBuddy 的筛选原则固化成 `SAFE_MODEL_IDS`(21 个公开对话模型)、
8
+ 分组表 `SAFE_MODEL_GROUPS`、谨慎档 `CAUTION_MODEL_IDS`(hy3 系列与 preview)与 `classifyModel()`。
9
+ `classifyModel` 把目录里每个模型判成 `safe / caution / alias / disabled / media / code / retired /
10
+ format / experimental / unknown` 十档,附中文理由;`allow` 只对前两档为真。
11
+ - **闸门默认开启**(`worlds.workbuddy.safeOnly = true`):`wb_chat`、`wb_select_model` 与面板的
12
+ 「使用」按钮都会拒绝安全清单外的模型,并给出「判定档位 + 理由 + 如何解除」的说明;
13
+ 关掉这个配置才能调用 disabled / 内部别名 / 预览等模型,风险自负。
14
+ - **`wb_list_models` 默认只列安全清单**(按 DeepSeek / GLM / 混元 / Kimi / 其他分组,带此刻倍率),
15
+ 传 `all=true` 才追加其余模型及其排除理由。
16
+ - **被排除的典型项**:`wb-lite`(目录 `disabled:true`)、`auto` / `fast-model` / `balanced-model` /
17
+ `deep-model`(内部路由别名)、`hunyuan-image-alpha*` / `kling-video-v3` / `minimax-video-h3`(媒体)、
18
+ `codewise-*`(代码补全)、`hy4-preview*`(实验预览)、19 个已下架(11102)、`glm-5.0-turbo`(11150)。
19
+ - **模型目录面板**新增「合规」列(安全清单 / 安全清单·谨慎 / 各排除档位,悬停看理由),
20
+ 默认只显示安全清单,勾选「显示全部模型(含不建议项)」才展开;不安全项的选择按钮置灰。
21
+ - `envPromptVars()` 增加 `workbuddy.safeOnly`;`ENV_PROMPT.md` 增加一条:闸门开着时不要绕过拒绝,
22
+ 应该告诉用户原因并改推荐安全清单里的模型。
23
+ - `describe.json` 的 `envPrompt` 与 `wb_list_models` 描述同步(多了 `all` 参数)。
24
+
3
25
  ## 0.1.6
4
26
 
5
27
  新增「用量」面板与真实的时段优惠计算(只在本机验证,未发布)。
@@ -22,7 +44,6 @@
22
44
  - `wb_list_models` 标注此刻生效倍率(含原价对比、时段)与优惠徽标;`wb_select_model` 提示此刻倍率。
23
45
  - 环境提示词新增 `{{workbuddy.todayCredit}}` / `{{workbuddy.todayTokens}}`,并在倍率旁说明已含生效中的限时优惠。
24
46
  - 详情页徽标增加「今日积分」。
25
- - 仓库补 `package-lock.json`(锁定 esbuild 0.24.2),CI 改为 `npm ci`:`dist` 的构建产物必须逐字节稳定,否则「dist 未同步」的检查会误报。
26
47
 
27
48
  ## 0.1.5
28
49
 
package/README.md CHANGED
@@ -139,11 +139,31 @@ Coopanion (Coo)
139
139
 
140
140
  | 面板 | 内容 |
141
141
  |---|---|
142
- | 模型目录 | 刷新目录;逐模型一行:名称/id、**此刻倍率**(含限时折扣时显示 `x0.5 ← x0.79 23:00–07:50`,x0 标「免费」)、上下文、推理档、「选用」按钮 |
142
+ | 模型目录 | 刷新目录;逐模型一行:名称/id、**合规档位**、**此刻倍率**(含限时折扣时显示 `x0.5 ← x0.79 23:00–07:50`,x0 标「免费」)、上下文、推理档、「选用」按钮;默认只列安全清单,勾选「显示全部模型(含不建议项)」才展开其余项 |
143
143
  | 账号 | 登录态来源(本机发现 / 手动)、掩码 token、积分余额查询、重新检测、手动 token 存取 |
144
144
  | 是否启用 WorkBuddy | 「中转站优先」开关 + 主进程状态,与开始页按钮同步 |
145
145
  | 用量 | 区间概览(今天 / 近 7 天 / 近 30 天)、按模型聚合、积分包明细、限时优惠清单、最近调用;带「刷新余额」「清空本地账本」 |
146
146
 
147
+ ### 安全清单(合规闸门)
148
+
149
+ WorkBuddy 官方对话给出的口径是:只调用「官方公开目录里、当前可正常对话」的模型,
150
+ `disabled`、内部路由别名、媒体/代码补全与状态不明的模型都不要经外部软件调用,否则有风控/封号风险。
151
+ 这里把它落成代码:
152
+
153
+ - 白名单 21 个(`src/safety.js` 的 `SAFE_MODEL_IDS`,按 DeepSeek / GLM / 混元 / Kimi / 其他分组):
154
+ `deepseek-v4.1-flash`、`deepseek-v4-flash`、`deepseek-v4-pro`、`deepseek-v3-2-volc`、
155
+ `glm-5.3-flash`、`glm-5.3`、`glm-5.2`、`hy3`、`hy3-b`、`hy3-c`、`hy3-x`、`hunyuan-chat`、
156
+ `kimi-k2.5`、`kimi-k2.8-preview`、`kimi-k3-1`、`kimi-k3.1-pro`、`minimax-m2.7`、
157
+ `minimax-m3.1-flash`、`mimo-v2.6-pro`、`space-bunny`、`step-5-preview`。
158
+ 其中 `hy3*` 与 `*preview` 标为「谨慎」(免费试用/预览,别做高频自动化)。
159
+ - 其余全部**拒绝**:`wb-lite`(目录 `disabled:true`)、`auto` / `fast-model` / `balanced-model` /
160
+ `deep-model`(内部路由别名)、4 个媒体模型、`codewise-*`、`hy4-preview*`、19 个已下架的、
161
+ `glm-5.0-turbo`(11150 请求格式不符)。
162
+ - 闸门是 `worlds.workbuddy.safeOnly`(默认 `true`)。开着时 `wb_chat` / `wb_select_model` / 面板「选用」
163
+ 三处一致拒绝,并说明「判定档位 + 理由 + 怎么解除」;关掉才放行,属于明示的风险自负。
164
+ - 清单是按 2026-11 的官方目录定的,官方加新模型不会被自动信任——这正是「未列入安全清单」这一档的意义。
165
+
166
+
147
167
  ### 倍率与限时优惠怎么算
148
168
 
149
169
  - 目录来自 `GET /v3/config`(应用身份 UA):一次同时拿到 `models`、`modelPromotions`、`modelTiers`。
@@ -167,9 +187,9 @@ Coopanion (Coo)
167
187
 
168
188
  | 工具 | 作用 |
169
189
  |---|---|
170
- | `wb_list_models` | 列出目录中全部模型及**此刻倍率**(含限时优惠与时段),并单列优惠清单(只读) |
171
- | `wb_select_model` | 按 id 选择 `wb_chat` 的默认模型,持久化;提示此刻倍率 |
172
- | `wb_chat` | 用选中模型回答一个单轮问题(**真实消耗积分**,需主进程在跑);回执附输入/输出/缓存 token 与本次积分 |
190
+ | `wb_list_models` | 列出**安全清单内**的模型及此刻倍率(含限时优惠与时段),并单列优惠清单;传 `all=true` 才带上被排除项与理由(只读) |
191
+ | `wb_select_model` | 按 id 选择 `wb_chat` 的默认模型,持久化;提示此刻倍率;安全清单外会被拒 |
192
+ | `wb_chat` | 用选中模型回答一个单轮问题(**真实消耗积分**,需主进程在跑);回执附输入/输出/缓存 token 与本次积分;安全清单外会被拒 |
173
193
  | `wb_credits` | 只读查询积分余额与本周期已用(需主进程在跑) |
174
194
 
175
195
  ## 配置(`worlds.workbuddy.*`)
@@ -184,6 +204,7 @@ Coopanion (Coo)
184
204
  | `envPrompt` | `true` | 是否把桥接状态写进 system 前缀(关掉后「中转站优先」也不生效) |
185
205
  | `prefer` | `false` | 「中转站优先」,由开始页按钮 / 详情页开关写入 |
186
206
  | `hostInject` | `true` | 是否注入「开始」页按钮(关掉后不碰宿主文件,只用详情页开关) |
207
+ | `safeOnly` | `true` | 只允许调用安全清单内的公开对话模型;关掉会放行 disabled / 别名 / 预览等项,风险自负 |
187
208
 
188
209
  ## 开发与校验
189
210
 
package/describe.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "tools": [
3
3
  {
4
4
  "name": "wb_list_models",
5
- "description": "List the WorkBuddy models available to the signed-in account, each with its default credit multiplier (e.g. x0.79), context window and reasoning efforts. Read-only; costs nothing.",
5
+ "description": "List the WorkBuddy chat models that are safe to call (official public catalog only), each with its credit multiplier (e.g. x0.79, already including time-limited discounts), context window and reasoning efforts. Read-only; costs nothing. Pass all=true to also list models that are disabled, internal routing aliases, media/code models or delisted ones — those are not callable through this bridge while worlds.workbuddy.safeOnly is on.",
6
6
  "summary": {
7
- "zh": "列出本机 WorkBuddy 账号可用的模型及各自的默认积分倍率。",
8
- "en": "Lists WorkBuddy models for the signed-in account with each default credit multiplier."
7
+ "zh": "列出 WorkBuddy 公开对话模型(安全清单)及各自的积分倍率、上下文与推理档;all=true 时附上被排除的项与原因。",
8
+ "en": "Lists the safe WorkBuddy chat models with multiplier, context and reasoning efforts; all=true also lists excluded ones with reasons."
9
9
  },
10
10
  "tags": [
11
11
  "read"
@@ -13,12 +13,17 @@
13
13
  "parameters": {
14
14
  "type": "object",
15
15
  "additionalProperties": false,
16
- "properties": {}
16
+ "properties": {
17
+ "all": {
18
+ "type": "boolean",
19
+ "description": "Also list models outside the safe list (shown with their risk reason)."
20
+ }
21
+ }
17
22
  }
18
23
  },
19
24
  {
20
25
  "name": "wb_select_model",
21
- "description": "Select which WorkBuddy model wb_chat should use by default. The id must come from wb_list_models. Persists across restarts.",
26
+ "description": "Select which WorkBuddy model wb_chat should use by default. The id must come from wb_list_models. Refused when worlds.workbuddy.safeOnly is on and the model is outside the safe list. Persists across restarts.",
22
27
  "summary": {
23
28
  "zh": "选择 wb_chat 默认调用的 WorkBuddy 模型。",
24
29
  "en": "Selects the default WorkBuddy model for wb_chat."
@@ -98,5 +103,5 @@
98
103
  }
99
104
  }
100
105
  ],
101
- "envPrompt": "## WorkBuddy bridge\n\nThis machine has a WorkBuddy desktop sign-in bridged in (region: {{workbuddy.region}}; signed in: {{workbuddy.authed}}).\nThe model catalog holds {{workbuddy.catalogSize}} models. The currently selected model is `{{workbuddy.model}}` with default credit multiplier {{workbuddy.multiplier}}.\nWorkBuddy desktop main process: {{workbuddy.process}}. Relay-first switch: {{workbuddy.priority}}.\nSpent today through the bridge: {{workbuddy.todayCredit}} credits / {{workbuddy.todayTokens}} tokens (locally recorded).\nThe multiplier above already includes any time-limited discount or free window that is active right now; `wb_list_models` also marks which offers are active and their hours.\n\n- Use `wb_list_models` to see every model with its multiplier before choosing; prefer low-multiplier models for casual tasks.\n- Use `wb_select_model` when the user asks to switch the WorkBuddy model.\n- `wb_chat` only works while the WorkBuddy desktop main process is running; otherwise it returns「未检测到 WorkBuddy 主进程」. Every call spends the user's own credits at that model's multiplier.\n- `wb_credits` is read-only and costs nothing (also requires the desktop process).\n\n### Relay first: {{workbuddy.priority}}\n\n- Switch `on` **and** process `running`: every user turn that expects a conversational answer (question, chat, writing, reasoning, rewrite, translation, summary) must be answered by a WorkBuddy model first — call `wb_chat` (omit `model` to use `{{workbuddy.model}}`), then speak the returned answer back in your own voice and name the model that answered. Only fall back to your own model if the call fails.\n- Switch `off` (default): do not call `wb_chat`; answer with your own model. If the user explicitly asks for WorkBuddy, `wb_chat` is still allowed — the desktop process must be running.\n- Tool errors and empty answers are not a reason to flip the switch; just say what happened.\n"
106
+ "envPrompt": "## WorkBuddy bridge\n\nThis machine has a WorkBuddy desktop sign-in bridged in (region: {{workbuddy.region}}; signed in: {{workbuddy.authed}}).\nThe model catalog holds {{workbuddy.catalogSize}} models. The currently selected model is `{{workbuddy.model}}` with default credit multiplier {{workbuddy.multiplier}}.\nWorkBuddy desktop main process: {{workbuddy.process}}. Relay-first switch: {{workbuddy.priority}}.\nSpent today through the bridge: {{workbuddy.todayCredit}} credits / {{workbuddy.todayTokens}} tokens (locally recorded).\nThe multiplier above already includes any time-limited discount or free window that is active right now; `wb_list_models` also marks which offers are active and their hours.\n\n- Use `wb_list_models` to see every model with its multiplier before choosing; prefer low-multiplier models for casual tasks.\n- Safe-list gate: {{workbuddy.safeOnly}}. When it is `on`, only the models WorkBuddy publishes as public chat models are callable — `disabled` entries, internal routing aliases (`auto`, `fast-model`, `balanced-model`, `deep-model`), media/code models and delisted ones are refused. Do not try to work around a refusal; tell the user why and offer a safe model instead. `wb_list_models` with `all=true` shows the refused ones with reasons.\n- Use `wb_select_model` when the user asks to switch the WorkBuddy model.\n- `wb_chat` only works while the WorkBuddy desktop main process is running; otherwise it returns「未检测到 WorkBuddy 主进程」. Every call spends the user's own credits at that model's multiplier.\n- `wb_credits` is read-only and costs nothing (also requires the desktop process).\n\n### Relay first: {{workbuddy.priority}}\n\n- Switch `on` **and** process `running`: every user turn that expects a conversational answer (question, chat, writing, reasoning, rewrite, translation, summary) must be answered by a WorkBuddy model first — call `wb_chat` (omit `model` to use `{{workbuddy.model}}`), then speak the returned answer back in your own voice and name the model that answered. Only fall back to your own model if the call fails.\n- Switch `off` (default): do not call `wb_chat`; answer with your own model. If the user explicitly asks for WorkBuddy, `wb_chat` is still allowed — the desktop process must be running.\n- Tool errors and empty answers are not a reason to flip the switch; just say what happened.\n"
102
107
  }
package/dist/webui.js CHANGED
@@ -77,7 +77,14 @@ var TEXT = {
77
77
  noData: "\u6682\u65E0\u6570\u636E",
78
78
  totalCredit: "\u5408\u8BA1\u79EF\u5206",
79
79
  todayCredit: "\u4ECA\u65E5\u79EF\u5206",
80
- todayTokens: "\u4ECA\u65E5 token"
80
+ todayTokens: "\u4ECA\u65E5 token",
81
+ colSafety: "\u5408\u89C4",
82
+ safetySafe: "\u5B89\u5168\u6E05\u5355",
83
+ safetyCaution: "\u5B89\u5168\u6E05\u5355\xB7\u8C28\u614E",
84
+ showAllModels: "\u663E\u793A\u5168\u90E8\u6A21\u578B\uFF08\u542B\u4E0D\u5EFA\u8BAE\u9879\uFF09",
85
+ unsafePick: "\u8BE5\u6A21\u578B\u4E0D\u5728\u5B89\u5168\u6E05\u5355\u5185\uFF0C\u5DF2\u62D2\u7EDD\u9009\u62E9",
86
+ safeOnlyOn: "\u4EC5\u8C03\u7528\u5B89\u5168\u6E05\u5355\u6A21\u578B",
87
+ safeOnlyOff: "safeOnly \u5DF2\u5173\u95ED\uFF1A\u5168\u90E8\u6A21\u578B\u53EF\u8C03\u7528\uFF08\u542B WorkBuddy \u4E0D\u5EFA\u8BAE\u5916\u90E8\u8C03\u7528\u7684\u9879\uFF09"
81
88
  },
82
89
  en: {
83
90
  refresh: "Refresh from WorkBuddy",
@@ -156,7 +163,14 @@ var TEXT = {
156
163
  noData: "No data",
157
164
  totalCredit: "Total credits",
158
165
  todayCredit: "Credits today",
159
- todayTokens: "Tokens today"
166
+ todayTokens: "Tokens today",
167
+ colSafety: "Policy",
168
+ safetySafe: "Safe list",
169
+ safetyCaution: "Safe list \xB7 caution",
170
+ showAllModels: "Show all models (including discouraged ones)",
171
+ unsafePick: "This model is outside the safe list and cannot be selected",
172
+ safeOnlyOn: "Safe-list only",
173
+ safeOnlyOff: "safeOnly is off: every model is callable (including ones WorkBuddy discourages)"
160
174
  }
161
175
  };
162
176
  function strings(ctx) {
@@ -190,33 +204,42 @@ function mountModels(ctx) {
190
204
  const { ui } = ctx;
191
205
  const sheet = ui.sheet({
192
206
  title: s.colModel === "\u6A21\u578B" ? "WorkBuddy \u6A21\u578B\u76EE\u5F55" : "WorkBuddy model catalog",
193
- desc: s.empty
207
+ desc: s.colModel === "\u6A21\u578B" ? "\u9ED8\u8BA4\u53EA\u5217 WorkBuddy \u516C\u5F00\u5BF9\u8BDD\u6A21\u578B\uFF08\u5B89\u5168\u6E05\u5355\uFF09\uFF1B\u52FE\u9009\u300C\u663E\u793A\u5168\u90E8\u6A21\u578B\u300D\u53EF\u67E5\u770B\u88AB\u6392\u9664\u9879\u53CA\u5176\u7406\u7531\u3002" : 'Only WorkBuddy public chat models are listed by default; tick "Show all models" to see excluded ones and why.'
194
208
  });
195
209
  ctx.root.append(sheet.el);
196
210
  const head = ui.rowbar();
197
211
  const refreshBtn = ui.button(s.refresh, { variant: "primary" });
198
212
  const stateChip = ui.chip("", "plain");
199
213
  const currentChip = ui.chip("", "accent");
200
- head.append(refreshBtn, stateChip, currentChip);
214
+ const allBox = ui.checkbox(s.showAllModels, { checked: false });
215
+ head.append(refreshBtn, stateChip, currentChip, allBox.el);
201
216
  sheet.body.append(head);
202
217
  const errLine = ui.msgline();
203
218
  sheet.body.append(errLine);
204
219
  const table = ui.table({
205
- head: [s.colModel, s.colMultiplier, s.colContext, s.colReasoning, s.colAction],
220
+ head: [s.colModel, s.colSafety, s.colMultiplier, s.colContext, s.colReasoning, s.colAction],
206
221
  maxHeight: "calc(100vh - 320px)"
207
222
  });
208
223
  sheet.body.append(table.el);
209
224
  let state = null;
210
225
  function renderChips() {
211
- stateChip.textContent = `${s.lastRefresh}: ${fmtTs(state?.catalogAt, s)} \xB7 ${state?.models?.length ?? 0} ${s.models}`;
226
+ const models = state?.models ?? [];
227
+ const safeCount = models.filter((m) => m.safety?.allow).length;
228
+ stateChip.textContent = `${s.lastRefresh}: ${fmtTs(state?.catalogAt, s)} \xB7 ${safeCount}/${models.length} ${s.models} \xB7 ${state?.safeOnly === false ? s.safeOnlyOff : s.safeOnlyOn}`;
212
229
  currentChip.textContent = `${s.currentModel}: ${state?.model || "\u2014"}`;
213
230
  }
231
+ function pickable(m) {
232
+ if (state?.safeOnly === false) return true;
233
+ return m.safety?.allow !== false;
234
+ }
214
235
  function renderTable() {
215
- const models = state?.models ?? [];
236
+ const all = state?.models ?? [];
237
+ const models = allBox.checked ? all : all.filter((m) => m.safety?.allow);
216
238
  table.clear(models.length ? void 0 : s.empty);
217
239
  for (const m of models) {
218
240
  const mul = fmtMultiplier(m, s);
219
241
  const isCurrent = state.model === m.id;
242
+ const safety = m.safety ?? {};
220
243
  const nameCell = ui.h("div");
221
244
  nameCell.append(ui.h("div", null, m.name || m.id));
222
245
  const sub = ui.h("div", "dim", m.id);
@@ -224,12 +247,26 @@ function mountModels(ctx) {
224
247
  sub.style.opacity = "0.65";
225
248
  nameCell.append(sub);
226
249
  if (m.description) nameCell.title = m.description;
250
+ const safePill = ui.pill(
251
+ safety.label ?? "\u2014",
252
+ safety.allow ? safety.caution ? "plain" : "on" : "plain"
253
+ );
254
+ if (safety.reason) safePill.title = safety.reason;
255
+ if (!safety.allow) {
256
+ safePill.style.opacity = "0.6";
257
+ nameCell.style.opacity = "0.6";
258
+ }
227
259
  const mulPill = ui.pill(mul.text, mul.free ? "on" : "plain");
228
260
  if (mul.promo) mulPill.title = mul.promo;
261
+ const allowed = pickable(m);
229
262
  const btn = ui.button(isCurrent ? s.selected : s.select, {
230
263
  size: "sm",
231
264
  variant: isCurrent ? "plain" : "primary",
232
265
  onClick: async () => {
266
+ if (!allowed) {
267
+ ui.toast(`${s.unsafePick}${safety.reason ? `\uFF1A${safety.reason}` : ""}`, "bad");
268
+ return;
269
+ }
233
270
  const off = ui.disable(btn);
234
271
  try {
235
272
  const r = await ctx.invoke("select", [m.id]);
@@ -249,8 +286,13 @@ function mountModels(ctx) {
249
286
  }
250
287
  });
251
288
  if (isCurrent) btn.disabled = true;
289
+ if (!allowed) {
290
+ btn.disabled = true;
291
+ btn.title = safety.reason ?? "";
292
+ }
252
293
  table.addRow([
253
294
  nameCell,
295
+ safePill,
254
296
  mulPill,
255
297
  m.contextWindow ? String(m.contextWindow) : "\u2014",
256
298
  m.reasoning?.supportedEfforts?.join(" / ") ?? "\u2014",
@@ -258,6 +300,10 @@ function mountModels(ctx) {
258
300
  ]);
259
301
  }
260
302
  }
303
+ allBox.input.addEventListener("change", () => {
304
+ renderChips();
305
+ renderTable();
306
+ });
261
307
  async function load() {
262
308
  state = await ctx.invoke("state");
263
309
  renderChips();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-workbuddy",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "把 Windows 端已登录的 WorkBuddy 客户端模型桥接进 Coopanion:读取本机登录态,列出模型目录与默认积分倍率,供 Coo 调用。",
6
6
  "license": "MIT",
@@ -356,6 +356,23 @@ for (let run = 1; run <= RUNS; run += 1) {
356
356
  row.checks.usageRows = usage.rows;
357
357
  row.checks.usageChip = usage.chip;
358
358
  if (SHOTS && run === 1) { await sleep(300); await shot('world-usage'); }
359
+ // --- 模型目录:合规列 + 默认只列安全清单 ---
360
+ await evaluate(clickByText('模型目录'));
361
+ const modelsView = await waitFor('模型目录渲染', async () => {
362
+ const v = await evaluate(`(() => {
363
+ const th = [...document.querySelectorAll('th')].map((n) => n.textContent.trim());
364
+ const rows = document.querySelectorAll('table tr').length;
365
+ const safePills = [...document.querySelectorAll('td span')].filter((n) => /安全清单/.test(n.textContent || '')).length;
366
+ const allBox = [...document.querySelectorAll('input[type="checkbox"]')].length;
367
+ return { hasPolicy: th.includes('合规'), headers: th.join('|'), rows, safePills, checkboxes: allBox };
368
+ })()`);
369
+ return v.hasPolicy && v.rows > 10 ? v : false;
370
+ });
371
+ row.checks.modelsPolicyColumn = modelsView.hasPolicy;
372
+ row.checks.modelsRows = modelsView.rows;
373
+ row.checks.modelsSafePills = modelsView.safePills;
374
+ row.checks.modelsHasShowAll = modelsView.checkboxes >= 1;
375
+ if (SHOTS && run === 1) { await sleep(300); await shot('world-models'); }
359
376
  if (KEEP) await shot(`run${run}-world`);
360
377
  void pane;
361
378
  } catch (err) {
package/src/ENV_PROMPT.md CHANGED
@@ -7,6 +7,7 @@ Spent today through the bridge: {{workbuddy.todayCredit}} credits / {{workbuddy.
7
7
  The multiplier above already includes any time-limited discount or free window that is active right now; `wb_list_models` also marks which offers are active and their hours.
8
8
 
9
9
  - Use `wb_list_models` to see every model with its multiplier before choosing; prefer low-multiplier models for casual tasks.
10
+ - Safe-list gate: {{workbuddy.safeOnly}}. When it is `on`, only the models WorkBuddy publishes as public chat models are callable — `disabled` entries, internal routing aliases (`auto`, `fast-model`, `balanced-model`, `deep-model`), media/code models and delisted ones are refused. Do not try to work around a refusal; tell the user why and offer a safe model instead. `wb_list_models` with `all=true` shows the refused ones with reasons.
10
11
  - Use `wb_select_model` when the user asks to switch the WorkBuddy model.
11
12
  - `wb_chat` only works while the WorkBuddy desktop main process is running; otherwise it returns「未检测到 WorkBuddy 主进程」. Every call spends the user's own credits at that model's multiplier.
12
13
  - `wb_credits` is read-only and costs nothing (also requires the desktop process).
package/src/config.js CHANGED
@@ -29,6 +29,12 @@ export const WB_DEFAULTS = {
29
29
  prefer: false,
30
30
  /** 是否允许改写宿主控制台 index.html 注入「中转站优先」按钮;关闭后不注入也不还原。 */
31
31
  hostInject: true,
32
+ /**
33
+ * 只允许调用 WorkBuddy 公开对话模型(安全清单)。
34
+ * 关掉后能用 disabled / 内部别名 / 预览等模型,但那属于「已知会违反对方使用建议」的操作,
35
+ * 默认必须是 true。
36
+ */
37
+ safeOnly: true,
32
38
  };
33
39
 
34
40
  /** World 配置组(WebUI 通用渲染的旋钮)。模型选择走自定义面板,不在这里。 */
@@ -84,6 +90,13 @@ export const WB_CONFIG_GROUP = {
84
90
  description: '在「开始」页加一个「中转站优先」按钮(会改写 Coopanion 的 build/console/index.html,停用或卸载时自动还原)。关掉后只能用 World 详情页的开关。',
85
91
  'x-hot': true,
86
92
  },
93
+ 'worlds.workbuddy.safeOnly': {
94
+ type: 'boolean',
95
+ title: '只调用安全清单模型',
96
+ description:
97
+ '默认开:wb_chat / wb_select_model 只接受 WorkBuddy 公开目录里的对话模型(21 个),disabled、内部路由别名、媒体与代码补全模型一律拒绝。关掉可绕过限制,但那属于违反对方使用建议的操作,风险自负。',
98
+ 'x-hot': true,
99
+ },
87
100
  },
88
101
  },
89
102
  };
package/src/safety.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 模型安全清单(合规口径)。
3
+ *
4
+ * 依据:WorkBuddy 官方对话给出的「避免违反用户协议/触发风控」筛选原则——
5
+ * 只调用「官方公开目录里、当前可正常对话」的模型;凡是 disabled、内部路由别名、
6
+ * 状态不明(请求格式报错)、媒体/代码补全模型,一律不通过外部软件调用。
7
+ *
8
+ * 这里把结论固化成三件事:
9
+ * 1. SAFE_MODEL_IDS:可直接用 wb_chat 调用的白名单(WorkBuddy 给的 21 个);
10
+ * 2. classifyModel():给目录里每个模型打等级与理由,供面板展示与拦截;
11
+ * 3. DEFAULT_SAFE_MODEL:当前选择失效时的回落项(同样是白名单内的 flash 档)。
12
+ *
13
+ * 清单会随官方目录变化而过时——这正是 classifyModel 返回 safe=false 时给出
14
+ * 「不在安全清单内」的原因,而不是断言模型不可用。用户可以显式关掉 safeOnly
15
+ * 自行承担风险,扩展不做单方面放行。
16
+ */
17
+
18
+ /** WorkBuddy 建议的公开对话模型白名单。 */
19
+ export const SAFE_MODEL_IDS = [
20
+ // DeepSeek
21
+ 'deepseek-v4.1-flash',
22
+ 'deepseek-v4-flash',
23
+ 'deepseek-v4-pro',
24
+ 'deepseek-v3-2-volc',
25
+ // GLM
26
+ 'glm-5.3-flash',
27
+ 'glm-5.3',
28
+ 'glm-5.2',
29
+ // 混元
30
+ 'hy3',
31
+ 'hy3-b',
32
+ 'hy3-c',
33
+ 'hy3-x',
34
+ 'hunyuan-chat',
35
+ // Kimi
36
+ 'kimi-k2.5',
37
+ 'kimi-k2.8-preview',
38
+ 'kimi-k3-1',
39
+ 'kimi-k3.1-pro',
40
+ // 其他
41
+ 'minimax-m2.7',
42
+ 'minimax-m3.1-flash',
43
+ 'mimo-v2.6-pro',
44
+ 'space-bunny',
45
+ 'step-5-preview',
46
+ ];
47
+
48
+ /** 白名单分组(面板与 wb_list_models 用同一份顺序)。 */
49
+ export const SAFE_MODEL_GROUPS = [
50
+ { label: 'DeepSeek', ids: ['deepseek-v4.1-flash', 'deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v3-2-volc'] },
51
+ { label: 'GLM', ids: ['glm-5.3-flash', 'glm-5.3', 'glm-5.2'] },
52
+ { label: '混元', ids: ['hy3', 'hy3-b', 'hy3-c', 'hy3-x', 'hunyuan-chat'] },
53
+ { label: 'Kimi', ids: ['kimi-k2.5', 'kimi-k2.8-preview', 'kimi-k3-1', 'kimi-k3.1-pro'] },
54
+ { label: '其他', ids: ['minimax-m2.7', 'minimax-m3.1-flash', 'mimo-v2.6-pro', 'space-bunny', 'step-5-preview'] },
55
+ ];
56
+
57
+ /** 白名单里属于「免费试用/预览」的模型:能用,但不适合高频自动化调用。 */
58
+ export const CAUTION_MODEL_IDS = ['hy3', 'hy3-b', 'hy3-c', 'kimi-k2.8-preview', 'step-5-preview'];
59
+
60
+ /** 当前选择失效时的回落模型。 */
61
+ export const DEFAULT_SAFE_MODEL = 'deepseek-v4.1-flash';
62
+
63
+ /** 内部路由别名:疑似仅限 WorkBuddy 应用内 UI 使用。 */
64
+ const ALIAS_IDS = ['auto', 'fast-model', 'balanced-model', 'deep-model'];
65
+
66
+ /** 媒体生成模型:不属于 /v2/chat/completions 的对话范围。 */
67
+ const MEDIA_IDS = ['hunyuan-image-alpha', 'hunyuan-image-alpha-edit', 'kling-video-v3', 'minimax-video-h3'];
68
+
69
+ /** 代码补全模型:走补全接口,对话接口只会回空。 */
70
+ const CODE_IDS = ['codewise-rewrite', 'codewise-nes-a4-027-aide', 'codewise-jump', 'codewise-completions'];
71
+
72
+ /** 实测已下架(11102 service info not found)的模型,仅用于展示时置灰。 */
73
+ const RETIRED_IDS = [
74
+ 'hy4-preview-x',
75
+ 'minimax-m2.5',
76
+ 'glm-4.6v',
77
+ 'kimi-k2-thinking',
78
+ 'glm-4.6',
79
+ 'deepseek-v3-1-volc',
80
+ 'deepseek-v3-1',
81
+ 'deepseek-v3-0324-lkeap',
82
+ 'deepseek-r1-0528-lkeap',
83
+ 'kimi-k2-instruct-taiji',
84
+ 'completion-gf',
85
+ 'default-1.1',
86
+ 'default-1.2',
87
+ 'hunyuan-3b',
88
+ 'hunyuan-7b-dense',
89
+ 'hunyuan-2.0-instruct',
90
+ 'deepseek-r1-0528',
91
+ 'deepseek-v3-0324',
92
+ 'deepseek-v3-0324-taco-completion',
93
+ ];
94
+
95
+ /** 请求格式不符(11150)的模型,状态不明。 */
96
+ const FORMAT_UNSUPPORTED_IDS = ['glm-5.0-turbo'];
97
+
98
+ /** 实验预览系列(能对话,但未列入安全清单)。 */
99
+ const EXPERIMENTAL_PREFIXES = ['hy4-preview', 'hy4preview-'];
100
+
101
+ const TIER_LABEL = {
102
+ safe: '安全清单',
103
+ caution: '安全清单(谨慎)',
104
+ alias: '内部路由别名',
105
+ disabled: '目录已标 disabled',
106
+ media: '媒体生成模型',
107
+ code: '代码补全模型',
108
+ retired: '已下架',
109
+ format: '状态不明',
110
+ experimental: '实验预览',
111
+ unknown: '不在安全清单',
112
+ };
113
+
114
+ function inList(id, list) {
115
+ return list.includes(id);
116
+ }
117
+
118
+ function startsWithAny(id, prefixes) {
119
+ return prefixes.some((p) => id.startsWith(p));
120
+ }
121
+
122
+ /**
123
+ * 判定一个模型属于哪一档。
124
+ * @param {string} id
125
+ * @param {{ disabled?: boolean }} [model]
126
+ * @returns {{ id: string, tier: string, label: string, reason: string, allow: boolean, caution: boolean }}
127
+ */
128
+ export function classifyModel(id, model) {
129
+ const mid = String(id ?? '');
130
+ const make = (tier, reason) => ({
131
+ id: mid,
132
+ tier,
133
+ label: TIER_LABEL[tier] ?? tier,
134
+ reason,
135
+ allow: tier === 'safe' || tier === 'caution',
136
+ caution: tier === 'caution',
137
+ });
138
+
139
+ if (model?.disabled === true) return make('disabled', '目录里标了 disabled,WorkBuddy 明确建议不要调用');
140
+ if (inList(mid, SAFE_MODEL_IDS)) {
141
+ return inList(mid, CAUTION_MODEL_IDS)
142
+ ? make('caution', '公开对话模型;属免费试用或 preview,能用但不适合高频自动化调用')
143
+ : make('safe', '公开对话模型,WorkBuddy 建议的调用范围');
144
+ }
145
+ if (inList(mid, ALIAS_IDS)) return make('alias', '内部路由别名,疑似仅限 WorkBuddy 应用内使用');
146
+ if (inList(mid, MEDIA_IDS)) return make('media', '图像/视频生成模型,不在对话接口范围内');
147
+ if (inList(mid, CODE_IDS)) return make('code', '代码补全模型,对话接口不会给出正文');
148
+ if (inList(mid, RETIRED_IDS)) return make('retired', '已下架(11102 service info not found)');
149
+ if (inList(mid, FORMAT_UNSUPPORTED_IDS)) return make('format', '请求格式不符(11150),状态不明');
150
+ if (startsWithAny(mid, EXPERIMENTAL_PREFIXES)) return make('experimental', '实验预览模型,未列入安全清单');
151
+ return make('unknown', 'WorkBuddy 未把它列为公开对话模型');
152
+ }
153
+
154
+ /** 白名单模型的建议默认次序:优先 flash 档,避开试用/预览。 */
155
+ export function preferredSafeModel(availableIds = SAFE_MODEL_IDS) {
156
+ const order = [DEFAULT_SAFE_MODEL, 'deepseek-v4-flash', 'glm-5.3-flash', 'minimax-m3.1-flash'];
157
+ for (const id of order) if (availableIds.includes(id)) return id;
158
+ const firstStable = availableIds.find((id) => inList(id, SAFE_MODEL_IDS) && !inList(id, CAUTION_MODEL_IDS));
159
+ return firstStable ?? DEFAULT_SAFE_MODEL;
160
+ }
161
+
162
+ /** 白名单按分组展开,只保留确实在目录里的。 */
163
+ export function safeGroupsOf(availableIds) {
164
+ const set = new Set(availableIds ?? []);
165
+ return SAFE_MODEL_GROUPS.map((g) => ({ label: g.label, ids: g.ids.filter((id) => set.has(id)) })).filter(
166
+ (g) => g.ids.length > 0,
167
+ );
168
+ }
package/src/upstream.js CHANGED
@@ -401,6 +401,8 @@ function normalizeModel(m) {
401
401
  supportsToolCall: m.supportsToolCall === true || m.supports_tool_call === true,
402
402
  description: str(m.descriptionZh ?? m.description ?? m.descriptionEn),
403
403
  reasoning: parseReasoning(m.reasoning),
404
+ /** 目录里标了 disabled=true 的模型:调用它们等于绕过官方限制,安全清单直接拒。 */
405
+ disabled: m.disabled === true,
404
406
  };
405
407
  return out;
406
408
  }
@@ -85,6 +85,13 @@ const TEXT = {
85
85
  totalCredit: '合计积分',
86
86
  todayCredit: '今日积分',
87
87
  todayTokens: '今日 token',
88
+ colSafety: '合规',
89
+ safetySafe: '安全清单',
90
+ safetyCaution: '安全清单·谨慎',
91
+ showAllModels: '显示全部模型(含不建议项)',
92
+ unsafePick: '该模型不在安全清单内,已拒绝选择',
93
+ safeOnlyOn: '仅调用安全清单模型',
94
+ safeOnlyOff: 'safeOnly 已关闭:全部模型可调用(含 WorkBuddy 不建议外部调用的项)',
88
95
  },
89
96
  en: {
90
97
  refresh: 'Refresh from WorkBuddy',
@@ -166,6 +173,13 @@ const TEXT = {
166
173
  totalCredit: 'Total credits',
167
174
  todayCredit: 'Credits today',
168
175
  todayTokens: 'Tokens today',
176
+ colSafety: 'Policy',
177
+ safetySafe: 'Safe list',
178
+ safetyCaution: 'Safe list · caution',
179
+ showAllModels: 'Show all models (including discouraged ones)',
180
+ unsafePick: 'This model is outside the safe list and cannot be selected',
181
+ safeOnlyOn: 'Safe-list only',
182
+ safeOnlyOff: 'safeOnly is off: every model is callable (including ones WorkBuddy discourages)',
169
183
  },
170
184
  };
171
185
 
@@ -205,7 +219,10 @@ function mountModels(ctx) {
205
219
 
206
220
  const sheet = ui.sheet({
207
221
  title: s.colModel === '模型' ? 'WorkBuddy 模型目录' : 'WorkBuddy model catalog',
208
- desc: s.empty,
222
+ desc:
223
+ s.colModel === '模型'
224
+ ? '默认只列 WorkBuddy 公开对话模型(安全清单);勾选「显示全部模型」可查看被排除项及其理由。'
225
+ : 'Only WorkBuddy public chat models are listed by default; tick "Show all models" to see excluded ones and why.',
209
226
  });
210
227
  ctx.root.append(sheet.el);
211
228
 
@@ -213,14 +230,15 @@ function mountModels(ctx) {
213
230
  const refreshBtn = ui.button(s.refresh, { variant: 'primary' });
214
231
  const stateChip = ui.chip('', 'plain');
215
232
  const currentChip = ui.chip('', 'accent');
216
- head.append(refreshBtn, stateChip, currentChip);
233
+ const allBox = ui.checkbox(s.showAllModels, { checked: false });
234
+ head.append(refreshBtn, stateChip, currentChip, allBox.el);
217
235
  sheet.body.append(head);
218
236
 
219
237
  const errLine = ui.msgline();
220
238
  sheet.body.append(errLine);
221
239
 
222
240
  const table = ui.table({
223
- head: [s.colModel, s.colMultiplier, s.colContext, s.colReasoning, s.colAction],
241
+ head: [s.colModel, s.colSafety, s.colMultiplier, s.colContext, s.colReasoning, s.colAction],
224
242
  maxHeight: 'calc(100vh - 320px)',
225
243
  });
226
244
  sheet.body.append(table.el);
@@ -228,16 +246,29 @@ function mountModels(ctx) {
228
246
  let state = null;
229
247
 
230
248
  function renderChips() {
231
- stateChip.textContent = `${s.lastRefresh}: ${fmtTs(state?.catalogAt, s)} · ${state?.models?.length ?? 0} ${s.models}`;
249
+ const models = state?.models ?? [];
250
+ const safeCount = models.filter((m) => m.safety?.allow).length;
251
+ stateChip.textContent = `${s.lastRefresh}: ${fmtTs(state?.catalogAt, s)} · ${safeCount}/${models.length} ${s.models} · ${
252
+ state?.safeOnly === false ? s.safeOnlyOff : s.safeOnlyOn
253
+ }`;
232
254
  currentChip.textContent = `${s.currentModel}: ${state?.model || '—'}`;
233
255
  }
234
256
 
257
+ /** 当前是否允许选这个模型:safeOnly 开时只放行安全清单。 */
258
+ function pickable(m) {
259
+ if (state?.safeOnly === false) return true;
260
+ return m.safety?.allow !== false;
261
+ }
262
+
235
263
  function renderTable() {
236
- const models = state?.models ?? [];
264
+ const all = state?.models ?? [];
265
+ const models = allBox.checked ? all : all.filter((m) => m.safety?.allow);
237
266
  table.clear(models.length ? undefined : s.empty);
238
267
  for (const m of models) {
239
268
  const mul = fmtMultiplier(m, s);
240
269
  const isCurrent = state.model === m.id;
270
+ const safety = m.safety ?? {};
271
+
241
272
  const nameCell = ui.h('div');
242
273
  nameCell.append(ui.h('div', null, m.name || m.id));
243
274
  const sub = ui.h('div', 'dim', m.id);
@@ -246,13 +277,28 @@ function mountModels(ctx) {
246
277
  nameCell.append(sub);
247
278
  if (m.description) nameCell.title = m.description;
248
279
 
280
+ const safePill = ui.pill(
281
+ safety.label ?? '—',
282
+ safety.allow ? (safety.caution ? 'plain' : 'on') : 'plain',
283
+ );
284
+ if (safety.reason) safePill.title = safety.reason;
285
+ if (!safety.allow) {
286
+ safePill.style.opacity = '0.6';
287
+ nameCell.style.opacity = '0.6';
288
+ }
289
+
249
290
  const mulPill = ui.pill(mul.text, mul.free ? 'on' : 'plain');
250
291
  if (mul.promo) mulPill.title = mul.promo;
251
292
 
293
+ const allowed = pickable(m);
252
294
  const btn = ui.button(isCurrent ? s.selected : s.select, {
253
295
  size: 'sm',
254
296
  variant: isCurrent ? 'plain' : 'primary',
255
297
  onClick: async () => {
298
+ if (!allowed) {
299
+ ui.toast(`${s.unsafePick}${safety.reason ? `:${safety.reason}` : ''}`, 'bad');
300
+ return;
301
+ }
256
302
  const off = ui.disable(btn);
257
303
  try {
258
304
  const r = await ctx.invoke('select', [m.id]);
@@ -272,9 +318,14 @@ function mountModels(ctx) {
272
318
  },
273
319
  });
274
320
  if (isCurrent) btn.disabled = true;
321
+ if (!allowed) {
322
+ btn.disabled = true;
323
+ btn.title = safety.reason ?? '';
324
+ }
275
325
 
276
326
  table.addRow([
277
327
  nameCell,
328
+ safePill,
278
329
  mulPill,
279
330
  m.contextWindow ? String(m.contextWindow) : '—',
280
331
  m.reasoning?.supportedEfforts?.join(' / ') ?? '—',
@@ -283,6 +334,11 @@ function mountModels(ctx) {
283
334
  }
284
335
  }
285
336
 
337
+ allBox.input.addEventListener('change', () => {
338
+ renderChips();
339
+ renderTable();
340
+ });
341
+
286
342
  async function load() {
287
343
  state = await ctx.invoke('state');
288
344
  renderChips();
package/src/world.js CHANGED
@@ -12,6 +12,7 @@ import { discoverCredentials, maskToken, resolveTokensWithKey } from './credenti
12
12
  import { detectWorkBuddy } from './wbproc.js';
13
13
  import { attachPromos, dailyWindowText, discountText, priceViewOf, promoActiveAt } from './promo.js';
14
14
  import { UsageLedger, summarize } from './usage.js';
15
+ import { DEFAULT_SAFE_MODEL, SAFE_MODEL_IDS, classifyModel, preferredSafeModel, safeGroupsOf } from './safety.js';
15
16
  import { backupPathOf, describeHostInjection, ensureHostInjection, findAppRoot, inspectHostInjection, removeHostInjection } from './host.js';
16
17
 
17
18
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
@@ -53,9 +54,15 @@ export const WB_TOOL_DECLS = [
53
54
  {
54
55
  name: 'wb_list_models',
55
56
  description:
56
- 'List the WorkBuddy models available to the signed-in account, each with its default credit multiplier (e.g. x0.79), context window and reasoning efforts. Read-only; costs nothing.',
57
+ 'List the WorkBuddy chat models that are safe to call (official public catalog only), each with its credit multiplier (e.g. x0.79, already including time-limited discounts), context window and reasoning efforts. Read-only; costs nothing. Pass all=true to also list models that are disabled, internal routing aliases, media/code models or delisted ones — those are not callable through this bridge while worlds.workbuddy.safeOnly is on.',
57
58
  tags: ['read'],
58
- parameters: { type: 'object', additionalProperties: false, properties: {} },
59
+ parameters: {
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ properties: {
63
+ all: { type: 'boolean', description: 'Also list models outside the safe list (shown with their risk reason).' },
64
+ },
65
+ },
59
66
  },
60
67
  {
61
68
  name: 'wb_select_model',
@@ -434,6 +441,37 @@ export class WorkBuddyWorld {
434
441
  return this.catalog.find((m) => m.id === id);
435
442
  }
436
443
 
444
+ // -------------------------------------------------------------------------
445
+ // 合规闸门:只放行 WorkBuddy 公开对话模型
446
+ // -------------------------------------------------------------------------
447
+
448
+ /** 目录里哪些模型进了安全清单(按目录顺序)。 */
449
+ safeModelIds() {
450
+ return this.catalog.filter((m) => classifyModel(m.id, m).allow).map((m) => m.id);
451
+ }
452
+
453
+ /** 单个模型的合规判定。 */
454
+ safetyOf(id) {
455
+ return classifyModel(id, this.findModel(id));
456
+ }
457
+
458
+ /** 把模型的合规判定挂进 models/state 的返回,供面板渲染。 */
459
+ describeModel(m) {
460
+ const safety = classifyModel(m.id, m);
461
+ return { ...m, safety, price: priceViewOf(m, Date.now()) };
462
+ }
463
+
464
+ /**
465
+ * wb_chat / wb_select_model 的准入检查。
466
+ * @returns {string|null} 拒绝时返回给模型看的说明,放行时 null。
467
+ */
468
+ rejectUnsafeModel(id) {
469
+ const safety = this.safetyOf(id);
470
+ if (safety.allow || this.cfg.safeOnly === false) return null;
471
+ const hint = '(workbuddy.safeOnly 已开;如确需使用,可在 World 配置里关掉它,风险自负)';
472
+ return `模型 ${id} 不在 WorkBuddy 安全清单内,判定为「${safety.label}」:${safety.reason}。${hint}`;
473
+ }
474
+
437
475
  // -------------------------------------------------------------------------
438
476
  // World 契约
439
477
  // -------------------------------------------------------------------------
@@ -454,6 +492,7 @@ export class WorkBuddyWorld {
454
492
  'workbuddy.authed': this.tokens ? 'yes' : 'no',
455
493
  'workbuddy.priority': this.preferOn ? 'on' : 'off',
456
494
  'workbuddy.process': this.proc.running ? 'running' : 'stopped',
495
+ 'workbuddy.safeOnly': this.cfg.safeOnly === false ? 'off' : 'on',
457
496
  'workbuddy.todayCredit': String(Math.round(this.today.credit * 100) / 100),
458
497
  'workbuddy.todayTokens': String(this.today.prompt + this.today.completion),
459
498
  };
@@ -463,7 +502,7 @@ export class WorkBuddyWorld {
463
502
  return [
464
503
  {
465
504
  ...WB_TOOL_DECLS[0],
466
- handler: async () => this.toolListModels(),
505
+ handler: async (args) => this.toolListModels({ all: args?.all === true }),
467
506
  },
468
507
  {
469
508
  ...WB_TOOL_DECLS[1],
@@ -581,10 +620,12 @@ export class WorkBuddyWorld {
581
620
  // 工具实现(回执只陈述可确认的事实)
582
621
  // -------------------------------------------------------------------------
583
622
 
584
- toolListModels() {
623
+ toolListModels({ all = false } = {}) {
585
624
  if (this.catalog.length === 0) return `WorkBuddy 模型目录为空。${this.lastError || '请先在面板刷新目录。'}`;
586
625
  const now = Date.now();
587
- const lines = this.catalog.map((m) => {
626
+ const safeOnly = this.cfg.safeOnly !== false;
627
+
628
+ const describe = (m) => {
588
629
  const parts = [`- ${m.id}`];
589
630
  if (m.name !== m.id) parts.push(`(${m.name})`);
590
631
  const price = priceViewOf(m, now);
@@ -602,10 +643,24 @@ export class WorkBuddyWorld {
602
643
  if (m.reasoning?.supportedEfforts) parts.push(`推理档 ${m.reasoning.supportedEfforts.join('/')}`);
603
644
  if (m.supportsImages) parts.push('支持图片输入');
604
645
  return parts.join(',');
646
+ };
647
+
648
+ const safeModels = this.catalog.filter((m) => classifyModel(m.id, m).allow);
649
+ const otherModels = this.catalog.filter((m) => !classifyModel(m.id, m).allow);
650
+
651
+ const groups = safeGroupsOf(safeModels.map((m) => m.id));
652
+ const groupBlocks = groups.map((g) => {
653
+ const rows = g.ids.map((id) => describe(this.findModel(id))).filter(Boolean);
654
+ return `【${g.label}】\n${rows.join('\n')}`;
605
655
  });
656
+
606
657
  const current = this.cfg.model
607
658
  ? `当前选择:${this.cfg.model}(此刻 ${this.selectedMultiplier() ?? '倍率未知'})\n`
608
659
  : '当前未选择模型。\n';
660
+ const policy = safeOnly
661
+ ? `安全清单:${safeModels.length}/${this.catalog.length} 个模型可调用(WorkBuddy 公开目录里的对话模型;wb_list_models 传 all=true 可看其余项及原因)。\n`
662
+ : `注意:safeOnly 已关闭,${this.catalog.length} 个模型全部可调用——其中含 disabled/别名/媒体等 WorkBuddy 不建议外部调用的项。\n`;
663
+
609
664
  const promoLines = this.promos
610
665
  .filter((p) => Array.isArray(p.modelIds) && p.modelIds.length > 0)
611
666
  .map((p) => {
@@ -616,12 +671,28 @@ export class WorkBuddyWorld {
616
671
  return `- ${p.id}:${discountText(p)}${when ? `,${when}` : ''},影响 ${p.modelIds.join('/')}(${flag})`;
617
672
  });
618
673
  const promoBlock = promoLines.length > 0 ? `\n限时优惠:\n${promoLines.join('\n')}` : '';
619
- return current + lines.join('\n') + promoBlock;
674
+
675
+ if (!all) {
676
+ const body = groupBlocks.length > 0 ? groupBlocks.join('\n') : '(安全清单里的模型当前都不在目录中,先刷新目录。)';
677
+ return `${current}${policy}\n${body}${promoBlock}`;
678
+ }
679
+
680
+ const otherRows = otherModels.map((m) => {
681
+ const safety = classifyModel(m.id, m);
682
+ return `- ${m.id}:${safety.label}——${safety.reason}`;
683
+ });
684
+ const otherBlock =
685
+ otherRows.length > 0
686
+ ? `\n\n其余 ${otherRows.length} 个模型(不建议通过外部软件调用${safeOnly ? ',当前会被拒绝' : ',但 safeOnly 已关闭'}):\n${otherRows.join('\n')}`
687
+ : '';
688
+ return `${current}${policy}\n${groupBlocks.join('\n')}${otherBlock}${promoBlock}`;
620
689
  }
621
690
 
622
691
  async toolSelectModel(id) {
623
692
  const model = this.findModel(id);
624
693
  if (!model) return { text: `目录里没有模型「${id}」。先用 wb_list_models 看可用 id。`, failed: true };
694
+ const reject = this.rejectUnsafeModel(id);
695
+ if (reject) return { text: reject, failed: true };
625
696
  this.cfg.model = id;
626
697
  // 框架的 ctx.persist(patch) 必须带 patch;只写本 World 段内的字段。
627
698
  this.ctx.persist({ model: id });
@@ -644,7 +715,15 @@ export class WorkBuddyWorld {
644
715
  return { text: '未检测到 WorkBuddy 主进程:请先启动 WorkBuddy 桌面端再试。', failed: true };
645
716
  }
646
717
  const useId = model || this.cfg.model;
647
- if (!useId) return { text: '尚未选择模型;先 wb_list_models 再 wb_select_model。', failed: true };
718
+ if (!useId) {
719
+ const suggestion = preferredSafeModel(this.safeModelIds());
720
+ return {
721
+ text: `尚未选择模型;先 wb_list_models 再 wb_select_model(建议 ${suggestion})。`,
722
+ failed: true,
723
+ };
724
+ }
725
+ const reject = this.rejectUnsafeModel(useId);
726
+ if (reject) return { text: reject, failed: true };
648
727
  const tokens = await this.ensureTokens();
649
728
  if (!tokens) return { text: '未发现 WorkBuddy 登录态,无法调用。', failed: true };
650
729
  const messages = [];
@@ -837,6 +916,7 @@ export class WorkBuddyWorld {
837
916
  { name: 'workbuddy.authed', description: '是否已有可用登录态。' },
838
917
  { name: 'workbuddy.priority', description: '「中转站优先」开关:on / off。' },
839
918
  { name: 'workbuddy.process', description: 'WorkBuddy 主进程状态:running / stopped。' },
919
+ { name: 'workbuddy.safeOnly', description: '安全清单闸门:on(只放行公开对话模型)/ off。' },
840
920
  { name: 'workbuddy.todayCredit', description: '今日已消耗积分(本地记账)。' },
841
921
  { name: 'workbuddy.todayTokens', description: '今日 token 用量(本地记账)。' },
842
922
  ],
@@ -1034,7 +1114,8 @@ export class WorkBuddyWorld {
1034
1114
  model: this.cfg.model,
1035
1115
  catalogAt: this.catalogAt,
1036
1116
  lastError: this.lastError,
1037
- models: this.catalog.map((m) => ({ ...m, price: priceViewOf(m, Date.now()) })),
1117
+ safeOnly: this.cfg.safeOnly !== false,
1118
+ models: this.catalog.map((m) => this.describeModel(m)),
1038
1119
  prefer: this.preferOn,
1039
1120
  running: !!this.proc.running,
1040
1121
  mainPid: this.proc.mainPid,
@@ -1053,6 +1134,8 @@ export class WorkBuddyWorld {
1053
1134
  const id = String(args[0] ?? '');
1054
1135
  const found = this.findModel(id);
1055
1136
  if (!found) return { ok: false, error: `unknown model: ${id}` };
1137
+ const reject = this.rejectUnsafeModel(id);
1138
+ if (reject) return { ok: false, error: reject, unsafe: true };
1056
1139
  this.cfg.model = id;
1057
1140
  this.ctx.persist({ model: id });
1058
1141
  return { ok: true, model: id, multiplier: found.creditMultiplier ?? null };