@wszhoho/dsh-file-attachment 0.5.3 → 0.5.6

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
@@ -1,6 +1,6 @@
1
1
  # dsh-file-attachment
2
2
 
3
- DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V 粘贴文档/图片(支持多文件,一次可拖入/粘贴多个文件)**、或通过 **📎 附件按钮上传**时,浏览器端批量读取全文,保存到**当前会话工作区根目录下的 `.dsh-file-attachment/`**,并在输入框光标处插入 `@<保存副本绝对路径>` 引用。agent 读取 `@路径` 时直接读到落盘副本,不再依赖原始文件位置。
3
+ DeepSeek Harness (dsh) web GUI 插件:在会话输入框中拖入或 Ctrl+V 粘贴文档/图片,或通过DSH本体 📎 附件按钮上传(插件已接管本体上传按钮),图片调用配置好的vlm模型自动进行图片识别。
4
4
 
5
5
  图片与文档走**同一条落盘管线**:图片在输入框**内联附件条**中以缩略图预览(点击放大),在聊天区渲染为**可点击放大的缩略图**;文档在附件条中以类型图标 + 文件名条目显示,在聊天区保持芯片样式。当前会话模型不支持多模态时,图片自动调用**可配置 VLM** 识别生成中文描述回填草稿,文本模型也能「看懂」图片。
6
6
 
@@ -21,7 +21,7 @@ DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V
21
21
  **设置页**:「设置 → 文件附件」页两部分:
22
22
 
23
23
  - **可上传类型**:按 文档 / 代码 / 配置文件 三类增删扩展名(小写、不带点),图片恒可发送;
24
- - **多模态识别参数(VLM)**:Base URL / API Key / 模型 / 思考模式开关(默认禁用),仅当前会话模型不支持多模态时调用,未填 API Key 时静默跳过。
24
+ - **多模态识别参数(VLM)**:Base URL / API Key / 模型 / 思考模式开关(默认禁用)/ 超时时间(默认 60 秒),仅当前会话模型不支持多模态时调用,未填 API Key 时静默跳过。
25
25
 
26
26
  ![可上传类型设置](docs/settings-file-types.png)
27
27
 
@@ -97,7 +97,7 @@ packages/dsh-file-attachment/
97
97
  - **Host 半**:`webServer.register` 前缀路由 `/dsh-file-attachment`:
98
98
  - POST `/save`:接收 `{name, data(base64), sessionId}`,base64 解码后用 `node:fs/promises` 写盘到会话工作区 `.dsh-file-attachment/`,返回 `{ok, value:{path,dir,name,size}}`;
99
99
  - GET/POST `/config`:读写插件配置(可上传类型 + VLM 参数);
100
- - POST `/describe`:接收 `{dataUrl, prompt}`,调 VLM(OpenAI 兼容 `chat/completions`)识别图片,返回中文描述;
100
+ - POST `/describe`:接收 `{dataUrl, prompt}`,调 VLM(OpenAI 兼容 `chat/completions`)识别图片,返回中文描述与当前思考模式配置(`thinkingType`);
101
101
  - `describe_image` 工具(Host 注册):agent 需要理解 `@图片` 引用时调用——读已落盘图片 → VLM 识别 → 描述直接作为工具输出返回(UI 展示 + 模型可见)。
102
102
  - **Client 半**槽位:
103
103
  - `conversation.input.attachments`(shadow 原生附件条,priority -100):**内联附件条**,渲染自维护的附件登记表——图片为 data URL 缩略图(点击放大)、文档为类型图标 + 文件名;条目可移除(同步清除草稿 `@引用`),发送后自动清空,不依赖 dsh 附件草稿链路(文本模型可正常发送);
@@ -113,7 +113,7 @@ packages/dsh-file-attachment/
113
113
  - `lib/*.js`、`package.json`、`cordis.patch.yml`、`README.md` 当前均为 **UTF-8 无 BOM**(以仓库实测为准);编辑时保持原编码,不引入 BOM。
114
114
  - Host 半用 `webServer.register({ kind: 'prefix', path: '/dsh-file-attachment', handler })` 提供 `/save`、`/config`、`/describe` 三个路由,并经 `tools.register` 注册 `describe_image` 工具;纯 ESM 无构建,不依赖装饰器/远程反射。
115
115
  - Client 半保存用 `fetch('/dsh-file-attachment/save', { method: 'POST', body: JSON.stringify({ name, data, sessionId }) })`,信封为 `{ ok, value | error }`。
