dsh-cmtoken-oauth 1.0.3 → 1.4.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
@@ -7,8 +7,14 @@ DeepSeek Harness(DSH)插件:在 **DSH 设置页**内一键认证广东移
7
7
  - **一键认证**:基于 OAuth 2.0 Device Authorization Grant(RFC 8628)+ PKCE (S256),扫码或输入用户码即可完成,无需配置脚本。
8
8
  - **内联二维码**:认证流程内置 QR 生成(零依赖),无需额外安装二维码工具。
9
9
  - **自动发现模型**:认证成功后自动拉取可用模型列表,写入 `llm-pi-ai` 的 `providers.cmtoken` 配置(merge,不影响其他 provider)。
10
+ - **原生视觉**:视觉模型(Qwen3.8 27B / Qwen3.x / Kimi K2.x 非 code / MiniMax M3)写入配置时自动声明 `input: ["text","image"]` 模态,DSH 直接放行图片输入(经持久附件服务直达模型),粘贴/拖入图片即可用。
11
+ - **粘贴/拖图转路径(普通模型直接收图)**:纯文本模型(如 deepseekv4-flash)贴图/拖图时,client 端拦截并把图片存到 `~/.dsh/cmtoken-pastes/`,`[Image: <路径>]` 直接进入输入框——不再弹「当前模型不支持图片」。识图延迟到模型按需调用 `cmtoken_read_image` 工具时完成,不看图不花识图调用;同一图片按内容哈希缓存路径。视觉模型不接管,保留原生缩略图体验。
12
+ - **视觉回退工具**:注册 `cmtoken_read_image` 工具——任何模型把图片路径(本地路径或 http(s) URL)交给工具,host 侧自动选用优先级最高的视觉模型识图,返回结构化取证(概述 / 全文转写 / 版面 / 语义 / 不确定项)。无需外部识图服务,凭据与配额都在 cmtoken 体系内。
13
+ - **可选包装路由(默认关闭)**:profile 插件 config 设 `visionRoute: true` 可注册 `cmtoken-vision` 分组——把纯文本模型镜像成声明图片输入的条目,贴图以真实图片气泡进入会话、请求时自动转证据文本。需要图片气泡原样展示的会话可开启。
14
+ - **自动配置上下文**:按模型名称关键词自动匹配各模型的上下文与最大输出长度(DeepSeek V4 / MiniMax M3 / Qwen / GLM / Kimi / MiniMax 2.x / hy 等),无需手动填写。
10
15
  - **安全存储**:token 存入 DSH 凭据服务(OS keyring / env),不落盘明文。
11
16
  - **自动续期 / 注销**:令牌临近过期自动 refresh;可随时注销清空凭据。
17
+ - **订购算力豆套餐**:设置页内一键跳转广东移动 H5 订购页,直达算力豆套餐购买。
12
18
 
13
19
  零第三方运行时依赖(仅使用 Node 内置模块)。
14
20
 
@@ -33,8 +39,10 @@ dsh plugin --profile web add github:your-username/dsh-cmtoken-oauth
33
39
 
34
40
  1. 打开 DSH Web → 设置(左下角齿轮)→ 左侧导航选择「**cmtoken 认证**」。
35
41
  2. 点击「开始一键认证」→ 用手机扫描二维码(或打开链接输入用户码),在广东移动 IDaaS 页面确认授权。
36
- 3. 插件自动完成:发现模型 → 写入 `llm-pi-ai.providers.cmtoken` → 保存凭据。
37
- 4. DSH 的模型选择器中选择 cmtoken 模型即可使用。
42
+ 3. 插件自动完成:发现模型 → 按名称配置各模型上下文 → 写入 `llm-pi-ai.providers.cmtoken` → 保存凭据。
43
+ 4. 套餐不足时,点设置页里的「**订购算力豆套餐**」跳转到广东移动 H5 订购页购买。
44
+ 5. 在 DSH 的模型选择器中选择 cmtoken 模型即可使用。
45
+ 6. 视觉,日常零配置:**任何 cmtoken 模型直接粘贴图片**——视觉模型(Qwen3.8 / Qwen3.x / Kimi K2.x / MiniMax M3)原生收图;纯文本模型贴图时自动经视觉模型识读为文字证据进入输入框(首次粘贴前聚焦一次输入框可预热判定)。也可在任何模型下把图片路径给助手调用 `cmtoken_read_image` 工具。
38
46
 
39
47
  ## 数据与配置
40
48
 
@@ -45,12 +53,17 @@ dsh plugin --profile web add github:your-username/dsh-cmtoken-oauth
45
53
  | 模型发现端点 | `GET https://maas.gd.chinamobile.com:36007/ai/uifm/open/v1/models` |
46
54
  | 凭据引用 | `CMTOKEN_API_KEY` / `CMTOKEN_REFRESH_TOKEN` / `CMTOKEN_EXPIRES` |
47
55
  | 写配置 | `llm-pi-ai` 设置 namespace 的 `providers.cmtoken`(merge,不覆盖其他 provider) |
56
+ | 视觉模态 | 模型写入时声明 `input`:qwen38 / qwen3.x / kimi k2.x(非 code)/ minimax-m3 → `["text","image"]`,其余 `["text"]` |
57
+ | 粘贴转路径 | `GET/POST /cmtoken-oauth/paste`(client 拦截纯文本模型贴图/拖图,host 落盘 `~/.dsh/cmtoken-pastes/` 返回路径;7 天自动清理) |
58
+ | 视觉优先级 | qwen38 > qwen3.x > kimi k2.x(非 code)> minimax-m3;识图走 `{baseURL}/chat/completions` |
59
+ | 视觉回退工具 | `cmtoken_read_image` 工具;输入本地路径或 http(s) URL |
60
+ | 包装路由(可选) | config `visionRoute: true` 启用 `cmtoken-vision` 分组(默认关闭) |
48
61
 
49
62
  ## 开发
50
63
 
51
64
  ```text
52
- lib/index.js host:OAuth 核心(PKCE / 设备码 / 轮询 / 刷新 / 模型发现)+ RPC
53
- lib/client.js web:设置页「cmtoken 认证」向导(二维码 + 轮询 + 完成)
65
+ lib/index.js host:OAuth 核心(PKCE / 设备码 / 轮询 / 刷新 / 模型发现)+ RPC + 粘贴识图路由 + cmtoken_read_image 工具 + 可选 cmtoken-vision 包装路由
66
+ lib/client.js web:设置页「cmtoken 认证」向导(视觉模型标 👁)+ 粘贴识图拦截
54
67
  lib/qr-core.cjs 内联 QR 编码器(输出 SVG)
55
68
  ```
56
69
 
