@xmanrui/dsh-im 4.4.0 → 4.6.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.
Files changed (43) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +29 -9
  4. package/lib/index.js +252 -241
  5. package/package.json +1 -1
  6. package/plugin-src/client/context-enhancement.js +19 -5
  7. package/plugin-src/client/i18n.js +5 -1
  8. package/plugin-src/host/modern-harness-api.mjs +3 -0
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
  10. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  11. package/src/channels/dingtalk/state-store.mjs +98 -0
  12. package/src/channels/discord/discord-api.mjs +7 -0
  13. package/src/channels/discord/discord-runtime.mjs +97 -2
  14. package/src/channels/feishu/bridge.mjs +30 -7
  15. package/src/channels/feishu/feishu-cards.mjs +2 -2
  16. package/src/channels/feishu/feishu-channel.mjs +36 -6
  17. package/src/channels/feishu/message-utils.mjs +229 -0
  18. package/src/channels/qq/qq-bridge.mjs +56 -9
  19. package/src/channels/shared/batch-input.mjs +3 -3
  20. package/src/channels/shared/bot-workspace-store.mjs +5 -0
  21. package/src/channels/shared/context-enhancement.mjs +9 -4
  22. package/src/channels/shared/harness-client.mjs +88 -30
  23. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  24. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  25. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  26. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  27. package/src/channels/shared/image-prompt.mjs +51 -0
  28. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  29. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  30. package/src/channels/shared/session-title.mjs +74 -0
  31. package/src/channels/shared/text-harness-bridge.mjs +19 -8
  32. package/src/channels/shared/workspace-command.mjs +16 -4
  33. package/src/channels/shared/workspace-session.mjs +28 -2
  34. package/src/channels/slack/manifest.mjs +3 -0
  35. package/src/channels/slack/slack-api.mjs +18 -0
  36. package/src/channels/slack/slack-runtime.mjs +56 -0
  37. package/src/channels/telegram/telegram-runtime.mjs +118 -2
  38. package/src/channels/wecom/wecom-bridge.mjs +55 -9
  39. package/src/channels/weixin/state-store.mjs +110 -0
  40. package/src/channels/weixin/weixin-api.mjs +86 -2
  41. package/src/channels/weixin/weixin-bridge.mjs +104 -12
  42. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  43. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
@@ -33,7 +33,6 @@ import {
33
33
  hasInboundImages,
34
34
  imagePromptDiagnostic,
35
35
  imagePromptUserMessage,
36
- promptContentForMessage,
37
36
  } from '../shared/image-prompt.mjs';
38
37
  import {
39
38
  hasInboundFiles,
@@ -44,6 +43,10 @@ import {
44
43
  trackOutboundArtifactProviderPromise,
45
44
  } from '../shared/semantic/artifact.mjs';
46
45
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
46
+ import {
47
+ hasReplyReference,
48
+ promptContentForInboundMessage,
49
+ } from '../shared/semantic/reply-reference.mjs';
47
50
  import {
48
51
  createDeliveryReceipt,
49
52
  providerMessageIdsFor,
@@ -85,7 +88,7 @@ function helpText() {
85
88
  t('/new 开启一个全新会话'),
86
89
  t('/compact 压缩当前会话的较早上下文'),
87
90
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
88
- t('/workspace 工作区绝对路径 切换工作区'),
91
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
89
92
  t('/workspacelist 列出工作区绝对路径'),
90
93
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
91
94
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -145,6 +148,37 @@ function hasQqFileAttachments(message) {
145
148
  && message.attachments.some((attachment) => !isQqImageAttachment(attachment));
146
149
  }
147
150
 
151
+ function qqAttachmentKind(attachment) {
152
+ const mediaType = attachmentMediaType(attachment);
153
+ if (mediaType?.startsWith('image/')) return 'image';
154
+ if (mediaType?.startsWith('audio/')) return 'audio';
155
+ if (mediaType?.startsWith('video/')) return 'video';
156
+ return 'file';
157
+ }
158
+
159
+ function qqReplyReference(message) {
160
+ const refMsgIdx = nonEmptyString(message?.refMsgIdx);
161
+ if (!refMsgIdx) return null;
162
+ const element = Array.isArray(message?.msgElements) ? message.msgElements[0] : null;
163
+ const sourceAttachments = Array.isArray(element?.attachments) ? element.attachments : [];
164
+ const attachments = sourceAttachments.map((attachment) => {
165
+ const name = nonEmptyString(attachment?.filename);
166
+ return { kind: qqAttachmentKind(attachment), ...(name ? { name } : {}) };
167
+ });
168
+ const asrText = sourceAttachments
169
+ .filter((attachment) => qqAttachmentKind(attachment) === 'audio')
170
+ .map((attachment) => nonEmptyString(attachment?.asr_refer_text))
171
+ .filter(Boolean)
172
+ .join('\n');
173
+ const content = asrText || nonEmptyString(element?.content);
174
+ return {
175
+ messageId: refMsgIdx,
176
+ ...(content ? { content } : {}),
177
+ ...(attachments.length > 0 ? { attachments } : {}),
178
+ ...(!content && attachments.length === 0 ? { unavailableReason: 'not-delivered' } : {}),
179
+ };
180
+ }
181
+
148
182
  async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
149
183
  const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
150
184
  const response = await fetchImpl(new URL(normalizedUrl), {
@@ -196,7 +230,13 @@ export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
196
230
  },
197
231
  });
198
232
  }
199
- return { content: safeText(message), images, files };
233
+ const replyTo = qqReplyReference(message);
234
+ return {
235
+ content: safeText(message),
236
+ images,
237
+ files,
238
+ ...(replyTo ? { replyTo } : {}),
239
+ };
200
240
  }