116
- - VLM 走 OpenAI 兼容 `chat/completions`(Base URL / API Key / 模型 / 思考模式可在设置页配置,默认关闭思考模式);未填 API Key 时识别静默跳过,不影响落盘。
116
+ - VLM 走 OpenAI 兼容 `chat/completions`(Base URL / API Key / 模型 / 思考模式 / 超时时间可在设置页配置,思考模式默认关闭,超时默认 60 秒);请求按 MiMo 官方格式发送 `thinking: { type: "enabled"|"disabled" }`(扁平 `thinkingType` 会被 API 忽略,而 mimo-v2.5 默认开启深度思考,导致识别极慢——实测 206 秒 vs 关闭后 22~73 秒,服务延迟波动较大,若偶发超时可调大);超时后工具调用中止并明确报错「VLM 请求超时(N 秒)」,不再挂死会话;`describe_image` 工具输出 JSON 附带 `thinkingType`(enabled/disabled),直观展示当前思考模式配置。未填 API Key 时识别静默跳过,不影响落盘。
117
117
  - 项目根 = 会话工作区(`session.header.cwd` → `sandboxPolicy.workspaceRoot` → `process.cwd()` 兜底)。
118
118
  - 50MB 上限两侧一致(client 跳过 + host 校验)。
119
119
 