package/lib/client.js CHANGED
@@ -12,6 +12,7 @@ window.__ModuleLoader__.load({
12
12
 
13
13
  // ==================== 工具 ====================
14
14
  const CHANNEL = "/cmtoken-oauth";
15
+ const ORDER_URL = "https://wap.gd.10086.cn/ech/h5/token-prduct/index.html";
15
16
  function unwrap(r) { if (r && r.ok) return r.value; throw new Error(r && r.error ? r.error.message : "未知错误"); }
16
17
 
17
18
  // ==================== 样式 ====================
@@ -142,10 +143,13 @@ window.__ModuleLoader__.load({
142
143
  statusCard,
143
144
  react.createElement("div", { className: "co-ok" }, "✅ 认证成功!已发现 " + models.length + " 个模型并写入配置。"),
144
145
  models.length ? react.createElement("div", null,
145
- react.createElement("div", { className: "co-hint", style: { marginBottom: 6 } }, "可用模型:"),
146
- react.createElement("div", { className: "co-models" }, models.map((m, i) => react.createElement(Pill, { key: i }, m.name || m.id)))
146
+ react.createElement("div", { className: "co-hint", style: { marginBottom: 6 } }, "可用模型(👁 支持视觉,可直接收图):"),
147
+ react.createElement("div", { className: "co-models" }, models.map((m, i) => {
148
+ const vision = Array.isArray(m.input) && m.input.indexOf("image") !== -1;
149
+ return react.createElement(Pill, { key: i }, (m.name || m.id) + (vision ? " 👁" : ""));
150
+ }))
147
151
  ) : null,
148
- react.createElement("p", null, "现在可以在 DSH 的模型选择器里选用 cmtoken 模型了。")
152
+ react.createElement("p", null, "现在可以在 DSH 的模型选择器里选用 cmtoken 模型了;非视觉模型可通过 cmtoken_read_image 工具读图。")
149
153
  );
150
154
  }
151
155
 
@@ -166,12 +170,186 @@ window.__ModuleLoader__.load({
166
170
  : react.createElement("p", null, "一键认证:点击下方按钮,用手机扫描二维码即可完成广东移动 MaaS 授权(无需手动填写 API Key)。"),
167
171
  react.createElement("div", { className: "co-row" },
168
172
  react.createElement(Button, { variant: "primary", size: "md", onClick: begin }, "开始一键认证"),
173
+ react.createElement(Button, { variant: "outline", size: "md", onClick: () => window.open(ORDER_URL, "_blank", "noopener,noreferrer") }, "订购算力豆套餐"),
169
174
  status && status.hasRefresh && status.expired ? react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "刷新令牌") : null
170
175
  ),
171
176
  error ? react.createElement("div", { className: "co-err" }, error) : null
172
177
  );
173
178
  }
174
179
 
180
+ // ==================== 粘贴识图(纯文本模型贴图即用) ====================
181
+ // 捕获阶段 paste 监听先于 composer 自身处理:剪贴板带图片且当前模型是
182
+ // 纯文本 cmtoken 模型(host 判定)时,抑制默认 intake(否则入口即被
183
+ // 「模型不支持图片」拒绝),把字节 POST 给 host 的识图路由,返回的
184
+ // 证据文本直接插入输入框。视觉模型不接管,保留原生缩略图体验。
185
+ function imageFilesOf(event) {
186
+ const items = event.clipboardData && event.clipboardData.items;
187
+ if (!items) return [];
188
+ const files = [];
189
+ for (let i = 0; i < items.length; i++) {
190
+ const item = items[i];
191
+ if (item.kind !== "file") continue;
192
+ const file = item.getAsFile();
193
+ if (file && /^image\//.test(file.type)) files.push(file);
194
+ }
195
+ return files;
196
+ }
197
+
198
+ function insertText(target, text) {
199
+ let el = target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT") ? target : document.activeElement;
200
+ if (!el || (el.tagName !== "TEXTAREA" && el.tagName !== "INPUT")) return;
201
+ el.focus();
202
+ let inserted = false;
203
+ try { inserted = document.execCommand("insertText", false, text); } catch { inserted = false; }
204
+ if (!inserted) {
205
+ const proto = el.tagName === "TEXTAREA" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
206
+ const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
207
+ setter.call(el, el.value + text);
208
+ el.dispatchEvent(new Event("input", { bubbles: true }));
209
+ }
210
+ }
211
+
212
+ function currentModelLabel() {
213
+ // aria-label 只是静态「选择模型」,不含当前模型名;模型名在按钮内部文本里,
214
+ // 拼成「选择模型,<模型名>」交给 host 解析(host 端按逗号取尾 + 剥「当前」)
215
+ const buttons = document.querySelectorAll("button");
216
+ for (let i = 0; i < buttons.length; i++) {
217
+ const label = buttons[i].getAttribute("aria-label") || "";
218
+ if (!/选择模型|select model|current model/i.test(label)) continue;
219
+ const text = (buttons[i].textContent || "").trim();
220
+ return text ? label + "," + text : label;
221
+ }
222
+ return "";
223
+ }
224
+
225
+ function findComposer() {
226
+ const el = document.activeElement;
227
+ if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT")) return el;
228
+ return document.querySelector("textarea") || el;
229
+ }
230
+
231
+ // 接管判定缓存:同一标签 60 秒内不重复询问;host 404(路由未注册)则彻底停用。
232
+ // 决策策略「零报错」:粘贴带图先同步拦截(纯文本模型吃到默认拒绝就是这次要
233
+ // 消灭的体验),异步拿到判定后——纯文本模型转证据文本;视觉模型把带原图
234
+ // 的粘贴事件重派给默认流程(标记 __cmtokenRevived 防循环),保留原生缩略图。
235
+ let pasteRouteAlive = true;
236
+ let pasteBusy = false;
237
+ const pasteVerdicts = {};
238
+ const PASTE_VERDICT_TTL_MS = 60000;
239
+
240
+ function verdictPromise(label) {
241
+ if (!label) return Promise.resolve(false);
242
+ const hit = pasteVerdicts[label];
243
+ if (hit && Date.now() - hit.at < PASTE_VERDICT_TTL_MS) return Promise.resolve(hit.takeover);
244
+ return fetch("/cmtoken-oauth/paste?model=" + encodeURIComponent(label))
245
+ .then((res) => {
246
+ if (res.status === 404) { pasteRouteAlive = false; return false; }
247
+ if (!res.ok) return false;
248
+ return res.json().then((body) => {
249
+ pasteVerdicts[label] = { at: Date.now(), takeover: !!body.takeover };
250
+ return !!body.takeover;
251
+ });
252
+ })
253
+ .catch(() => false);
254
+ }
255
+
256
+ function savePaste(file) {
257
+ return file.arrayBuffer()
258
+ .then((buffer) => fetch("/cmtoken-oauth/paste", { method: "POST", body: buffer }))
259
+ .then((res) => res.json().then((body) => {
260
+ if (!res.ok) throw new Error(body.error || ("保存图片失败 (" + res.status + ")"));
261
+ return body.path;
262
+ }));
263
+ }
264
+
265
+ // 图片插入输入框的形态:路径行。模型看到路径会用 cmtoken_read_image 工具按需识读。
266
+ function imageLine(path) {
267
+ return "[Image: " + path + "]\n";
268
+ }
269
+
270
+ function onPaste(event) {
271
+ if (!pasteRouteAlive || pasteBusy || event.__cmtokenRevived || !event.clipboardData) return;
272
+ const files = imageFilesOf(event);
273
+ if (files.length === 0) return;
274
+ const label = currentModelLabel();
275
+ if (!label) return;
276
+ // 同步拦截默认 intake(取消默认行为 + 阻断其他监听)
277
+ event.preventDefault();
278
+ event.stopImmediatePropagation();
279
+ pasteBusy = true;
280
+ const el = findComposer();
281
+ verdictPromise(label).then((takeover) => {
282
+ if (!takeover) {
283
+ // 视觉模型/未知模型:原样重派,走原生图片附件流程
284
+ const dt = new DataTransfer();
285
+ for (const file of files) dt.items.add(file);
286
+ const revived = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true });
287
+ revived.__cmtokenRevived = true;
288
+ (event.target && event.target.dispatchEvent ? event.target : document).dispatchEvent(revived);
289
+ return;
290
+ }
291
+ // 纯文本模型:存文件 → 路径插入输入框(识图交给 cmtoken_read_image 工具按需完成)
292
+ let chain = Promise.resolve();
293
+ for (const file of files) {
294
+ chain = chain.then(() => savePaste(file).then((path) => {
295
+ insertText(el, imageLine(path));
296
+ }).catch((e) => {
297
+ insertText(el, "[图片保存失败:" + String(e && e.message ? e.message : e) + "]\n");
298
+ }));
299
+ }
300
+ return chain;
301
+ }).finally(() => { pasteBusy = false; });
302
+ }
303
+
304
+ // 拖拽放图同样接管:附件栏的 drop 监听在 document 冒泡阶段,
305
+ // 我们的 capture 拦截先到;不接管时重派原事件走原生流程。
306
+ function onDrop(event) {
307
+ if (!pasteRouteAlive || pasteBusy || event.__cmtokenRevived || !event.dataTransfer) return;
308
+ let dtFiles = [];
309
+ try { dtFiles = Array.from(event.dataTransfer.files || []).filter((f) => /^image\//.test(f.type)); } catch (e) { dtFiles = []; }
310
+ if (dtFiles.length === 0) return;
311
+ const label = currentModelLabel();
312
+ if (!label) return;
313
+ event.preventDefault();
314
+ event.stopImmediatePropagation();
315
+ // 附件栏的 drop 监听被阻断后,其拖拽状态机收不到 reset;它在 window 上
316
+ // 还挂了 dragend → reset,手动派发一个让「拖动图片到此处」叠加层立即收起
317
+ try { window.dispatchEvent(new Event("dragend")); } catch (e) { /* 忽略 */ }
318
+ pasteBusy = true;
319
+ const el = findComposer();
320
+ verdictPromise(label).then((takeover) => {
321
+ if (!takeover) {
322
+ const dt = new DataTransfer();
323
+ for (const f of dtFiles) dt.items.add(f);
324
+ const revived = new DragEvent("drop", { dataTransfer: dt, bubbles: true, cancelable: true });
325
+ revived.__cmtokenRevived = true;
326
+ (event.target && event.target.dispatchEvent ? event.target : document).dispatchEvent(revived);
327
+ return;
328
+ }
329
+ let chain = Promise.resolve();
330
+ for (const file of dtFiles) {
331
+ chain = chain.then(() => savePaste(file).then((path) => {
332
+ insertText(el, imageLine(path));
333
+ }).catch((e) => {
334
+ insertText(el, "[图片保存失败:" + String(e && e.message ? e.message : e) + "]\n");
335
+ }));
336
+ }
337
+ return chain;
338
+ }).finally(() => { pasteBusy = false; });
339
+ }
340
+
341
+ // composer 聚焦时预取判定,粘贴时少等一次网络往返
342
+ function onFocusIn() {
343
+ if (pasteRouteAlive) verdictPromise(currentModelLabel());
344
+ }
345
+
346
+ if (typeof document !== "undefined" && !window.__cmtokenPasteHook) {
347
+ window.__cmtokenPasteHook = true;
348
+ document.addEventListener("paste", onPaste, true);
349
+ document.addEventListener("drop", onDrop, true);
350
+ document.addEventListener("focusin", onFocusIn, true);
351
+ }
352
+
175
353
  // ==================== 插件主体 ====================
176
354
  const NS = "cmtoken-oauth";
177
355
  const zh = { "nav": "cmtoken 认证" };
package/lib/index.js CHANGED
@@ -5,6 +5,8 @@
5
5
  // 写入 llm-pi-ai provider 配置 + 凭据(OS keyring / env)。
6
6
  // ============================================================
7
7
  import { randomBytes, randomUUID, createHash } from 'node:crypto';
8
+ import { readFile, writeFile, mkdir, readdir, stat, unlink } from 'node:fs/promises';
9
+ import { join } from 'node:path';
8
10
  import os from 'node:os';
9
11
  import { createRequire } from 'node:module';
10
12
  import z from '@deepseek-ai/schemastery';
@@ -15,7 +17,7 @@ const require2 = createRequire(import.meta.url);
15
17
  const { qrSvg } = require2('./qr-core.cjs');
16
18
 
17
19
  export const name = 'dsh-cmtoken-oauth';
18
- export const inject = ['connection', 'settings', 'credentials'];
20
+ export const inject = ['connection', 'settings', 'credentials', 'tools', 'llm', 'attachments'];
19
21
 
20
22
  export const Config = z.object({});
21
23
 
@@ -129,6 +131,61 @@ async function refreshToken(refresh) {
129
131
  };
130
132
  }
131
133
 
134
+ // 根据模型名称关键词匹配「输入(上下文)/ 输出(最大生成)」规格。
135
+ // 规格优先级:已知关键词匹配 > 发现接口自带值 > 通用默认值。
136
+ const MODEL_SPECS = [
137
+ // DeepSeek V4 系列:deepseek-v4-pro / deepseek-v4-flash
138
+ { match: (n) => n.includes('deepseek') && n.includes('v4'), contextWindow: 1_048_576, maxTokens: 393_216 },
139
+ // MiniMax-M3(支持图片)
140
+ { match: (n) => n.includes('minimax') && /m3/.test(n), contextWindow: 1_048_576, maxTokens: 131_072 },
141
+ // Qwen 开源系列:qwen36-35b / qwen38-27b(支持图片)
142
+ { match: (n) => n.includes('qwen36') || n.includes('qwen38'), contextWindow: 262_144, maxTokens: 65_536 },
143
+ // Qwen 闭源系列:Qwen3.x-flash / plus / max(支持图片)
144
+ { match: (n) => n.includes('qwen'), contextWindow: 1_048_576, maxTokens: 65_536 },
145
+ // 智谱 GLM:glm-5.2 / glm-5.3
146
+ { match: (n) => n.includes('glm'), contextWindow: 1_048_576, maxTokens: 131_072 },
147
+ // MiniMax 2.x 系列:M2.7-highspeed / MiniMax M2.5
148
+ { match: (n) => n.startsWith('m2') || (n.includes('minimax') && /m2/.test(n)), contextWindow: 204_800, maxTokens: 65_536 },
149
+ // hy3 / hy3-preview 等
150
+ { match: (n) => /hy/.test(n), contextWindow: 262_144, maxTokens: 65_536 },
151
+ ];
152
+
153
+ const DEFAULT_MODEL_SPEC = { contextWindow: 262_144, maxTokens: 65_536 };
154
+
155
+ function modelSpecFor(id) {
156
+ const n = String(id).toLowerCase().replace(/[-_\s.]+/g, '');
157
+ for (const spec of MODEL_SPECS) {
158
+ if (spec.match(n)) return spec;
159
+ }
160
+ return DEFAULT_MODEL_SPEC;
161
+ }
162
+
163
+ // ---------------- 视觉(图片)能力判定与回退优先级 ----------------
164
+ // 视觉模型优先级(值越小越优先,0 表示不支持图片):
165
+ // 1) Qwen3.8 27B(qwen38-27b 等)—— 最优先
166
+ // 2) Qwen3.x 系列(qwen3.6、qwen3-plus/flash/max 等)
167
+ // 3) Kimi K2.x 非 code 系列(kimi-k2.5 / kimi-k2.6,原生多模态)
168
+ // 4) MiniMax M3(minimax-m3 等)
169
+ function visionRank(id) {
170
+ const n = String(id).toLowerCase().replace(/[-_\s.]+/g, '');
171
+ if (n.includes('qwen38')) return 1; // Qwen3.8 27B
172
+ if (n.includes('qwen3')) return 2; // Qwen3.6 / Qwen3.x 系列
173
+ if (n.includes('kimik2') && !n.includes('code')) return 3; // kimi-k2.5 / k2.x(非 code,原生多模态)
174
+ if (n.includes('minimax') && /m3/.test(n)) return 4; // MiniMax M3
175
+ return 0;
176
+ }
177
+
178
+ function supportsVision(id) {
179
+ return visionRank(id) > 0;
180
+ }
181
+
182
+ // 模型在 llm-pi-ai 配置里的 input 模态;视觉模型声明 ['text','image'],纯文本声明 ['text']。
183
+ // DSH(dsh-llm-pi-ai)按该声明决定图片准入:缺失按目录默认(通常纯文本),图片请求会被
184
+ // 以 UNSUPPORTED_CONTENT 拒绝,故必须显式声明而非省略。
185
+ function modelInputModalities(id) {
186
+ return supportsVision(id) ? ['text', 'image'] : ['text'];
187
+ }
188
+
132
189
  async function fetchModels(access, discoveryUrl) {
133
190
  const res = await fetch(discoveryUrl || DISCOVERY_URL, {
134
191
  signal: AbortSignal.timeout(15_000),
@@ -143,12 +200,16 @@ async function fetchModels(access, discoveryUrl) {
143
200
  }
144
201
  const json = await res.json();
145
202
  const list = Array.isArray(json.models) ? json.models : Array.isArray(json.data) ? json.data : [];
146
- return list.map((m) => ({
147
- id: m.id,
148
- name: m.name ?? m.id,
149
- contextWindow: m.contextWindow ?? 192_000,
150
- maxTokens: m.maxTokens ?? 8192,
151
- }));
203
+ return list.map((m) => {
204
+ // 已知模型按名称关键词配置最大上下文;其余回退到发现接口值或通用默认
205
+ const spec = modelSpecFor(m.id);
206
+ return {
207
+ id: m.id,
208
+ name: m.name ?? m.id,
209
+ contextWindow: spec.contextWindow,
210
+ maxTokens: spec.maxTokens,
211
+ };
212
+ });
152
213
  }
153
214
 
154
215
  function errorResult(e) {
@@ -207,7 +268,15 @@ function registerRpc(ctx) {
207
268
  models = [{ id: 'deepseekv4-flash', name: 'deepseekv4-flash', contextWindow: 192000, maxTokens: 8192 }];
208
269
  }
209
270
  await writeConfigAndCreds(ctx, token, models);
210
- return { ok: true, value: { status: 'ok', models, baseURL: token.resourceUrl || DEFAULT_API_BASE } };
271
+ return {
272
+ ok: true,
273
+ value: {
274
+ status: 'ok',
275
+ // input 模态一并返回,client 用它给视觉模型打标
276
+ models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
277
+ baseURL: token.resourceUrl || DEFAULT_API_BASE,
278
+ },
279
+ };
211
280
  }
212
281
 
213
282
  // ---- 认证状态 ----
@@ -273,7 +342,15 @@ async function writeConfigAndCreds(ctx, token, models) {
273
342
  apiKeyEnv: API_KEY_ENV,
274
343
  api: 'openai-completions',
275
344
  baseURL: token.resourceUrl || DEFAULT_API_BASE,
276
- models: models.map((m) => ({ id: m.id, name: m.name, contextWindow: m.contextWindow, maxTokens: m.maxTokens })),
345
+ models: models.map((m) => ({
346
+ id: m.id,
347
+ name: m.name,
348
+ contextWindow: m.contextWindow,
349
+ maxTokens: m.maxTokens,
350
+ // input 模态显式声明:视觉模型 ['text','image'],纯文本 ['text']。
351
+ // 声明后 DSH 原生放行图片(经持久附件服务直达模型),缺失则一律拒绝。
352
+ input: m.input ?? modelInputModalities(m.id),
353
+ })),
277
354
  },
278
355
  },
279
356
  };
@@ -333,18 +410,594 @@ function startAutoRefresh(ctx) {
333
410
  return () => clearInterval(timer);
334
411
  }
335
412
 
413
+ // ---------------- 视觉回退:cmtoken_read_image 工具 ----------------
414
+ // DSH 的图片准入以模型声明的 input 模态为准:文本模型收图直接报 UNSUPPORTED_CONTENT,
415
+ // rc8 没有原生的"改投视觉模型"路由。本工具补上这条通路:任何在用模型把图片路径交给
416
+ // 工具,host 侧按优先级(qwen38 > qwen3.x > minimax-m3)选 cmtoken 视觉模型完成识读,
417
+ // 返回结构化取证结果——纯文本模型由此获得视觉。与 modlens 的外置识图引擎不同,
418
+ // 引擎就是 cmtoken 自己的模型,凭据/配额都在本插件体系内,零外部依赖。
419
+
420
+ const VISION_TOOL_NAME = 'cmtoken_read_image';
421
+ const VISION_TOOL_TIMEOUT_MS = 180_000;
422
+ const VISION_MAX_IMAGE_BYTES = 15 * 1024 * 1024;
423
+
424
+ // 魔数签名:只放行 MaaS 通道常见的四种光栅格式,其余(heic/bmp 等)明确报错而非让服务端猜
425
+ const MEDIA_SIGNATURES = [
426
+ { type: 'image/png', match: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
427
+ { type: 'image/jpeg', match: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
428
+ { type: 'image/gif', match: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 },
429
+ { type: 'image/webp', match: (b) => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50 },
430
+ ];
431
+
432
+ function detectMediaType(bytes) {
433
+ for (const sig of MEDIA_SIGNATURES) {
434
+ if (bytes.length >= 12 && sig.match(bytes)) return sig.type;
435
+ }
436
+ return undefined;
437
+ }
438
+
439
+ const EVIDENCE_PROMPT = [
440
+ '你是图像取证助手。请仔细阅读这张图片,输出严格的 JSON(不要 markdown 代码块、不要任何多余文字),字段如下:',
441
+ '{"summary":"一两句话概述图片内容","ocr_full_text":"图中所有可读文字的完整转写,保持阅读顺序,没有文字则为空字符串","layout":[{"type":"区域类型,如 title/paragraph/list/table/chart/form/code/button","reading_order":1,"text":"该区域文字或内容摘要"}],"semantics":{"scene":"场景描述","entities":[{"name":"实体名","type":"类型"}]},"uncertainty":["看不清或不确定的地方,没有则为空数组"]}',
442
+ '只输出 JSON 本身。',
443
+ ].join('\n');
444
+
445
+ // 工具输出 schema:rc8 的 tools.register 强制要求 output { schema, render },
446
+ // 缺 output 会在注册时抛 TypeError(工具静默不生效)。形状对齐 EVIDENCE_PROMPT 的字段,
447
+ // 外层多一个 model(实际识图的模型 id)。
448
+ const VISION_OUTPUT_SCHEMA = {
449
+ type: 'object',
450
+ properties: {
451
+ model: { type: 'string', description: '实际完成识图的 cmtoken 视觉模型 id' },
452
+ summary: { type: 'string' },
453
+ ocr_full_text: { type: 'string' },
454
+ layout: {
455
+ type: 'array',
456
+ items: {
457
+ type: 'object',
458
+ properties: {
459
+ type: { type: 'string' },
460
+ reading_order: { type: 'number' },
461
+ text: { type: 'string' },
462
+ },
463
+ required: ['type', 'reading_order', 'text'],
464
+ },
465
+ },
466
+ semantics: {
467
+ type: 'object',
468
+ properties: {
469
+ scene: { type: 'string' },
470
+ entities: {
471
+ type: 'array',
472
+ items: {
473
+ type: 'object',
474
+ properties: { name: { type: 'string' }, type: { type: 'string' } },
475
+ required: ['name', 'type'],
476
+ },
477
+ },
478
+ },
479
+ required: ['scene'],
480
+ },
481
+ uncertainty: { type: 'array', items: { type: 'string' } },
482
+ },
483
+ required: ['model', 'summary'],
484
+ };
485
+
486
+ /** 取证结果 → 可读多行文本(工具 render 与视觉回退 provider 共用)。 */
487
+ function evidenceTextLines(value) {
488
+ const v = value ?? {};
489
+ const lines = [];
490
+ lines.push(`识图模型:${v.model ?? '(unknown)'}`);
491
+ lines.push(`概述:${v.summary ?? ''}`);
492
+ if (typeof v.ocr_full_text === 'string' && v.ocr_full_text !== '') {
493
+ lines.push(`全文转写:\n${v.ocr_full_text}`);
494
+ }
495
+ if (Array.isArray(v.layout) && v.layout.length > 0) {
496
+ lines.push('版面:');
497
+ for (const r of [...v.layout].sort((a, b) => (a?.reading_order ?? 0) - (b?.reading_order ?? 0))) {
498
+ lines.push(` [${r?.type ?? '?'}] ${r?.text ?? ''}`);
499
+ }
500
+ }
501
+ const scene = v.semantics?.scene;
502
+ if (typeof scene === 'string' && scene !== '') lines.push(`场景:${scene}`);
503
+ const entities = v.semantics?.entities;
504
+ if (Array.isArray(entities) && entities.length > 0) {
505
+ lines.push(`实体:${entities.map((e) => `${e?.name ?? '?'}(${e?.type ?? '?'})`).join('、')}`);
506
+ }
507
+ if (Array.isArray(v.uncertainty) && v.uncertainty.length > 0) {
508
+ lines.push(`不确定:${v.uncertainty.join(';')}`);
509
+ }
510
+ return lines.join('\n');
511
+ }
512
+
513
+ /** 工具结果 → 会话里的可读文本块(对齐 rc8 render 契约:返回 content 块数组)。 */
514
+ function renderEvidence(_args, value) {
515
+ return [{ type: 'text', text: evidenceTextLines(value) }];
516
+ }
517
+
518
+ /** 从已认证写入的 llm-pi-ai 配置里选出优先级最高的视觉模型;未认证返回 undefined。 */
519
+ function resolveVisionModel(ctx) {
520
+ const section = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'));
521
+ const models = section?.providers?.cmtoken?.models ?? [];
522
+ let best;
523
+ for (const m of models) {
524
+ const rank = visionRank(m.id);
525
+ if (rank > 0 && (!best || rank < best.rank)) best = { rank, id: m.id };
526
+ }
527
+ return best?.id;
528
+ }
529
+
530
+ /** 读取图片字节:http(s) URL 或本地路径。 */
531
+ async function loadImageBytes(source, signal) {
532
+ if (/^https?:\/\//i.test(source)) {
533
+ const res = await fetch(source, { signal, headers: { Accept: 'image/*' } });
534
+ if (!res.ok) throw new Error(`下载图片失败 HTTP ${res.status}`);
535
+ return new Uint8Array(await res.arrayBuffer());
536
+ }
537
+ try {
538
+ return new Uint8Array(await readFile(source));
539
+ } catch (e) {
540
+ throw new Error(`读取图片文件失败: ${e?.code ?? e?.message ?? e}`);
541
+ }
542
+ }
543
+
544
+ /** 视觉模型输出 → 结构化取证;JSON 解析失败时整段文本降级为 summary。 */
545
+ function parseEvidence(content) {
546
+ const raw = String(content ?? '').trim();
547
+ const fenced = raw.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
548
+ const candidate = fenced ? fenced[1] : raw;
549
+ const start = candidate.indexOf('{');
550
+ const end = candidate.lastIndexOf('}');
551
+ if (start >= 0 && end > start) {
552
+ try {
553
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
554
+ if (parsed && typeof parsed === 'object') return parsed;
555
+ } catch { /* 降级为纯文本 */ }
556
+ }
557
+ return { summary: raw };
558
+ }
559
+
560
+ /** 调用 cmtoken 视觉模型识图(OpenAI 兼容 chat completions + data URI)。 */
561
+ async function describeImage({ baseURL, apiKey, model, dataUri, focus, signal }) {
562
+ let res;
563
+ try {
564
+ res = await fetch(`${baseURL.replace(/\/+$/, '')}/chat/completions`, {
565
+ method: 'POST',
566
+ signal,
567
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${apiKey}` },
568
+ body: JSON.stringify({
569
+ model,
570
+ messages: [{
571
+ role: 'user',
572
+ content: [
573
+ { type: 'text', text: focus ? `${EVIDENCE_PROMPT}\n重点关注:${focus}` : EVIDENCE_PROMPT },
574
+ { type: 'image_url', image_url: { url: dataUri } },
575
+ ],
576
+ }],
577
+ max_tokens: 4096,
578
+ }),
579
+ });
580
+ } catch (e) {
581
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') throw new Error('视觉模型识图超时/已取消');
582
+ throw new Error(`视觉模型请求失败: ${e?.message ?? e}`);
583
+ }
584
+ if (!res.ok) {
585
+ const text = (await res.text().catch(() => '')).slice(0, 300);
586
+ if (res.status === 401) throw new Error('cmtoken 访问令牌无效,请在设置 → cmtoken 认证里重新认证');
587
+ if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) throw new Error('订阅错误:' + text);
588
+ throw new Error(`视觉模型识图失败 ${res.status}: ${text}`);
589
+ }
590
+ const json = await res.json().catch(() => undefined);
591
+ const content = json?.choices?.[0]?.message?.content;
592
+ if (typeof content !== 'string' || content.trim() === '') {
593
+ throw new Error('视觉模型未返回识图内容' + (json?.error ? `: ${JSON.stringify(json.error).slice(0, 200)}` : ''));
594
+ }
595
+ return parseEvidence(content);
596
+ }
597
+
598
+ function registerVisionTool(ctx) {
599
+ const tool = {
600
+ name: VISION_TOOL_NAME,
601
+ description:
602
+ '通过 cmtoken 视觉模型读取图片。当对话中出现当前模型无法查看的图片时使用,典型形态是 [Image: <本地路径>] 行(粘贴/拖入的图片由插件自动落盘)或裸的本地绝对路径、http(s) URL:截图、照片、图表、扫描件等。返回结构化取证结果(summary、ocr_full_text、layout、semantics、uncertainty),请引用证据而不是猜测。需要已完成 cmtoken 一键认证。',
603
+ parameters: {
604
+ type: 'object',
605
+ properties: {
606
+ path: { type: 'string', description: '图片的本地绝对路径或 http(s) URL' },
607
+ prompt: { type: 'string', description: '可选的额外关注点(如"重点看坐标轴标签")' },
608
+ },
609
+ required: ['path'],
610
+ },
611
+ timeoutMs: VISION_TOOL_TIMEOUT_MS,
612
+ isConcurrencySafe: () => true,
613
+ output: {
614
+ schema: VISION_OUTPUT_SCHEMA,
615
+ render: renderEvidence,
616
+ },
617
+ async execute(args, exec) {
618
+ const source = typeof args?.path === 'string' ? args.path.trim() : '';
619
+ if (!source) throw new Error(`${VISION_TOOL_NAME} 需要非空字符串 "path"(本地路径或 http(s) URL)`);
620
+
621
+ const model = resolveVisionModel(ctx);
622
+ if (!model) throw new Error('cmtoken 未配置视觉模型:请先在 DSH 设置 → cmtoken 认证完成一键认证');
623
+
624
+ // 识图前先按需刷新令牌(复用自动续期逻辑),避免用过期 access 调用
625
+ await autoRefresh(ctx).catch(() => {});
626
+ const key = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
627
+ if (!key) throw new Error('cmtoken 访问令牌不可用:请先在 DSH 设置 → cmtoken 认证完成一键认证');
628
+
629
+ const bytes = await loadImageBytes(source, exec?.signal);
630
+ if (bytes.length === 0) throw new Error('图片内容为空');
631
+ if (bytes.length > VISION_MAX_IMAGE_BYTES) {
632
+ throw new Error(`图片过大(${(bytes.length / 1048576).toFixed(1)}MB),上限 ${VISION_MAX_IMAGE_BYTES / 1048576}MB`);
633
+ }
634
+ const mediaType = detectMediaType(bytes);
635
+ if (!mediaType) throw new Error('不支持的图片格式(支持 PNG / JPEG / GIF / WebP)');
636
+
637
+ const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
638
+ if (exec?.signal) signals.push(exec.signal);
639
+ const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || DEFAULT_API_BASE;
640
+ const evidence = await describeImage({
641
+ baseURL,
642
+ apiKey: key.value,
643
+ model,
644
+ dataUri: `data:${mediaType};base64,${Buffer.from(bytes).toString('base64')}`,
645
+ focus: typeof args.prompt === 'string' && args.prompt.trim() ? args.prompt.trim() : undefined,
646
+ signal: AbortSignal.any(signals),
647
+ });
648
+ // 标注实际使用的模型,便于调用方区分证据来源与核算配额
649
+ return { model, ...evidence };
650
+ },
651
+ };
652
+ try {
653
+ ctx.tools.register(tool);
654
+ ctx.logger?.info?.(`[cmtoken-oauth] 视觉回退工具 ${VISION_TOOL_NAME} 已注册`);
655
+ } catch (e) {
656
+ // 注册失败(服务面变化/重名)只降级报错,不影响认证主功能
657
+ ctx.logger?.error?.(`[cmtoken-oauth] ${VISION_TOOL_NAME} 注册失败: ${String(e?.message ?? e)}`);
658
+ }
659
+ }
660
+
661
+ // ---------------- 视觉回退 provider(cmtoken-vision 包装路由) ----------------
662
+ // DSH 的图片准入以所选模型的 input 模态为准:纯文本模型在入口即被拒,
663
+ // 贴图永远到不了任何工具。本包装注册一个新 provider 路由 cmtoken-vision,
664
+ // 把 cmtoken 的纯文本模型镜像成声明图片输入的条目(名称带「(视觉回退)」);
665
+ // 请求时把消息里的图片块交给自家视觉模型识读(qwen38 > qwen3.x > minimax-m3),
666
+ // 以文本证据块替换后再委托上游文本模型——会话日志保留真实图片块(UI 原样显示),
667
+ // 仅出网请求携带证据文本。原生视觉模型不包装(自己能收图,回退反而降级)。
668
+
669
+ const VISION_PROVIDER_ID = 'cmtoken-vision';
670
+ const VISION_PROVIDER_NAME = 'cmtoken (视觉回退)';
671
+ const UPSTREAM_PROVIDER = 'cmtoken';
672
+ const EVIDENCE_CACHE_MAX = 32;
673
+ const EVIDENCE_FAILURE_COOLDOWN_MS = 60_000;
674
+
675
+ function contentHasImage(blocks) {
676
+ return (
677
+ Array.isArray(blocks) &&
678
+ blocks.some((b) => b?.type === 'image' || (b?.type === 'tool-result' && contentHasImage(b.content)))
679
+ );
680
+ }
681
+
682
+ async function convertBlocks(blocks, convertOne) {
683
+ const out = [];
684
+ for (const block of blocks) {
685
+ if (block?.type === 'image') {
686
+ out.push(await convertOne(block));
687
+ } else if (block?.type === 'tool-result' && contentHasImage(block.content)) {
688
+ out.push({ ...block, content: await convertBlocks(block.content, convertOne) });
689
+ } else {
690
+ out.push(block);
691
+ }
692
+ }
693
+ return out;
694
+ }
695
+
696
+ /** 跨回放稳定的缓存键:同一附件以不同键序序列化后仍命中同一证据。 */
697
+ function evidenceKey(value) {
698
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
699
+ if (Array.isArray(value)) return `[${value.map(evidenceKey).join(',')}]`;
700
+ const keys = Object.keys(value).sort();
701
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${evidenceKey(value[k])}`).join(',')}}`;
702
+ }
703
+
704
+ /**
705
+ * 图片块 → 证据文本块。不抛错:失败降级为说明性文本块。
706
+ * 同一附件的证据带缓存(同一历史每步都会重发,不缓存则每步重复识图);
707
+ * 失败按单调时钟冷却缓存,避免引擎故障时每步烧配额。
708
+ */
709
+ async function evidenceForBlock(ctx, block, signal, cache) {
710
+ const key = evidenceKey(block.attachment ?? block);
711
+ const hit = cache.get(key);
712
+ if (hit !== undefined) {
713
+ cache.delete(key);
714
+ cache.set(key, hit); // 刷新新近度(Map 按插入序淘汰)
715
+ if (hit.ok || performance.now() < hit.retryAfter) return hit.block;
716
+ cache.delete(key);
717
+ }
718
+ try {
719
+ const stored = await ctx.attachments.readImage(block.attachment, signal);
720
+ if (!stored?.data) throw new Error('附件服务未返回图片数据');
721
+ const mediaType = stored.ref?.mediaType ?? block.attachment?.mediaType;
722
+ if (typeof mediaType !== 'string' || !mediaType.startsWith('image/')) {
723
+ throw new Error(`不支持的附件媒体类型 ${mediaType ?? '(未声明)'}`);
724
+ }
725
+ const model = resolveVisionModel(ctx);
726
+ if (!model) throw new Error('cmtoken 未配置视觉模型,请先完成一键认证');
727
+ await autoRefresh(ctx).catch(() => {});
728
+ const keyRes = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
729
+ if (!keyRes) throw new Error('cmtoken 访问令牌不可用,请重新认证');
730
+ const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || DEFAULT_API_BASE;
731
+ const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
732
+ if (signal) signals.push(signal);
733
+ const evidence = await describeImage({
734
+ baseURL,
735
+ apiKey: keyRes.value,
736
+ model,
737
+ dataUri: `data:${mediaType};base64,${Buffer.from(stored.data).toString('base64')}`,
738
+ signal: AbortSignal.any(signals),
739
+ });
740
+ const textBlock = Object.freeze({
741
+ type: 'text',
742
+ text: `[粘贴/上传的图片,由 cmtoken 视觉模型 ${model} 识读]\n${evidenceTextLines({ model, ...evidence })}`,
743
+ });
744
+ cache.set(key, { ok: true, block: textBlock });
745
+ trimEvidenceCache(cache);
746
+ return textBlock;
747
+ } catch (e) {
748
+ const detail = String(e?.message ?? e).slice(0, 300);
749
+ ctx.logger?.warn?.('[cmtoken-oauth] 图片证据生成失败: ' + detail);
750
+ cache.set(key, { ok: false, retryAfter: performance.now() + EVIDENCE_FAILURE_COOLDOWN_MS, block: { type: 'text', text: `[图片识读失败:${detail}。请把失败告知用户,并建议在 DSH 设置 → cmtoken 认证里检查认证状态。]` } });
751
+ trimEvidenceCache(cache);
752
+ return cache.get(key).block;
753
+ }
754
+ }
755
+
756
+ function trimEvidenceCache(cache) {
757
+ while (cache.size > EVIDENCE_CACHE_MAX) cache.delete(cache.keys().next().value);
758
+ }
759
+
760
+ /** 出网前把包装路由产生的 assistant 轮次按上游重标:回放状态是 adapter 私有的。 */
761
+ function relabelUpstreamSource(messages) {
762
+ return messages.map((m) => (
763
+ m?.role === 'assistant' && m?.source?.kind === 'model' && m?.source?.provider === VISION_PROVIDER_ID
764
+ ? { ...m, source: { ...m.source, provider: UPSTREAM_PROVIDER } }
765
+ : m
766
+ ));
767
+ }
768
+
769
+ // ---------------- 粘贴/拖图转路径(普通模型直接收图) ----------------
770
+ // 普通分组里的纯文本模型贴图会在入口被拒(准入按模型模态声明)。此路由配合
771
+ // client 端捕获阶段拦截:client 拿当前模型标签问 host 是否接管(GET);纯文本
772
+ // 模型则把图片字节 POST 过来,host 存成文件并把路径返回,client 把
773
+ // `[Image: <路径>]` 插入输入框。识图延迟到模型按需调用 cmtoken_read_image
774
+ // 工具时完成——不需要看图就不花识图调用,需要看图时上下文更充分。
775
+ // 视觉模型(Qwen3.8 / Qwen3.x / kimi k2.x 非 code / MiniMax M3)不接管,
776
+ // 走原生缩略图 + 原生视觉。
777
+
778
+ const PASTE_ROUTE_PATH = '/cmtoken-oauth/paste';
779
+ const PASTE_CACHE_MAX = 32;
780
+ const PASTE_EXT = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' };
781
+ const PASTE_MAX_AGE_MS = 7 * 24 * 3600_000;
782
+
783
+ async function openPasteRoot() {
784
+ const root = join(os.homedir(), '.dsh', 'cmtoken-pastes');
785
+ await mkdir(root, { recursive: true }).catch(() => {});
786
+ return root;
787
+ }
788
+
789
+ /** 清理超过 7 天的粘贴文件;随粘贴顺带触发,失败静默。 */
790
+ function sweepExpiredPastes(root) {
791
+ return (async () => {
792
+ const entries = await readdir(root).catch(() => []);
793
+ const now = Date.now();
794
+ for (const name of entries) {
795
+ const file = join(root, name);
796
+ const info = await stat(file).catch(() => undefined);
797
+ if (info?.isFile() && now - info.mtimeMs > PASTE_MAX_AGE_MS) {
798
+ await unlink(file).catch(() => {});
799
+ }
800
+ }
801
+ })().catch(() => {});
802
+ }
803
+
804
+ /** 从模型选择器标签(如「选择模型,当前 deepseekv4-flash」)提取模型名。 */
805
+ function modelLabelToName(label) {
806
+ let s = String(label ?? '');
807
+ const idx = s.includes(',') ? s.lastIndexOf(',') : s.lastIndexOf(',');
808
+ if (idx >= 0) s = s.slice(idx + 1);
809
+ // 逗号后可能还有「当前 / current (:)」前缀,按 locale 变体剥掉
810
+ s = s.replace(/^\s*(当前|current)\s*:?\s*/i, '');
811
+ return s.replace(/\s*\(视觉回退\)\s*$/i, '').trim();
812
+ }
813
+
814
+ /** 在已认证模型列表里查找(id 或显示名,忽略大小写;兜底子串匹配);找不到返回 undefined。 */
815
+ function findConfiguredModel(ctx, name) {
816
+ if (!name) return undefined;
817
+ const models = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.models ?? [];
818
+ const lower = String(name).toLowerCase();
819
+ return models.find((m) => String(m.id).toLowerCase() === lower || String(m.name ?? '').toLowerCase() === lower)
820
+ ?? models.find((m) => lower.includes(String(m.id).toLowerCase()) || lower.includes(String(m.name ?? '').toLowerCase()));
821
+ }
822
+
823
+ function registerPasteRoute(ctx) {
824
+ if (typeof ctx.inject !== 'function') return;
825
+ ctx.inject(['webServer'], (scope) => {
826
+ const cache = new Map(); // sha256(图片字节) → 文件路径
827
+ try {
828
+ scope.webServer.register({
829
+ name: 'cmtoken-oauth-paste',
830
+ kind: 'exact',
831
+ path: PASTE_ROUTE_PATH,
832
+ handler: async (req, res) => {
833
+ try {
834
+ if (req.method === 'GET') {
835
+ const label = new URL(req.url, 'http://localhost').searchParams.get('model') ?? '';
836
+ const model = findConfiguredModel(ctx, modelLabelToName(label));
837
+ res.writeHead(200, { 'content-type': 'application/json' });
838
+ res.end(JSON.stringify({ takeover: !!model && !supportsVision(model.id) }));
839
+ return;
840
+ }
841
+ if (req.method === 'POST') {
842
+ const chunks = [];
843
+ for await (const chunk of req) chunks.push(chunk);
844
+ const bytes = new Uint8Array(Buffer.concat(chunks));
845
+ if (bytes.length === 0) throw new Error('空请求体');
846
+ if (bytes.length > VISION_MAX_IMAGE_BYTES) {
847
+ throw new Error(`图片过大(${(bytes.length / 1048576).toFixed(1)}MB),上限 ${VISION_MAX_IMAGE_BYTES / 1048576}MB`);
848
+ }
849
+ const mediaType = detectMediaType(bytes);
850
+ if (!mediaType) throw new Error('不支持的图片格式(支持 PNG / JPEG / GIF / WebP)');
851
+ const hash = createHash('sha256').update(bytes).digest('hex');
852
+ const hit = cache.get(hash);
853
+ if (hit !== undefined) {
854
+ cache.delete(hash);
855
+ cache.set(hash, hit); // 刷新新近度
856
+ res.writeHead(200, { 'content-type': 'application/json' });
857
+ res.end(JSON.stringify({ path: hit }));
858
+ return;
859
+ }
860
+ const root = await openPasteRoot();
861
+ void sweepExpiredPastes(root); // 顺带清理过期文件,不阻塞响应
862
+ const file = join(root, `paste-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}${PASTE_EXT[mediaType]}`);
863
+ await writeFile(file, bytes, { mode: 0o600 });
864
+ cache.set(hash, file);
865
+ while (cache.size > PASTE_CACHE_MAX) cache.delete(cache.keys().next().value);
866
+ res.writeHead(200, { 'content-type': 'application/json' });
867
+ res.end(JSON.stringify({ path: file }));
868
+ return;
869
+ }
870
+ res.writeHead(405, { 'content-type': 'application/json' });
871
+ res.end(JSON.stringify({ error: 'method not allowed' }));
872
+ } catch (e) {
873
+ res.writeHead(400, { 'content-type': 'application/json' });
874
+ res.end(JSON.stringify({ error: String(e?.message ?? e).slice(0, 300) }));
875
+ }
876
+ },
877
+ });
878
+ ctx.logger?.info?.('[cmtoken-oauth] 粘贴转路径路由已注册 ' + PASTE_ROUTE_PATH);
879
+ } catch (e) {
880
+ ctx.logger?.warn?.('[cmtoken-oauth] 粘贴转路径路由注册失败: ' + String(e?.message ?? e));
881
+ }
882
+ });
883
+ }
884
+
885
+ function registerVisionAdapter(ctx) {
886
+ // 宿主无该服务面(旧版 dsh)时只降级为「工具型」插件,不影响认证主功能
887
+ if (typeof ctx.llm?.registerAdapter !== 'function' || typeof ctx.llm?.stream !== 'function') {
888
+ ctx.logger?.warn?.('[cmtoken-oauth] 当前宿主无 llm.registerAdapter,视觉回退 provider 未注册(工具仍可用)');
889
+ return () => {};
890
+ }
891
+ const cache = new Map();
892
+ // llm-pi-ai 在 settings 加载后才挂载 cmtoken 路由,而本插件加载更早:
893
+ // providerRetryPolicy 会在 registerAdapter 内同步查询上游,上游未就绪即抛
894
+ // "no adapter registered"。故先探测、未就绪则监听 llm/adapters-updated 补注册。
895
+ const upstreamReady = () => {
896
+ try {
897
+ return typeof ctx.llm.listProviders === 'function'
898
+ && ctx.llm.listProviders().some((p) => (typeof p === 'string' ? p : p?.id) === UPSTREAM_PROVIDER);
899
+ } catch {
900
+ return false;
901
+ }
902
+ };
903
+ let disposeRegistration = () => {};
904
+ let done = false;
905
+ const tryRegister = () => {
906
+ if (done || !upstreamReady()) return;
907
+ try {
908
+ const registration = ctx.llm.registerAdapter([VISION_PROVIDER_ID], {
909
+ providerInfo(provider) {
910
+ return { id: provider, name: VISION_PROVIDER_NAME };
911
+ },
912
+ providerRetryPolicy() {
913
+ try {
914
+ return typeof ctx.llm.providerRetryPolicy === 'function'
915
+ ? ctx.llm.providerRetryPolicy(UPSTREAM_PROVIDER)
916
+ : undefined;
917
+ } catch {
918
+ return undefined; // 上游恰在此刻被卸载(热重载):按默认重试策略注册
919
+ }
920
+ },
921
+ async listModels(_provider, signal) {
922
+ const models = await ctx.llm.listModels(UPSTREAM_PROVIDER, signal);
923
+ // 镜像纯文本模型;原生视觉模型(声明 image)不包装
924
+ return models
925
+ .filter((m) => !(Array.isArray(m?.inputModalities) && m.inputModalities.includes('image')))
926
+ .map((m) => ({
927
+ ...m,
928
+ provider: VISION_PROVIDER_ID,
929
+ inputModalities: ['text', 'image'],
930
+ name: `${m.name ?? m.id} (视觉回退)`,
931
+ }));
932
+ },
933
+ async resolveModel(provider, model, signal) {
934
+ const info = await ctx.llm.resolveModelInfo(UPSTREAM_PROVIDER, model, signal);
935
+ if (Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')) {
936
+ throw new Error(`模型 "${model}" 原生支持图片输入,无需视觉回退;请在模型选择器选 cmtoken 分组下的同名模型。`);
937
+ }
938
+ // 校验要求 provider/id 与本路由一致,展开上游 info 后必须显式覆写
939
+ return { ...info, provider, id: model, inputModalities: ['text', 'image'] };
940
+ },
941
+ stream(options) {
942
+ return (async function* () {
943
+ const messages = [];
944
+ for (const message of options.messages) {
945
+ if (!contentHasImage(message.content)) {
946
+ messages.push(message);
947
+ continue;
948
+ }
949
+ const content = await convertBlocks(message.content, (block) =>
950
+ evidenceForBlock(ctx, block, options.signal, cache));
951
+ messages.push({ ...message, content });
952
+ }
953
+ yield* ctx.llm.stream({ ...options, provider: UPSTREAM_PROVIDER, messages: relabelUpstreamSource(messages) });
954
+ })();
955
+ },
956
+ });
957
+ disposeRegistration = () => {
958
+ try { registration?.(); } catch { /* 已卸载 */ }
959
+ };
960
+ done = true;
961
+ ctx.logger?.info?.(`[cmtoken-oauth] 视觉回退 provider ${VISION_PROVIDER_ID} 已注册`);
962
+ } catch (e) {
963
+ const duplicate = e?.code === 'DUPLICATE_ADAPTER' || /already registered|duplicate (adapter|provider)/i.test(String(e?.message ?? e));
964
+ if (duplicate) {
965
+ done = true; // 已有同名注册(如热重载残留),视为成功
966
+ return;
967
+ }
968
+ ctx.logger?.error?.(`[cmtoken-oauth] 视觉回退 provider 注册失败: ${String(e?.message ?? e)}`);
969
+ }
970
+ };
971
+ tryRegister(); // 上游已就绪(插件热重载/晚加载)则立即注册
972
+ let off = () => {};
973
+ if (typeof ctx.on === 'function') {
974
+ off = ctx.on('llm/adapters-updated', () => tryRegister());
975
+ }
976
+ return () => {
977
+ off();
978
+ disposeRegistration();
979
+ };
980
+ }
981
+
336
982
  export function apply(ctx, cfg = {}) {
337
983
  let removeRpc = () => {};
338
984
  let stopTimer = () => {};
985
+ let disposeVisionAdapter = () => {};
339
986
  ctx.effect(() => {
340
987
  removeRpc = registerRpc(ctx);
341
988
  stopTimer = startAutoRefresh(ctx);
989
+ registerVisionTool(ctx);
990
+ registerPasteRoute(ctx);
991
+ // 视觉回退包装分组(cmtoken-vision)默认关闭:粘贴识图已覆盖纯文本模型,
992
+ // 需要真实图片气泡的会话可在 profile 的插件 config 里设 visionRoute: true。
993
+ if (cfg.visionRoute === true) disposeVisionAdapter = registerVisionAdapter(ctx);
342
994
  // 启动时立即补刷一次:若停机期间 access 过期、但 refresh 仍有效,马上恢复,
343
995
  // 不必等 60 秒定时器;失败只记日志,不影响启动。
344
996
  void autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 启动补刷失败: ' + String(e?.message || e)));
345
997
  return () => {
346
998
  removeRpc();
347
999
  stopTimer();
1000
+ disposeVisionAdapter();
348
1001
  };
349
1002
  }, 'cmtoken-oauth: host service');
350
1003
  ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(自动刷新已开启:启动即补刷 + 每 1 分钟检查 + status 查询按需触发,剩余 <1 小时或已过期即刷新)');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cmtoken-oauth",
3
- "version": "1.0.3",
4
- "description": "DeepSeek Harness 插件:在 DSH 设置页一键完成广东移动 MaaS(cmtoken)的 OAuth 2.0 Device Grant + PKCE 认证,自动发现模型并写入 llm-pi-ai provider 配置,凭据安全保存并自动续期。零第三方运行时依赖。",
3
+ "version": "1.4.0",
4
+ "description": "DeepSeek Harness 插件:在 DSH 设置页一键完成广东移动 MaaS(cmtoken)的 OAuth 2.0 Device Grant + PKCE 认证,自动发现模型并写入 llm-pi-ai provider 配置(视觉模型自动声明图片输入模态);粘贴识图让纯文本模型直接收图(Qwen3.8 / Qwen3.x / Kimi K2.x / MiniMax M3 自动识读),另有 cmtoken_read_image 工具与可选的 cmtoken-vision 包装路由;凭据安全保存并自动续期。零第三方运行时依赖。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/types/index.d.ts",