201
241
 
202
242
  function nonEmptyString(value) {
@@ -493,7 +533,8 @@ export class QqHarnessBridge {
493
533
  : this.#batchInputs.handle(key, commandText, {
494
534
  plainText: Boolean(commandText)
495
535
  && !hasQqImageAttachments(message)
496
- && !hasQqFileAttachments(message),
536
+ && !hasQqFileAttachments(message)
537
+ && !qqReplyReference(message),
497
538
  });
498
539
  if (result.handled) {
499
540
  if (result.kind === 'submit') {
@@ -785,10 +826,11 @@ export class QqHarnessBridge {
785
826
  const text = promptMessage.content;
786
827
  const hasImages = hasInboundImages(promptMessage);
787
828
  const hasFiles = hasInboundFiles(promptMessage);
829
+ const hasReply = hasReplyReference(promptMessage);
788
830
  let stream = null;
789
831
  let batchSettled = batchSubmission === null;
790
832
  try {
791
- if (!text && !hasImages && !hasFiles) {
833
+ if (!text && !hasImages && !hasFiles && !hasReply) {
792
834
  await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
793
835
  await markMessageSeen();
794
836
  return;
@@ -836,16 +878,19 @@ export class QqHarnessBridge {
836
878
  return;
837
879
  }
838
880
 
839
- let content = hasImages
840
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
881
+ let content = hasImages || hasReply
882
+ ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
841
883
  : undefined;
842
884
  const snapshot = this.#acceptedMessageIds.get(messageId);
885
+ let contextEnhanced = false;
843
886
  if (snapshot) {
844
- content = enhanceContextContent(content ?? text, snapshot, () => ({
887
+ const originalContent = content ?? text;
888
+ content = enhanceContextContent(originalContent, snapshot, () => ({
845
889
  channel: 'qq',
846
890
  senderId: sender,
847
891
  senderName: message.kind === 'group' ? message.senderName : undefined,
848
892
  }));
893
+ contextEnhanced = content !== originalContent;
849
894
  }
850
895
  // QQ stream_messages can acknowledge a final frame without rendering it in
851
896
  // some C2C clients. Standard Markdown delivery is the reliable reply path.
@@ -860,7 +905,9 @@ export class QqHarnessBridge {
860
905
  harness: this.#harness,
861
906
  state: this.#state,
862
907
  key,
863
- ...(content !== undefined ? { content } : { text }),
908
+ text,
909
+ content,
910
+ contextEnhanced,
864
911
  createOptions: { signal: this.#signal },
865
912
  existsOptions: { signal: this.#signal },
866
913
  askOptions: {
@@ -74,7 +74,7 @@ export class BatchInputManager {
74
74
  if (!batch) {
75
75
  if (!name) return { handled: false };
76
76
  if (!plainText) {
77
- return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片或文件后重试。'));
77
+ return result('unsupported-content', t('批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。'));
78
78
  }
79
79
  if (name === 'send') {
80
80
  return result('no-batch', t('当前没有待提交的批量内容,请先发送 /batch。'));
@@ -91,7 +91,7 @@ export class BatchInputManager {
91
91
  }
92
92
 
93
93
  if (!plainText && (batch.phase === 'collecting' || name)) {
94
- return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
94
+ return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
95
95
  请继续发送文字,或使用 /send、/cancel。`), {
96
96
  count: batch.messages.length,
97
97
  limit: BATCH_INPUT_LIMIT,
@@ -146,7 +146,7 @@ export class BatchInputManager {
146
146
  }
147
147
 
148
148
  if (typeof text !== 'string') {
149
- return result('unsupported-content', t(`批量输入模式目前仅支持文字,这条消息未收录。
149
+ return result('unsupported-content', t(`批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。
150
150
  请继续发送文字,或使用 /send、/cancel。`), {
151
151
  count: batch.messages.length,
152
152
  limit: BATCH_INPUT_LIMIT,
@@ -1211,6 +1211,11 @@ export function createBotWorkspaceScope(
1211
1211
  readHistory(...args) {
1212
1212
  return invokeCurrentSession('readSessionHistory', args, 'history read');
1213
1213
  },
1214
+ ...(typeof target.renameSession === 'function' ? {
1215
+ renameTitle(...args) {
1216
+ return invokeStartedSessionMutation('renameSession', args, 'title rename');
1217
+ },
1218
+ } : {}),
1214
1219
  selectModel(...args) {
1215
1220
  return invokeCurrentSession('selectSessionModel', args, 'model selection');
1216
1221
  },
@@ -1,6 +1,6 @@
1
1
  // Shared by the Host and settings UI; keep this module browser-compatible.
2
2
  export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
3
- 'channel', 'conversationType', 'senderId', 'senderName', 'botId',
3
+ 'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle', 'botId',
4
4
  ]);
5
5
 
6
6
  export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
@@ -36,7 +36,10 @@ const CHANNELS = new Set([
36
36
  'wecom', 'weixin', 'feishu', 'dingtalk', 'qq',
37
37
  'slack', 'telegram', 'discord', 'whatsapp',
38
38
  ]);
39
- const SOURCE_LIMITS = { channel: 16, conversationType: 6, senderId: 256, senderName: 256, botId: 128 };
39
+ const SOURCE_LIMITS = {
40
+ channel: 16, conversationType: 6, senderId: 256, senderName: 256,
41
+ conversationTitle: 256, botId: 128,
42
+ };
40
43
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
41
44
 
42
45
  function invalidConfig(message) {
@@ -61,7 +64,7 @@ function validateContextEnhancementScope(input) {
61
64
  throw invalidConfig('群聊和私聊开关必须是布尔值。');
62
65
  }
63
66
  if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
64
- throw invalidConfig('来源字段只能选择已定义的五个字段。');
67
+ throw invalidConfig('来源字段只能选择已定义的六个字段。');
65
68
  }
66
69
  if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
67
70
  throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
@@ -146,7 +149,9 @@ function sourceString(value, field) {
146
149
 
147
150
  function sourceBlock(snapshot, sourceFactory) {
148
151
  const { fields } = snapshot.config;
149
- const needsSource = fields.some((field) => ['channel', 'senderId', 'senderName'].includes(field));
152
+ const needsSource = fields.some((field) => [
153
+ 'channel', 'senderId', 'senderName', 'conversationTitle',
154
+ ].includes(field));
150
155
  const source = needsSource ? sourceFactory?.() : null;
151
156
  const projected = {};
152
157
  for (const field of fields) {
@@ -7,6 +7,12 @@ import {
7
7
  appendInboundFilesToPrompt,
8
8
  InboundFileError,
9
9
  } from './inbound-file.mjs';
10
+ import {
11
+ IMAGE_FILE_FALLBACK_PROMPT,
12
+ contentWithoutImages,
13
+ imageFileSourcesFromContent,
14
+ isModelImageRejection,
15
+ } from './image-prompt.mjs';
10
16
  import { outboundArtifactRegistry } from './semantic/artifact.mjs';
11
17
  import { t } from './i18n.mjs';
12
18
  import { watchHarnessMux } from './harness-mux.mjs';
@@ -911,6 +917,12 @@ export class HarnessClient {
911
917
  return created.sessionId;
912
918
  }
913
919
 
920
+ async renameSession(sessionId, title, options = {}) {
921
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
922
+ if (typeof title !== 'string' || !title.trim()) throw new TypeError('session title is required');
923
+ return this.rpc('session.rename', { sessionId, title }, 30_000, options);
924
+ }
925
+
914
926
  async executeCommand(sessionId, line, options = {}) {
915
927
  if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
916
928
  if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
@@ -1236,6 +1248,31 @@ export class HarnessClient {
1236
1248
  return ownership ? { ownership, recovered: true } : null;
1237
1249
  }
1238
1250
 
1251
+ /** Stage inbound file sources into the Session workspace via the Host executor. */
1252
+ async #stageWorkspaceFiles(sessionId, files, signal) {
1253
+ if (!this.#fileIngressExecutor) {
1254
+ throw new InboundFileError(
1255
+ 'inbound-file-ingress-unavailable',
1256
+ 'Harness file ingress is unavailable in this Host process.',
1257
+ );
1258
+ }
1259
+ const sessionList = await this.rpc(
1260
+ 'session.list',
1261
+ {},
1262
+ 30_000,
1263
+ { signal },
1264
+ );
1265
+ const sessionWorkspace = sessionList?.items?.find(
1266
+ (item) => item?.sessionId === sessionId,
1267
+ )?.cwd;
1268
+ return this.#fileIngressExecutor({
1269
+ sessionId,
1270
+ workspace: sessionWorkspace,
1271
+ files,
1272
+ signal,
1273
+ });
1274
+ }
1275
+
1239
1276
  async ask(sessionId, prompt, options = {}) {
1240
1277
  if (typeof options === 'number') options = { timeoutMs: options };
1241
1278
  const timeoutMs = options.timeoutMs ?? 600_000;
@@ -1292,7 +1329,7 @@ export class HarnessClient {
1292
1329
  let interactionTask = null;
1293
1330
  let artifactsDelivered = false;
1294
1331
  let deliveredArtifactCount = 0;
1295
- let stagedInboundFiles = null;
1332
+ const stagedBatches = [];
1296
1333
  let promptAccepted = false;
1297
1334
  let turnFinished = false;
1298
1335
 
@@ -1323,29 +1360,11 @@ export class HarnessClient {
1323
1360
  const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
1324
1361
 
1325
1362
  try {
1363
+ const basePrompt = prompt;
1326
1364
  if (inboundFiles.length > 0) {
1327
- if (!this.#fileIngressExecutor) {
1328
- throw new InboundFileError(
1329
- 'inbound-file-ingress-unavailable',
1330
- 'Harness file ingress is unavailable in this Host process.',
1331
- );
1332
- }
1333
- const sessionList = await this.rpc(
1334
- 'session.list',
1335
- {},
1336
- 30_000,
1337
- { signal },
1338
- );
1339
- const sessionWorkspace = sessionList?.items?.find(
1340
- (item) => item?.sessionId === sessionId,
1341
- )?.cwd;
1342
- stagedInboundFiles = await this.#fileIngressExecutor({
1343
- sessionId,
1344
- workspace: sessionWorkspace,
1345
- files: inboundFiles,
1346
- signal,
1347
- });
1348
- prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
1365
+ const staged = await this.#stageWorkspaceFiles(sessionId, inboundFiles, signal);
1366
+ stagedBatches.push(staged);
1367
+ prompt = appendInboundFilesToPrompt(prompt, staged);
1349
1368
  }
1350
1369
  if (interactionSignal) {
1351
1370
  let markOpen;
@@ -1372,12 +1391,47 @@ export class HarnessClient {
1372
1391
  if (!Array.isArray(content) || content.length === 0) {
1373
1392
  throw new TypeError('Harness prompt content is required');
1374
1393
  }
1375
- await this.rpc('session.prompt', {
1394
+ const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1395
+ const sendPrompt = (promptContent) => this.rpc('session.prompt', {
1376
1396
  sessionId,
1377
1397
  mode: 'queue',
1378
- content,
1379
- clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1398
+ content: promptContent,
1399
+ clientTimeZone,
1380
1400
  }, 30_000, { rpcId: promptRpcId, signal });
1401
+ try {
1402
+ await sendPrompt(content);
1403
+ } catch (error) {
1404
+ // The Host refuses image blocks for a non-vision model before any
1405
+ // durable user message exists. Re-deliver the same bytes the way
1406
+ // ordinary uploads (zip, documents) already travel — staged into the
1407
+ // Session workspace and named in a text manifest — then retry once
1408
+ // with a text-only prompt. The retry reuses promptRpcId so reply
1409
+ // tracking, control and interaction ownership stay bound to this ask.
1410
+ const imageSources = isModelImageRejection(error)
1411
+ ? imageFileSourcesFromContent(content)
1412
+ : [];
1413
+ if (imageSources.length === 0) throw error;
1414
+ let stagedImages;
1415
+ try {
1416
+ stagedImages = await this.#stageWorkspaceFiles(sessionId, imageSources, signal);
1417
+ } catch (stagingError) {
1418
+ if (signal?.aborted) throw signal.reason ?? stagingError;
1419
+ console.warn(
1420
+ `[${this.#logPrefix}] unable to restage rejected images as workspace files:`,
1421
+ stagingError?.message ?? String(stagingError),
1422
+ );
1423
+ throw error;
1424
+ }
1425
+ stagedBatches.push(stagedImages);
1426
+ const baseContent = typeof basePrompt === 'string'
1427
+ ? [{ type: 'text', text: basePrompt }]
1428
+ : basePrompt;
1429
+ const fallbackPrompt = appendInboundFilesToPrompt([
1430
+ ...contentWithoutImages(baseContent),
1431
+ { type: 'text', text: t(IMAGE_FILE_FALLBACK_PROMPT) },
1432
+ ], { files: stagedBatches.flatMap((batch) => batch?.files ?? []) });
1433
+ await sendPrompt(fallbackPrompt);
1434
+ }
1381
1435
  promptAccepted = true;
1382
1436
 
1383
1437
  try {
@@ -1435,10 +1489,14 @@ export class HarnessClient {
1435
1489
  throw turnStoppedError();
1436
1490
  }
1437
1491
  } finally {
1438
- if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
1439
- await stagedInboundFiles.cleanup().catch((error) => {
1440
- console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
1441
- });
1492
+ if (!promptAccepted || turnFinished) {
1493
+ for (const staged of stagedBatches) {
1494
+ try {
1495
+ await staged?.cleanup?.();
1496
+ } catch (error) {
1497
+ console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
1498
+ }
1499
+ }
1442
1500
  }
1443
1501
  closeArtifactConsumer();
1444
1502
  if (ownership) {
@@ -227,7 +227,6 @@ export default {
227
227
  '/sessionlist or /sessions List workspace sessions',
228
228
  '/session ID 绑定已有会话': '/session ID Bind an existing session',
229
229
  '/workspacelist 列出工作区': '/workspacelist List workspaces',
230
- '/workspace 路径 切换工作区': '/workspace PATH Switch workspace',
231
230
  '/new 开启全新会话': '/new Start a new session',
232
231
  '📊 状态 / 压缩': '📊 Status / compact',
233
232
  '/status 连接状态': '/status Connection status',
@@ -247,8 +246,8 @@ export default {
247
246
  '/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
248
247
  '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
249
248
  '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
250
- '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
251
- '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
249
+ '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
250
+ '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
252
251
  '**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**补全权限 · **6**帮助':
253
252
  '**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Complete permissions · **6** Help',
254
253
  '从下方下拉选择补充指令;最后一项可自定义输入。':
@@ -338,6 +337,7 @@ export default {
338
337
 
339
338
  // feishu/feishu-channel.mjs
340
339
  '正在生成…': 'Generating…',
340
+ '⤵️ 最终结果见下方': '⤵️ Final result below',
341
341
  '回答完成': 'Answer complete',
342
342
  '内容较长,生成完成后将分段发送完整回答。':
343
343
  'This response is long. The complete answer will be sent in parts when generation finishes.',
@@ -120,7 +120,8 @@ export default {
120
120
  '{label}机器人已连接 DeepSeek Harness。': 'The {label} bot is connected to DeepSeek Harness.',
121
121
  '/new 开启一个全新会话': '/new Start a brand-new session',
122
122
  '/compact 压缩当前会话的较早上下文': '/compact Compact the earlier context of the current session',
123
- '/workspace 工作区绝对路径 切换工作区': '/workspace <absolute workspace path> Switch workspace',
123
+ '/workspace 工作区序号或绝对路径 切换工作区':
124
+ '/workspace <workspace index or absolute path> Switch workspace',
124
125
  '/workspacelist 列出工作区绝对路径': '/workspacelist List absolute workspace paths',
125
126
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题':
126
127
  '/sessionlist [workspace index or absolute path] List session IDs and titles',
@@ -17,7 +17,8 @@ export default {
17
17
  '当前 Harness Host 上存在的工作区({count}):':
18
18
  'Workspaces on the current Harness Host ({count}):',
19
19
  '(当前)': '(current)',
20
- '切换用法:/workspace 工作区绝对路径': 'To switch: /workspace Workspace absolute path',
20
+ '切换用法:/workspace 工作区序号或绝对路径':
21
+ 'To switch: /workspace Workspace index or absolute path',
21
22
  '查看会话:/sessionlist 工作区序号或绝对路径':
22
23
  'To view Sessions: /sessionlist Workspace index or absolute path',
23
24
  '机器人正在移除或已重新接入,无法列出原会话的工作区。':
@@ -80,10 +81,12 @@ export default {
80
81
  '归档:{archived}': 'Archived: {archived}',
81
82
  '是': 'Yes',
82
83
  '否': 'No',
83
- '用法:/workspace 工作区绝对路径': 'Usage: /workspace Workspace absolute path',
84
+ '用法:/workspace 工作区序号或绝对路径':
85
+ 'Usage: /workspace Workspace index or absolute path',
84
86
  '当前机器人暂不支持切换工作区。': 'This bot does not support switching Workspaces yet.',
85
87
  '工作区已切换为:{workspace}': 'Workspace switched to: {workspace}',
86
- '{message}\n用法:/workspace 工作区绝对路径': '{message}\nUsage: /workspace Workspace absolute path',
88
+ '{message}\n用法:/workspace 工作区序号或绝对路径':
89
+ '{message}\nUsage: /workspace Workspace index or absolute path',
87
90
  '机器人正在移除或已重新接入,无法切换原会话的工作区。':
88
91
  'The bot is being removed or has been reconnected; cannot switch the Workspace of the original Session.',
89
92
 
@@ -81,6 +81,8 @@ export default {
81
81
  // image-prompt.mjs
82
82
  '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。':
83
83
  'The current model does not support images. Use /models to list available models, switch with /model <number>, then resend.',
84
+ '当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。':
85
+ 'The current session model does not accept direct image input. The images sent by the user were saved into the workspace as files (see the file manifest below). Answer by analyzing those image files with the available tools — for example run_code or pwsh to read bytes, parse metadata, or call image-processing or OCR libraries — and do not assume you can see the images directly.',
84
86
  '图片超过宿主允许的大小,请压缩后重试。':
85
87
  'The image exceeds the size allowed by the host; compress it and try again.',
86
88
  '图片分辨率过高,请压缩后重试。':
@@ -157,15 +159,15 @@ export default {
157
159
  '当前聊天有正在运行的任务、待回答问题或待审批请求。\n请先完成当前交互或发送 /stop,再使用 /batch。':
158
160
  'This chat has a running task, unanswered question, or pending approval.\nFinish the current interaction or send /stop before using /batch.',
159
161
  '用法:/{command}(不带参数)': 'Usage: /{command} (without arguments)',
160
- '批量输入命令仅支持纯文字,请移除图片或文件后重试。':
161
- 'Batch input commands support text only. Remove the image or file and try again.',
162
+ '批量输入命令仅支持纯文字,请移除图片、文件或引用消息后重试。':
163
+ 'Batch input commands support text only. Remove the image, file, or quoted message and try again.',
162
164
  '当前没有待提交的批量内容,请先发送 /batch。':
163
165
  'There is no batch to submit. Send /batch first.',
164
166
  '当前没有正在进行的批量输入。': 'There is no active batch input.',
165
167
  '已进入批量输入模式,最多可发送 {limit} 条文字。\n完成后发送 /send,取消请发送 /cancel。':
166
168
  'Batch input started. You can send up to {limit} text messages.\nSend /send when finished or /cancel to cancel.',
167
- '批量输入模式目前仅支持文字,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
168
- 'Batch input currently supports text only, so this message was not collected.\nContinue with text, or use /send or /cancel.',
169
+ '批量输入模式目前仅支持文字,不支持图片、文件或引用消息,这条消息未收录。\n请继续发送文字,或使用 /send、/cancel。':
170
+ 'Batch input currently supports text only, not images, files, or quoted messages, so this message was not collected.\nContinue with text, or use /send or /cancel.',
169
171
  '当前批次正在提交,请勿重复发送 /send。':
170
172
  'The current batch is being submitted. Do not send /send again.',
171
173
  '批量内容已经提交,无法取消。\n如需停止当前任务,请发送 /stop。':
@@ -6,6 +6,12 @@ const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
6
6
 
7
7
  export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
8
8
 
9
+ /**
10
+ * Model-facing guidance appended when the Host refuses image input for the
11
+ * current model and the same images are re-delivered as workspace files.
12
+ */
13
+ export const IMAGE_FILE_FALLBACK_PROMPT = '当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。';
14
+
9
15
  export class ImagePromptError extends Error {
10
16
  constructor(code, message, userMessage, options = {}) {
11
17
  super(message, options);
@@ -299,3 +305,48 @@ export function imagePromptDiagnostic(error) {
299
305
  export function imagePromptUserMessage(error) {
300
306
  return imagePromptDiagnostic(error)?.userMessage ?? null;
301
307
  }
308
+
309
+ const IMAGE_FILE_EXTENSIONS = new Map([
310
+ ['image/png', '.png'],
311
+ ['image/jpeg', '.jpg'],
312
+ ['image/gif', '.gif'],
313
+ ['image/webp', '.webp'],
314
+ ]);
315
+
316
+ const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
317
+
318
+ function imageStorageName(name, mediaType, index) {
319
+ const extension = IMAGE_FILE_EXTENSIONS.get(mediaType) ?? '.img';
320
+ const cleaned = safeName(name);
321
+ if (cleaned && IMAGE_EXTENSION_PATTERN.test(cleaned)) return cleaned;
322
+ return `${cleaned ?? `image-${index + 1}`}${extension}`;
323
+ }
324
+
325
+ /**
326
+ * Convert already-admitted image content blocks into inbound file sources so
327
+ * the same bytes can reach a non-vision model as workspace files — the path
328
+ * ordinary uploads such as zip archives already take.
329
+ */
330
+ export function imageFileSourcesFromContent(content) {
331
+ if (!Array.isArray(content)) return [];
332
+ return content
333
+ .filter((part) => part?.type === 'image')
334
+ .map((part, index) => ({
335
+ name: imageStorageName(part.name, part.mediaType, index),
336
+ ...(typeof part.mediaType === 'string' && part.mediaType.trim()
337
+ ? { mediaType: part.mediaType.trim() }
338
+ : {}),
339
+ data: Buffer.from(typeof part.data === 'string' ? part.data : '', 'base64'),
340
+ }));
341
+ }
342
+
343
+ /** Return the same content with every image block removed. */
344
+ export function contentWithoutImages(content) {
345
+ return Array.isArray(content) ? content.filter((part) => part?.type !== 'image') : content;
346
+ }
347
+
348
+ /** Whether an error is the Host rejecting image input for a non-vision model. */
349
+ export function isModelImageRejection(error) {
350
+ return error?.code === 'attachment-error'
351
+ && error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES';
352
+ }