package/lib/client.js CHANGED
@@ -49,8 +49,8 @@ window.__ModuleLoader__.load({
49
49
  config: ['json', 'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'env', 'properties', 'xml', 'html', 'css'],
50
50
  }
51
51
  const allowedTypes = { doc: DEFAULT_TYPES.doc.slice(), code: DEFAULT_TYPES.code.slice(), config: DEFAULT_TYPES.config.slice() }
52
- // 多模态 VLM 识别参数(仅当前模型非多模态时调用):baseURL/apiKey/model/thinkingType
53
- let vlmCfg = { baseURL: 'https://api.xiaomimimo.com/v1', apiKey: '', model: 'mimo-v2.5', thinkingType: 'disabled' }
52
+ // 多模态 VLM 识别参数(仅当前模型非多模态时调用):baseURL/apiKey/model/thinkingType/timeout
53
+ let vlmCfg = { baseURL: 'https://api.xiaomimimo.com/v1', apiKey: '', model: 'mimo-v2.5', thinkingType: 'disabled', timeout: 60 }
54
54
  let allowedExts = null // Set<ext>,rebuildAllowed 填充;上传校验用
55
55
  let acceptString = 'image/*' // 文件选择器 <input accept>,buildAccept 生成;劫持📎打开前赋值
56
56
  // 规范化扩展名列表(小写/去点/去重/仅 [a-z0-9]),与 host 侧同逻辑
@@ -135,12 +135,15 @@ window.__ModuleLoader__.load({
135
135
  const timer = setInterval(scan, 1500)
136
136
  return { mo, timer }
137
137
  }
138
- // 客户端侧多模态参数规范化(空值回退默认,与 host normalizeMimo 一致)
138
+ // 客户端侧多模态参数规范化(空值回退默认,与 host normalizeVlm 一致)
139
139
  function normalizeVlmClient(raw) {
140
- const d = { baseURL: 'https://api.xiaomimimo.com/v1', apiKey: '', model: 'mimo-v2.5', thinkingType: 'disabled' }
140
+ const d = { baseURL: 'https://api.xiaomimimo.com/v1', apiKey: '', model: 'mimo-v2.5', thinkingType: 'disabled', timeout: 60 }
141
141
  if (raw === null || typeof raw !== 'object') return d
142
142
  const pick = (k) => (typeof raw[k] === 'string' && raw[k] !== '') ? raw[k] : d[k]
143
- return { baseURL: pick('baseURL'), apiKey: pick('apiKey'), model: pick('model'), thinkingType: raw.thinkingType === 'enabled' ? 'enabled' : 'disabled' }
143
+ // 超时(秒):正整数 1~600,非法回退默认
144
+ const t = Number(raw && raw.timeout)
145
+ const timeout = Number.isFinite(t) && t >= 1 && t <= 600 ? Math.floor(t) : d.timeout
146
+ return { baseURL: pick('baseURL'), apiKey: pick('apiKey'), model: pick('model'), thinkingType: raw.thinkingType === 'enabled' ? 'enabled' : 'disabled', timeout }
144
147
  }
145
148
  // 应用配置:更新 allowedTypes → 重建校验 Set + accept(劫持📎在打开选择器时读取 acceptString)
146
149
  function applyConfig(cfg) {
@@ -1140,6 +1143,7 @@ window.__ModuleLoader__.load({
1140
1143
  const [vlmApiKey, setVlmApiKey] = react.useState('')
1141
1144
  const [vlmModel, setVlmModel] = react.useState('')
1142
1145
  const [vlmThinking, setVlmThinking] = react.useState(false)
1146
+ const [vlmTimeout, setVlmTimeout] = react.useState('30')
1143
1147
  const [docIn, setDocIn] = react.useState('')
1144
1148
  const [codeIn, setCodeIn] = react.useState('')
1145
1149
  const [cfgIn, setCfgIn] = react.useState('')
@@ -1157,6 +1161,7 @@ window.__ModuleLoader__.load({
1157
1161
  setVlmApiKey(vlmCfg.apiKey)
1158
1162
  setVlmModel(vlmCfg.model)
1159
1163
  setVlmThinking(vlmCfg.thinkingType === 'enabled')
1164
+ setVlmTimeout(String(vlmCfg.timeout || 60))
1160
1165
  })
1161
1166
  return () => { live = false }
1162
1167
  }, [])
@@ -1169,7 +1174,7 @@ window.__ModuleLoader__.load({
1169
1174
  async function save() {
1170
1175
  setSaving(true); setSaved(false)
1171
1176
  try {
1172
- const body = { doc: doc, code: code, config: config, vlm: { baseURL: vlmBaseURL, apiKey: vlmApiKey, model: vlmModel, thinkingType: vlmThinking ? 'enabled' : 'disabled' } }
1177
+ const body = { doc: doc, code: code, config: config, vlm: { baseURL: vlmBaseURL, apiKey: vlmApiKey, model: vlmModel, thinkingType: vlmThinking ? 'enabled' : 'disabled', timeout: Math.min(600, Math.max(1, Math.floor(Number(vlmTimeout) || 60))) } }
1173
1178
  const r = await fetch('/dsh-file-attachment/config', {
1174
1179
  method: 'POST',
1175
1180
  headers: { 'Content-Type': 'application/json' },
@@ -1192,16 +1197,18 @@ window.__ModuleLoader__.load({
1192
1197
  react.createElement(FaExtGroup, { title: t('doc.title'), list: doc, setList: setDoc, input: docIn, setInput: setDocIn }),
1193
1198
  react.createElement(FaExtGroup, { title: t('code.title'), list: code, setList: setCode, input: codeIn, setInput: setCodeIn }),
1194
1199
  react.createElement(FaExtGroup, { title: t('config.title'), list: config, setList: setConfig, input: cfgIn, setInput: setCfgIn }),
1195
- react.createElement('div', { style: { margin: '16px 0', padding: '12px', borderRadius: '8px', background: 'rgba(128,128,128,0.08)', border: '1px solid rgba(128,128,128,0.15)' } },
1196
- react.createElement('div', { style: { marginBottom: '8px', fontSize: '13px', fontWeight: '600' } }, t('vlm.title')),
1197
- react.createElement('div', { style: { marginBottom: '10px', fontSize: '12px', color: 'rgba(128,128,128,0.9)' } }, t('vlm.hint')),
1200
+ react.createElement('div', { style: { margin: '16px 0', padding: '12px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.08))', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.15))' } },
1201
+ react.createElement('div', { style: { marginBottom: '8px', fontSize: '13px', fontWeight: '600', color: 'var(--dsw-alias-label-primary, inherit)' } }, t('vlm.title')),
1202
+ react.createElement('div', { style: { marginBottom: '10px', fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, rgba(128,128,128,0.9))' } }, t('vlm.hint')),
1198
1203
  react.createElement('div', { style: { display: 'grid', gridTemplateColumns: '110px 1fr', gap: '8px 10px', alignItems: 'center' } },
1199
1204
  react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.baseURL')),
1200
- react.createElement('input', { value: vlmBaseURL, onChange: (e) => setVlmBaseURL(e.target.value), placeholder: 'https://api.xiaomimimo.com/v1', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1205
+ react.createElement('input', { value: vlmBaseURL, onChange: (e) => setVlmBaseURL(e.target.value), placeholder: 'https://api.xiaomimimo.com/v1', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1201
1206
  react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.apiKey')),
1202
- react.createElement('input', { value: vlmApiKey, onChange: (e) => setVlmApiKey(e.target.value), type: 'password', placeholder: 'sk-...', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1207
+ react.createElement('input', { value: vlmApiKey, onChange: (e) => setVlmApiKey(e.target.value), type: 'password', placeholder: 'sk-...', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1203
1208
  react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.model')),
1204
- react.createElement('input', { value: vlmModel, onChange: (e) => setVlmModel(e.target.value), placeholder: 'mimo-v2.5', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1209
+ react.createElement('input', { value: vlmModel, onChange: (e) => setVlmModel(e.target.value), placeholder: 'mimo-v2.5', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1210
+ react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.timeout')),
1211
+ react.createElement('input', { value: vlmTimeout, onChange: (e) => setVlmTimeout(e.target.value), placeholder: '60', title: t('vlm.timeoutHint'), style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
1205
1212
  react.createElement('span', { style: { fontSize: '12px' } }),
1206
1213
  react.createElement('label', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px' } },
1207
1214
  react.createElement('input', { type: 'checkbox', checked: vlmThinking, onChange: (e) => setVlmThinking(e.target.checked) }),
@@ -1223,6 +1230,73 @@ window.__ModuleLoader__.load({
1223
1230
  )
1224
1231
  }
1225
1232
 
1233
+ // 插件配置 tab 卡片:自渲染标题 + 描述 + 展开/收起,展开后承载原有设置表单(FaSettingsPage)。
1234
+ // 「插件配置」tab 只派发 settings.plugin.item 槽位,卡片外观完全由插件自绘
1235
+ //(与自动继续/插件市场同形);表单内部仍走 /config 接口读写真实配置。
1236
+ function FaSettingsCard() {
1237
+ const [open, setOpen] = react.useState(false)
1238
+ const [hover, setHover] = react.useState(false)
1239
+ const [version, setVersion] = react.useState(null)
1240
+ react.useEffect(() => {
1241
+ fetch('/dsh-file-attachment/version').then(r => r.json()).then(d => {
1242
+ if (d.ok && d.version) setVersion(d.version)
1243
+ }).catch(() => { /* ignore */ })
1244
+ }, [])
1245
+ function t(key) {
1246
+ if (ctxRef !== null && ctxRef.locale !== void 0 && typeof ctxRef.locale.bind === 'function') {
1247
+ try { return ctxRef.locale.bind('dsh-file-attachment')(key) } catch (err) { /* 回退 key */ }
1248
+ }
1249
+ return key
1250
+ }
1251
+ const borderActive = open || hover
1252
+ return react.createElement('div', {
1253
+ onMouseEnter: () => setHover(true),
1254
+ onMouseLeave: () => setHover(false),
1255
+ style: {
1256
+ borderRadius: '16px',
1257
+ border: `0.5px solid ${borderActive ? 'var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l4))' : 'var(--dsw-alias-border-l4)'}`,
1258
+ background: open ? 'var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.06))' : 'var(--dsw-alias-bg-layer-3, rgba(128,128,128,0.04))',
1259
+ overflow: 'hidden',
1260
+ transition: 'border-color 0.16s, background 0.16s',
1261
+ },
1262
+ },
1263
+ react.createElement('button', {
1264
+ type: 'button',
1265
+ 'aria-expanded': open,
1266
+ onClick: () => setOpen(!open),
1267
+ style: {
1268
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
1269
+ width: '100%', padding: '14px 16px',
1270
+ background: 'none', border: 'none', cursor: 'pointer',
1271
+ color: 'inherit', textAlign: 'left', boxSizing: 'border-box',
1272
+ },
1273
+ },
1274
+ react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: 0, flex: 1 } },
1275
+ react.createElement('div', { style: { fontSize: '15px', fontWeight: 600, lineHeight: '1.4', color: 'var(--dsw-alias-label-primary, inherit)' } },
1276
+ t('card.title'),
1277
+ version != null ? react.createElement('span', { style: { marginLeft: '1em', fontSize: '11px', fontWeight: 400, color: 'var(--dsw-alias-label-quaternary, rgba(128,128,128,0.55))', verticalAlign: 'middle' } }, 'v' + version) : null,
1278
+ ),
1279
+ react.createElement('div', { style: { fontSize: '13px', lineHeight: '1.5', color: 'var(--dsw-alias-label-tertiary, rgba(128,128,128,0.85))' } }, t('card.description')),
1280
+ ),
1281
+ react.createElement('span', {
1282
+ style: {
1283
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1284
+ color: 'var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))',
1285
+ flex: 'none',
1286
+ transition: 'transform 0.16s',
1287
+ transform: open ? 'rotate(180deg)' : 'none',
1288
+ },
1289
+ },
1290
+ react.createElement('svg', { width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true },
1291
+ react.createElement('path', { d: 'M3.5 5.5l3.5 3.5 3.5-3.5', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' })),
1292
+ ),
1293
+ ),
1294
+ open ? react.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.14))', margin: '0 16px', paddingBottom: '8px' } },
1295
+ react.createElement(FaSettingsPage, {}),
1296
+ ) : null,
1297
+ )
1298
+ }
1299
+
1226
1300
  function apply(ctx) {
1227
1301
  ctxRef = ctx
1228
1302
  if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] apply', { hasSlots: ctx.get('slots') !== undefined, hasConversation: ctx.get('conversation') !== undefined })
@@ -1231,6 +1305,8 @@ window.__ModuleLoader__.load({
1231
1305
  // 注册设置页文案(zh/en)
1232
1306
  ctx.effect(() => ctx.locale.register('dsh-file-attachment', {
1233
1307
  zh: {
1308
+ 'card.title': '文件附件',
1309
+ 'card.description': '拖拽、粘贴或上传文件与图片,可配置文件类型与多模态识别参数。',
1234
1310
  'settings.title': '文件附件',
1235
1311
  'settings.hint': '用于设置聊天中可发送的文件类型。图片恒可发送;以下三类按扩展名校验文档/代码/配置文件(扩展名不带点、小写,如 pdf、py)。',
1236
1312
  'doc.title': '文档',
@@ -1242,11 +1318,15 @@ window.__ModuleLoader__.load({
1242
1318
  'vlm.apiKey': 'API Key',
1243
1319
  'vlm.model': '模型',
1244
1320
  'vlm.thinking': '启用思考模式(默认禁用)',
1321
+ 'vlm.timeout': '超时(秒)',
1322
+ 'vlm.timeoutHint': '图片识别超时时间,默认 60 秒;识别慢或卡顿时可调大',
1245
1323
  'save': '保存',
1246
1324
  'saving': '保存中…',
1247
1325
  'saved': '已保存',
1248
1326
  },
1249
1327
  en: {
1328
+ 'card.title': 'File Attachments',
1329
+ 'card.description': 'Drag, paste or upload files and images. Configure file types and multimodal recognition.',
1250
1330
  'settings.title': 'File Attachments',
1251
1331
  'settings.hint': 'Set which file types can be sent in chat. Images are always allowed; the lists below gate documents / code / config files by extension (no dot, lowercase, e.g. pdf, py).',
1252
1332
  'doc.title': 'Documents',
@@ -1258,6 +1338,8 @@ window.__ModuleLoader__.load({
1258
1338
  'vlm.apiKey': 'API Key',
1259
1339
  'vlm.model': 'Model',
1260
1340
  'vlm.thinking': 'Enable thinking mode (disabled by default)',
1341
+ 'vlm.timeout': 'Timeout (s)',
1342
+ 'vlm.timeoutHint': 'Image recognition timeout, default 60s; increase if recognition is slow or hangs',
1261
1343
  'save': 'Save',
1262
1344
  'saving': 'Saving…',
1263
1345
  'saved': 'Saved',
@@ -1301,15 +1383,17 @@ window.__ModuleLoader__.load({
1301
1383
  { name: 'shell.overlay', id: 'dsh-file-attachment-lightbox', order: 200 },
1302
1384
  FaLightbox
1303
1385
  ))
1304
- // 设置页:文件附件类型配置(左侧菜单项 + 右侧配置页)
1305
- slots.inject('settings.section', () => [
1306
- slots.register({
1307
- name: 'settings.section',
1308
- id: 'dsh-file-attachment-settings',
1309
- order: 120,
1310
- label: () => ctx.locale.bind('dsh-file-attachment')('settings.title'),
1311
- }, () => react.createElement(FaSettingsPage, {})),
1312
- ])
1386
+ // 插件配置 tab 卡片:settings.plugin.item(key = settings 命名空间 'dsh-file-attachment')。
1387
+ // 卡片自渲染标题+描述+展开(见 FaSettingsCard),展开后承载原有设置表单。
1388
+ // 嵌套 inject settingsScope:老版本 host(< 0.1.0-rc.7)无该服务时卡片不出现,
1389
+ // 但不卸载整个插件(graceful degradation,与插件市场同形)。
1390
+ ctx.inject(['settingsScope'], (scoped) => {
1391
+ scoped.slots.inject('settings.plugin.item', () => scoped.slots.register({
1392
+ name: 'settings.plugin.item',
1393
+ key: 'dsh-file-attachment',
1394
+ locale: 'dsh-file-attachment',
1395
+ }, FaSettingsCard))
1396
+ })
1313
1397
  }
1314
1398
  bridge.conversation = ctx.get('conversation')
1315
1399
  // 劫持 dsh 原生📎:让上传入口位于与 dsh 本体一致的位置(hero/composer 两模式同一按钮),
package/lib/index.js CHANGED
@@ -7,7 +7,9 @@
7
7
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
8
8
  import { homedir } from 'node:os'
9
9
  import { join, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
10
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import z from '@deepseek-ai/schemastery'
11
13
 
12
14
  /** 本包声明依赖的 Host 服务。 */
13
15
  export const name = 'dsh-file-attachment'
@@ -143,17 +145,48 @@ const VLM_DEFAULTS = {
143
145
  apiKey: '',
144
146
  model: 'mimo-v2.5',
145
147
  thinkingType: 'disabled',
148
+ timeout: 60, // 识别超时(秒)。实测 VLM 延迟波动大(12~73s),30s 会误杀多数请求
146
149
  }
147
150
 
151
+ // 「插件配置」tab 卡片所绑定的 settings 命名空间(kebab-case,匹配 /dsh-file-attachment)。
152
+ const SETTINGS_NS = 'dsh-file-attachment'
153
+
154
+ /**
155
+ * settings 命名空间的 wire schema:字段自带默认值,描述「可上传类型」与多模态参数结构。
156
+ *
157
+ * 作用:向 host 的 `settings` 服务登记该命名空间,使浏览器半侧的插件配置卡片
158
+ * (settings.plugin.item,key = 本命名空间)被「插件配置」tab 派发渲染。
159
+ *
160
+ * 注意:实际配置仍持久化在 ~/.dsh/file-attachment.json(/config 路由读写),此 schema
161
+ * 仅用于登记命名空间结构(与自动继续插件的 settings.register 同形);卡片内部表单
162
+ * 继续走 /config 接口读写真实配置。
163
+ */
164
+ const FaAttachmentSchema = z.object({
165
+ doc: z.array(z.string()).default(DEFAULT_TYPES.doc),
166
+ code: z.array(z.string()).default(DEFAULT_TYPES.code),
167
+ config: z.array(z.string()).default(DEFAULT_TYPES.config),
168
+ vlm: z.object({
169
+ baseURL: z.string().default(VLM_DEFAULTS.baseURL),
170
+ apiKey: z.string().default(VLM_DEFAULTS.apiKey),
171
+ model: z.string().default(VLM_DEFAULTS.model),
172
+ thinkingType: z.string().default(VLM_DEFAULTS.thinkingType),
173
+ timeout: z.natural().default(VLM_DEFAULTS.timeout),
174
+ }),
175
+ })
176
+
148
177
  /** 规范化多模态参数(缺省/空值回退默认,trim 去空白)。 */
149
178
  function normalizeVlm(raw) {
150
179
  const r = (raw && typeof raw === 'object') ? raw : {}
151
180
  const pick = (key) => (typeof r[key] === 'string' && r[key].trim() !== '' ? r[key].trim() : VLM_DEFAULTS[key])
181
+ // 超时(秒):正整数 1~600,非法回退默认
182
+ const t = Number(r && r.timeout)
183
+ const timeout = Number.isFinite(t) && t >= 1 && t <= 600 ? Math.floor(t) : VLM_DEFAULTS.timeout
152
184
  return {
153
185
  baseURL: pick('baseURL'),
154
186
  apiKey: pick('apiKey'),
155
187
  model: pick('model'),
156
188
  thinkingType: pick('thinkingType'),
189
+ timeout,
157
190
  }
158
191
  }
159
192
 
@@ -219,19 +252,47 @@ async function handleSave(root, body) {
219
252
  return { path: target, dir, name: safe, size: bytes.length }
220
253
  }
221
254
 
255
+ // 仅思考模型识别:这类模型(旗舰推理版 / 带 thinking 后缀)始终思考、不可关闭,
256
+ // 传 thinking.type='disabled' 会报错(Kimi k2.7-code、GLM-5.3、Qwen3-VL-*-Thinking 等)。
257
+ function isThinkingOnlyModel(model) {
258
+ const m = String(model || '').toLowerCase()
259
+ if (/-thinking\b/.test(m)) return true
260
+ if (/kimi-k3\b/.test(m)) return true
261
+ if (/kimi-k2\.7-code/.test(m)) return true
262
+ if (/^glm-5\.3/.test(m)) return true
263
+ return false
264
+ }
265
+
266
+ // 思考参数适配器:各家 OpenAI 兼容 API 的「思考开关」字段形式不统一。
267
+ // 按 model 名称判断(不依赖 baseURL host——中转站/代理转发时 host 不可靠,而 model 字段稳定跟随请求)。
268
+ // - 仅思考模型 → 不传任何参数(服务端默认开启思考;传 disabled 会报错)
269
+ // - 通义千问 Qwen / QVQ(含 VL 视觉)→ 扁平布尔 enable_thinking
270
+ // - 其余主流(小米 MiMo / DeepSeek / 智谱 GLM / Kimi 官方)→ 嵌套对象 thinking:{type}
271
+ function buildThinkingParams(vlm) {
272
+ if (isThinkingOnlyModel(vlm.model)) return {}
273
+ const wantEnabled = vlm.thinkingType === 'enabled'
274
+ const model = String(vlm.model || '').toLowerCase()
275
+ if (model.includes('qwen') || model.includes('qvq')) {
276
+ return { enable_thinking: wantEnabled }
277
+ }
278
+ return { thinking: { type: wantEnabled ? 'enabled' : 'disabled' } }
279
+ }
280
+
222
281
  /**
223
282
  * 调 VLM(OpenAI 兼容 chat/completions)识别图片,返回中文描述。
224
- * @param vlm - 多模态参数 { baseURL, apiKey, model, thinkingType }(来自配置)。
283
+ * @param vlm - 多模态参数 { baseURL, apiKey, model, thinkingType, timeout }(来自配置,timeout 单位秒)。
225
284
  * @param dataUrl - base64 data URL(形如 data:image/png;base64,xxx)。
226
285
  * @returns 图片的简洁中文描述。
227
- * @throws VLM 请求失败或未返回有效内容时抛错。
286
+ * @throws VLM 请求失败或未返回有效内容时抛错;超时抛「VLM 请求超时」。
228
287
  */
229
288
  async function describeImage(vlm, dataUrl, prompt) {
230
289
  const url = vlm.baseURL.replace(/\/+$/u, '') + '/chat/completions'
231
290
  const userPrompt = (typeof prompt === 'string' && prompt.trim() !== '') ? prompt.trim() : '请描述这张图片。'
291
+ // 思考开关:各家 OpenAI 兼容 API 字段形式不统一,由 buildThinkingParams 按 model 名称适配
292
+ // (Qwen/QVQ → 扁平 enable_thinking;MiMo/DeepSeek/GLM/Kimi → 嵌套 thinking.type;仅思考模型 → 不传)。
232
293
  const payload = {
233
294
  model: vlm.model,
234
- thinkingType: vlm.thinkingType,
295
+ ...buildThinkingParams(vlm),
235
296
  messages: [
236
297
  { role: 'system', content: '你是图片描述助手,用简洁中文回答。' },
237
298
  {
@@ -243,22 +304,35 @@ async function describeImage(vlm, dataUrl, prompt) {
243
304
  },
244
305
  ],
245
306
  }
246
- const resp = await fetch(url, {
247
- method: 'POST',
248
- headers: {
249
- 'content-type': 'application/json',
250
- 'authorization': 'Bearer ' + vlm.apiKey,
251
- },
252
- body: JSON.stringify(payload),
253
- })
254
- if (!resp.ok) {
255
- const text = await resp.text().catch(() => '')
256
- throw new Error('VLM 请求失败 ' + resp.status + (text !== '' ? ' ' + text.slice(0, 200) : ''))
307
+ const timeoutSec = (Number.isFinite(vlm.timeout) && vlm.timeout >= 1 && vlm.timeout <= 600) ? vlm.timeout : VLM_DEFAULTS.timeout
308
+ const controller = new AbortController()
309
+ const timer = setTimeout(() => controller.abort(), timeoutSec * 1000)
310
+ try {
311
+ const resp = await fetch(url, {
312
+ method: 'POST',
313
+ headers: {
314
+ 'content-type': 'application/json',
315
+ 'authorization': 'Bearer ' + vlm.apiKey,
316
+ },
317
+ body: JSON.stringify(payload),
318
+ signal: controller.signal,
319
+ })
320
+ if (!resp.ok) {
321
+ const text = await resp.text().catch(() => '')
322
+ throw new Error('VLM 请求失败 ' + resp.status + (text !== '' ? ' ' + text.slice(0, 200) : ''))
323
+ }
324
+ const data = await resp.json()
325
+ const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
326
+ if (typeof content !== 'string' || content === '') throw new Error('VLM 未返回有效内容')
327
+ return content
328
+ } catch (err) {
329
+ if (err && err.name === 'AbortError') {
330
+ throw new Error('VLM 请求超时(' + timeoutSec + ' 秒),可在「设置→文件附件」调大超时时间')
331
+ }
332
+ throw err
333
+ } finally {
334
+ clearTimeout(timer)
257
335
  }
258
- const data = await resp.json()
259
- const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
260
- if (typeof content !== 'string' || content === '') throw new Error('VLM 未返回有效内容')
261
- return content
262
336
  }
263
337
 
264
338
  /** 注册 /dsh-file-attachment 前缀路由(save POST)。 */
@@ -294,6 +368,18 @@ function registerRoutes(ctx) {
294
368
  return
295
369
  }
296
370
  }
371
+ if (req.method === 'GET' && pathname === '/dsh-file-attachment/version') {
372
+ try {
373
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url))
374
+ const raw = await readFile(pkgPath, 'utf8')
375
+ const pkg = JSON.parse(raw)
376
+ json(res, { ok: true, version: pkg.version || '0.0.0' })
377
+ return
378
+ } catch {
379
+ json(res, { ok: false, version: 'unknown' }, 500)
380
+ return
381
+ }
382
+ }
297
383
  if (req.method === 'GET' && pathname === '/dsh-file-attachment/config') {
298
384
  json(res, { ok: true, value: await readConfig() })
299
385
  return
@@ -327,7 +413,7 @@ function registerRoutes(ctx) {
327
413
  }
328
414
  try {
329
415
  const description = await describeImage(vlm, dataUrl, body.prompt)
330
- json(res, { ok: true, value: { description } })
416
+ json(res, { ok: true, value: { description, thinkingType: vlm.thinkingType } })
331
417
  return
332
418
  } catch (err) {
333
419
  const message = err && err.message ? err.message : '识别失败'
@@ -383,6 +469,11 @@ function registerTools(ctx) {
383
469
  type: 'string',
384
470
  description: '可选。告诉多模态模型要关注什么(如"识别图中的文字"、"描述 UI 布局")。不填则默认"请描述这张图片"。',
385
471
  },
472
+ thinkingType: {
473
+ type: 'string',
474
+ enum: ['disabled', 'enabled'],
475
+ description: 'VLM 思考模式开关:disabled=关闭(默认)/ enabled=开启。本工具默认关闭思考以加速识别,请填 disabled。',
476
+ },
386
477
  },
387
478
  output: {
388
479
  schema: {
@@ -391,11 +482,13 @@ function registerTools(ctx) {
391
482
  properties: {
392
483
  ok: { type: 'boolean', required: true },
393
484
  description: { type: 'string' },
485
+ thinkingType: { type: 'string', description: '当前 VLM 思考模式配置:enabled=开启 / disabled=关闭' },
394
486
  },
395
487
  },
396
488
  render: (_args, value) => {
489
+ const thinkingText = (value && value.thinkingType === 'enabled') ? '开启' : '关闭'
397
490
  if (value && value.ok === true && typeof value.description === 'string' && value.description !== '') {
398
- return [{ type: 'text', text: '图片识别结果:' + value.description }]
491
+ return [{ type: 'text', text: '图片识别结果:' + value.description + '(思考模式:' + thinkingText + ')' }]
399
492
  }
400
493
  return [{ type: 'text', text: (value && value.ok === true) ? '(图片已识别)' : '(图片识别失败)' }]
401
494
  },
@@ -413,7 +506,11 @@ function registerTools(ctx) {
413
506
  const description = await describeImage(cfg.vlm, dataUrl, args.prompt)
414
507
  // 识别结果直接作为工具输出返回:UI 直接展示描述文本,
415
508
  // 同时该输出就是模型可见内容(tool/result 的 content),无需额外 deferContext 注入。
416
- return { ok: true, description }
509
+ // 注意 thinkingType 的双重角色:
510
+ // - 输入参数(args.thinkingType):模型按 description 填充,仅用于让「工具调用 JSON」直观可见思考开关,
511
+ // 实际调用 VLM 时不读它(真实开关以 cfg.vlm.thinkingType 为准,见 describeImage 内的 thinking.type)。
512
+ // - 输出(此处返回值):回填配置真实值,使结果与输入展示一致、可核对。
513
+ return { ok: true, description, thinkingType: cfg.vlm.thinkingType }
417
514
  },
418
515
  }))
419
516
  console.log('[dsh-file-attachment] describe_image tool registered')
@@ -425,5 +522,12 @@ function registerTools(ctx) {
425
522
  export function apply(ctx, config = {}) {
426
523
  registerTools(ctx)
427
524
  registerRoutes(ctx)
525
+ // 登记「插件配置」tab 的 settings 命名空间:让浏览器半侧的插件配置卡片
526
+ // (settings.plugin.item,key = 本命名空间)被 tab 派发渲染。
527
+ // 老版本 host(无 settings 服务,< 0.1.0-rc.7)此回调不运行,命名空间不登记,
528
+ // 卡片不出现但不卸载整个插件(graceful degradation,与自动继续/插件市场同形)。
529
+ ctx.inject(['settings'], (scopedCtx) => {
530
+ scopedCtx.settings.register(SETTINGS_NS, FaAttachmentSchema, {})
531
+ })
428
532
  console.log('[dsh-file-attachment] host loaded (webServer routes + describe_image tool)')
429
533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wszhoho/dsh-file-attachment",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
4
4
  "description": "文件附件:拖拽/粘贴/上传文件(支持多文件);图片与文档统一落盘到 .dsh-file-attachment 并以 @绝对路径 引用发送(文本模型可正常使用);输入框内联显示图片缩略图预览与文件条目,聊天区图片同样渲染为可点击放大的缩略图、文件保持芯片样式;非多模态模型下图片自动调用可配置 VLM 识别生成中文描述回填草稿;文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
5
5
  "keywords": [
6
6
  "dsh",
@@ -34,7 +34,8 @@
34
34
  "node": ">=18"
35
35
  },
36
36
  "peerDependencies": {
37
- "@deepseek-ai/dsh": ">=0.1.0-rc.1 <0.1.1-0 || >=0.1.1-rc.1 <0.1.2-0 || >=0.1.2-alpha.1 <0.2.0-0"
37
+ "@deepseek-ai/dsh": ">=0.1.0-rc.1 <0.1.1-0 || >=0.1.1-rc.1 <0.1.2-0 || >=0.1.2-alpha.1 <0.2.0-0",
38
+ "@deepseek-ai/schemastery": "^3.18.1"
38
39
  },
39
40
  "publishConfig": {
40
41
  "registry": "https://registry.npmjs.org/"