@xmanrui/dsh-im 4.5.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.
- package/lib/client.js +4 -1
- package/lib/index.js +247 -239
- package/package.json +1 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +166 -20
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
- package/src/channels/dingtalk/state-store.mjs +98 -0
- package/src/channels/discord/discord-api.mjs +7 -0
- package/src/channels/discord/discord-runtime.mjs +97 -2
- package/src/channels/feishu/bridge.mjs +11 -5
- package/src/channels/feishu/message-utils.mjs +229 -0
- package/src/channels/qq/qq-bridge.mjs +48 -6
- package/src/channels/shared/batch-input.mjs +3 -3
- package/src/channels/shared/harness-client.mjs +82 -30
- package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
- package/src/channels/shared/image-prompt.mjs +51 -0
- package/src/channels/shared/semantic/reply-reference.mjs +153 -0
- package/src/channels/shared/session-reply-recovery.mjs +104 -0
- package/src/channels/shared/session-title.mjs +1 -1
- package/src/channels/shared/text-harness-bridge.mjs +12 -6
- package/src/channels/slack/manifest.mjs +3 -0
- package/src/channels/slack/slack-api.mjs +18 -0
- package/src/channels/slack/slack-runtime.mjs +56 -0
- package/src/channels/telegram/telegram-runtime.mjs +117 -2
- package/src/channels/wecom/wecom-bridge.mjs +49 -7
- package/src/channels/weixin/state-store.mjs +110 -0
- package/src/channels/weixin/weixin-api.mjs +86 -2
- package/src/channels/weixin/weixin-bridge.mjs +96 -9
- package/src/channels/weixin/weixin-runtime.mjs +26 -6
- package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
|
@@ -2,8 +2,14 @@ import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
|
2
2
|
import { t } from '../shared/i18n.mjs';
|
|
3
3
|
|
|
4
4
|
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
5
|
+
const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
|
|
5
6
|
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
6
7
|
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
8
|
+
const FEISHU_CARD_TEXT_MAX_DEPTH = 12;
|
|
9
|
+
const FEISHU_CARD_TEXT_MAX_NODES = 1_000;
|
|
10
|
+
const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
|
|
11
|
+
'请升级至最新版本客户端,以查看内容',
|
|
12
|
+
]);
|
|
7
13
|
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
8
14
|
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
|
9
15
|
|
|
@@ -56,6 +62,124 @@ function nonEmptyString(value) {
|
|
|
56
62
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
function objectRecord(value) {
|
|
66
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonRecord(value) {
|
|
70
|
+
if (objectRecord(value)) return value;
|
|
71
|
+
if (typeof value !== 'string') return null;
|
|
72
|
+
try {
|
|
73
|
+
return objectRecord(JSON.parse(value));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function interactiveCardRoot(parsed) {
|
|
80
|
+
const root = objectRecord(parsed);
|
|
81
|
+
if (!root) return null;
|
|
82
|
+
// raw_card_content wraps CardKit entities in json_card. Keep direct Card
|
|
83
|
+
// 1.0/2.0 payloads readable as well for historical messages and fixtures.
|
|
84
|
+
return jsonRecord(root.json_card) ?? jsonRecord(root.card) ?? root;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cardProperty(value) {
|
|
88
|
+
const record = objectRecord(value);
|
|
89
|
+
return objectRecord(record?.property) ?? record;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cardTextContent(property) {
|
|
93
|
+
const i18n = objectRecord(property?.i18nContent);
|
|
94
|
+
const content = nonEmptyString(i18n?.zh_cn)
|
|
95
|
+
?? nonEmptyString(i18n?.en_us)
|
|
96
|
+
?? nonEmptyString(i18n?.ja_jp)
|
|
97
|
+
?? nonEmptyString(property?.content)
|
|
98
|
+
?? nonEmptyString(property?.text);
|
|
99
|
+
return content && !FEISHU_CARD_UNAVAILABLE_TEXTS.has(content) ? content : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cardElementText(
|
|
103
|
+
element,
|
|
104
|
+
{ depth = 0, inline = false, budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES } } = {},
|
|
105
|
+
) {
|
|
106
|
+
if (depth > FEISHU_CARD_TEXT_MAX_DEPTH || budget.remaining <= 0) return '';
|
|
107
|
+
budget.remaining -= 1;
|
|
108
|
+
if (Array.isArray(element)) {
|
|
109
|
+
return element
|
|
110
|
+
.map((part) => cardElementText(part, {
|
|
111
|
+
depth: depth + 1,
|
|
112
|
+
inline: Array.isArray(part),
|
|
113
|
+
budget,
|
|
114
|
+
}))
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(inline ? ' ' : '\n');
|
|
117
|
+
}
|
|
118
|
+
const value = objectRecord(element);
|
|
119
|
+
if (!value) return '';
|
|
120
|
+
const property = cardProperty(value);
|
|
121
|
+
if (!property) return '';
|
|
122
|
+
const tag = String(value.tag ?? '').toLowerCase();
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
tag === 'markdown'
|
|
126
|
+
|| tag === 'markdown_v1'
|
|
127
|
+
|| tag === 'lark_md'
|
|
128
|
+
|| tag === 'plain_text'
|
|
129
|
+
|| tag === 'text'
|
|
130
|
+
) {
|
|
131
|
+
const content = cardTextContent(property);
|
|
132
|
+
if (content) return content;
|
|
133
|
+
const nested = cardElementText(property.elements, {
|
|
134
|
+
depth: depth + 1,
|
|
135
|
+
inline: true,
|
|
136
|
+
budget,
|
|
137
|
+
});
|
|
138
|
+
if (nested || tag !== 'markdown_v1') return nested;
|
|
139
|
+
return cardElementText(value.fallback ?? property.fallback, {
|
|
140
|
+
depth: depth + 1,
|
|
141
|
+
inline: true,
|
|
142
|
+
budget,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (tag === 'a' || tag === 'link' || tag === 'button') {
|
|
146
|
+
if (typeof property.text === 'string') return nonEmptyString(property.text) ?? '';
|
|
147
|
+
return cardElementText(property.text, { depth: depth + 1, inline: true, budget });
|
|
148
|
+
}
|
|
149
|
+
if (tag === 'div') {
|
|
150
|
+
return [
|
|
151
|
+
cardElementText(property.text, { depth: depth + 1, budget }),
|
|
152
|
+
cardElementText(property.fields, { depth: depth + 1, budget }),
|
|
153
|
+
].filter(Boolean).join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Traverse visible layout containers only. Deliberately ignore callback
|
|
157
|
+
// values, form state, URLs, ids, template variables and other hidden data.
|
|
158
|
+
return ['elements', 'columns', 'fields', 'children', 'actions']
|
|
159
|
+
.map((key) => cardElementText(property[key], { depth: depth + 1, budget }))
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function interactiveCardText(parsed) {
|
|
165
|
+
const card = interactiveCardRoot(parsed);
|
|
166
|
+
if (!card) return '';
|
|
167
|
+
const budget = { remaining: FEISHU_CARD_TEXT_MAX_NODES };
|
|
168
|
+
const header = cardProperty(card.header);
|
|
169
|
+
const title = [
|
|
170
|
+
cardElementText(header?.title, { inline: true, budget }),
|
|
171
|
+
cardElementText(header?.subtitle, { inline: true, budget }),
|
|
172
|
+
].filter(Boolean).join('\n') || nonEmptyString(card.title) || '';
|
|
173
|
+
const body = cardProperty(card.body);
|
|
174
|
+
const elements = Array.isArray(body?.elements)
|
|
175
|
+
? body.elements
|
|
176
|
+
: Array.isArray(card.elements)
|
|
177
|
+
? card.elements
|
|
178
|
+
: null;
|
|
179
|
+
const content = cardElementText(elements, { budget });
|
|
180
|
+
return [title, content].filter(Boolean).join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
59
183
|
function postContent(event, parsed = parsedMessageContent(event)) {
|
|
60
184
|
if (event?.message?.message_type !== 'post') return null;
|
|
61
185
|
if (!parsed) return null;
|
|
@@ -283,6 +407,109 @@ function feishuFileSource(event, client, file) {
|
|
|
283
407
|
};
|
|
284
408
|
}
|
|
285
409
|
|
|
410
|
+
function feishuReplyTargetId(event) {
|
|
411
|
+
const parentId = nonEmptyString(event?.message?.parent_id);
|
|
412
|
+
if (parentId) return parentId;
|
|
413
|
+
const rootId = nonEmptyString(event?.message?.root_id);
|
|
414
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
415
|
+
return rootId && rootId !== messageId ? rootId : null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function feishuReplyAttachments(messageType, parsed, post) {
|
|
419
|
+
if (messageType === 'post') {
|
|
420
|
+
return (post?.imageKeys ?? []).map(() => ({ kind: 'image' }));
|
|
421
|
+
}
|
|
422
|
+
if (messageType === 'image') return [{ kind: 'image' }];
|
|
423
|
+
if (messageType === 'file') {
|
|
424
|
+
return [{
|
|
425
|
+
kind: 'file',
|
|
426
|
+
...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
|
|
427
|
+
}];
|
|
428
|
+
}
|
|
429
|
+
if (messageType === 'audio') return [{ kind: 'audio' }];
|
|
430
|
+
if (messageType === 'media') {
|
|
431
|
+
return [{
|
|
432
|
+
kind: 'video',
|
|
433
|
+
...(nonEmptyString(parsed?.file_name) ? { name: nonEmptyString(parsed.file_name) } : {}),
|
|
434
|
+
}];
|
|
435
|
+
}
|
|
436
|
+
if (messageType === 'sticker') return [{ kind: 'other' }];
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function feishuReplyReference(event, client) {
|
|
441
|
+
const messageId = feishuReplyTargetId(event);
|
|
442
|
+
if (!messageId) return null;
|
|
443
|
+
const chatId = nonEmptyString(event?.message?.chat_id);
|
|
444
|
+
return {
|
|
445
|
+
messageId,
|
|
446
|
+
async load({ signal } = {}) {
|
|
447
|
+
signal?.throwIfAborted();
|
|
448
|
+
let response;
|
|
449
|
+
try {
|
|
450
|
+
response = await client?.im?.v1?.message?.get?.({
|
|
451
|
+
path: { message_id: messageId },
|
|
452
|
+
params: {
|
|
453
|
+
with_sender_name: true,
|
|
454
|
+
card_msg_content_type: FEISHU_CARD_MESSAGE_CONTENT_TYPE,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
signal?.throwIfAborted();
|
|
459
|
+
if (await feishuProviderCode(error, signal) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
460
|
+
return { messageId, unavailableReason: 'permission-denied' };
|
|
461
|
+
}
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
signal?.throwIfAborted();
|
|
465
|
+
if (providerCode(response) === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
466
|
+
return { messageId, unavailableReason: 'permission-denied' };
|
|
467
|
+
}
|
|
468
|
+
if (providerCode(response) !== null && providerCode(response) !== 0) {
|
|
469
|
+
return { messageId, unavailableReason: 'not-delivered' };
|
|
470
|
+
}
|
|
471
|
+
const item = response?.data?.items?.find?.(
|
|
472
|
+
(candidate) => nonEmptyString(candidate?.message_id) === messageId,
|
|
473
|
+
);
|
|
474
|
+
if (!item) return { messageId, unavailableReason: 'not-found' };
|
|
475
|
+
if (item.deleted) return { messageId, unavailableReason: 'deleted' };
|
|
476
|
+
const itemChatId = nonEmptyString(item.chat_id);
|
|
477
|
+
if (!chatId || !itemChatId || itemChatId !== chatId) {
|
|
478
|
+
return { messageId, unavailableReason: 'not-found' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const messageType = nonEmptyString(item.msg_type) ?? '';
|
|
482
|
+
const quotedEvent = {
|
|
483
|
+
message: {
|
|
484
|
+
message_id: messageId,
|
|
485
|
+
message_type: messageType,
|
|
486
|
+
content: item.body?.content,
|
|
487
|
+
mentions: item.mentions ?? [],
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
const parsed = parsedMessageContent(quotedEvent);
|
|
491
|
+
const post = postContent(quotedEvent, parsed);
|
|
492
|
+
const quoted = extractInboundMessage(quotedEvent, client);
|
|
493
|
+
const content = messageType === 'interactive'
|
|
494
|
+
? interactiveCardText(parsed)
|
|
495
|
+
: quoted.content;
|
|
496
|
+
const attachments = feishuReplyAttachments(messageType, parsed, post);
|
|
497
|
+
return {
|
|
498
|
+
messageId,
|
|
499
|
+
...(nonEmptyString(item.sender?.id) ? { authorId: nonEmptyString(item.sender.id) } : {}),
|
|
500
|
+
...(nonEmptyString(item.sender?.sender_name)
|
|
501
|
+
? { authorName: nonEmptyString(item.sender.sender_name) }
|
|
502
|
+
: {}),
|
|
503
|
+
...(content ? { content } : {}),
|
|
504
|
+
attachments,
|
|
505
|
+
...(messageType === 'interactive' && !content && attachments.length === 0
|
|
506
|
+
? { unavailableReason: 'unsupported' }
|
|
507
|
+
: {}),
|
|
508
|
+
};
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
286
513
|
export function extractInboundMessage(event, client) {
|
|
287
514
|
const messageType = event?.message?.message_type;
|
|
288
515
|
const parsed = parsedMessageContent(event);
|
|
@@ -292,10 +519,12 @@ export function extractInboundMessage(event, client) {
|
|
|
292
519
|
: null;
|
|
293
520
|
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
|
|
294
521
|
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
|
|
522
|
+
const replyTo = feishuReplyReference(event, client);
|
|
295
523
|
return {
|
|
296
524
|
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
|
|
297
525
|
images: imageKeys.map((key) => feishuImageSource(event, client, key)),
|
|
298
526
|
files: file ? [file] : [],
|
|
527
|
+
...(replyTo ? { replyTo } : {}),
|
|
299
528
|
};
|
|
300
529
|
}
|
|
301
530
|
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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,8 +878,8 @@ export class QqHarnessBridge {
|
|
|
836
878
|
return;
|
|
837
879
|
}
|
|
838
880
|
|
|
839
|
-
let content = hasImages
|
|
840
|
-
? await
|
|
881
|
+
let content = hasImages || hasReply
|
|
882
|
+
? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
|
|
841
883
|
: undefined;
|
|
842
884
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
843
885
|
let contextEnhanced = false;
|
|
@@ -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,
|
|
@@ -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';
|
|
@@ -1242,6 +1248,31 @@ export class HarnessClient {
|
|
|
1242
1248
|
return ownership ? { ownership, recovered: true } : null;
|
|
1243
1249
|
}
|
|
1244
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
|
+
|
|
1245
1276
|
async ask(sessionId, prompt, options = {}) {
|
|
1246
1277
|
if (typeof options === 'number') options = { timeoutMs: options };
|
|
1247
1278
|
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
@@ -1298,7 +1329,7 @@ export class HarnessClient {
|
|
|
1298
1329
|
let interactionTask = null;
|
|
1299
1330
|
let artifactsDelivered = false;
|
|
1300
1331
|
let deliveredArtifactCount = 0;
|
|
1301
|
-
|
|
1332
|
+
const stagedBatches = [];
|
|
1302
1333
|
let promptAccepted = false;
|
|
1303
1334
|
let turnFinished = false;
|
|
1304
1335
|
|
|
@@ -1329,29 +1360,11 @@ export class HarnessClient {
|
|
|
1329
1360
|
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
|
|
1330
1361
|
|
|
1331
1362
|
try {
|
|
1363
|
+
const basePrompt = prompt;
|
|
1332
1364
|
if (inboundFiles.length > 0) {
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
'Harness file ingress is unavailable in this Host process.',
|
|
1337
|
-
);
|
|
1338
|
-
}
|
|
1339
|
-
const sessionList = await this.rpc(
|
|
1340
|
-
'session.list',
|
|
1341
|
-
{},
|
|
1342
|
-
30_000,
|
|
1343
|
-
{ signal },
|
|
1344
|
-
);
|
|
1345
|
-
const sessionWorkspace = sessionList?.items?.find(
|
|
1346
|
-
(item) => item?.sessionId === sessionId,
|
|
1347
|
-
)?.cwd;
|
|
1348
|
-
stagedInboundFiles = await this.#fileIngressExecutor({
|
|
1349
|
-
sessionId,
|
|
1350
|
-
workspace: sessionWorkspace,
|
|
1351
|
-
files: inboundFiles,
|
|
1352
|
-
signal,
|
|
1353
|
-
});
|
|
1354
|
-
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
|
|
1365
|
+
const staged = await this.#stageWorkspaceFiles(sessionId, inboundFiles, signal);
|
|
1366
|
+
stagedBatches.push(staged);
|
|
1367
|
+
prompt = appendInboundFilesToPrompt(prompt, staged);
|
|
1355
1368
|
}
|
|
1356
1369
|
if (interactionSignal) {
|
|
1357
1370
|
let markOpen;
|
|
@@ -1378,12 +1391,47 @@ export class HarnessClient {
|
|
|
1378
1391
|
if (!Array.isArray(content) || content.length === 0) {
|
|
1379
1392
|
throw new TypeError('Harness prompt content is required');
|
|
1380
1393
|
}
|
|
1381
|
-
|
|
1394
|
+
const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1395
|
+
const sendPrompt = (promptContent) => this.rpc('session.prompt', {
|
|
1382
1396
|
sessionId,
|
|
1383
1397
|
mode: 'queue',
|
|
1384
|
-
content,
|
|
1385
|
-
clientTimeZone
|
|
1398
|
+
content: promptContent,
|
|
1399
|
+
clientTimeZone,
|
|
1386
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
|
+
}
|
|
1387
1435
|
promptAccepted = true;
|
|
1388
1436
|
|
|
1389
1437
|
try {
|
|
@@ -1441,10 +1489,14 @@ export class HarnessClient {
|
|
|
1441
1489
|
throw turnStoppedError();
|
|
1442
1490
|
}
|
|
1443
1491
|
} finally {
|
|
1444
|
-
if (
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
+
}
|
|
1448
1500
|
}
|
|
1449
1501
|
closeArtifactConsumer();
|
|
1450
1502
|
if (ownership) {
|
|
@@ -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
|
|
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
|
-
'
|
|
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
|
+
}
|