@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 +4 -4
- package/lib/client.js +105 -21
- package/lib/index.js +125 -21
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-file-attachment
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness (dsh) web GUI
|
|
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 / 模型 /
|
|
24
|
+
- **多模态识别参数(VLM)**:Base URL / API Key / 模型 / 思考模式开关(默认禁用)/ 超时时间(默认 60 秒),仅当前会话模型不支持多模态时调用,未填 API Key 时静默跳过。
|
|
25
25
|
|
|
26
26
|

|
|
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 / 模型 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
+
"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/"
|