@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.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +29 -9
- package/lib/index.js +252 -241
- package/package.json +1 -1
- package/plugin-src/client/context-enhancement.js +19 -5
- package/plugin-src/client/i18n.js +5 -1
- package/plugin-src/host/modern-harness-api.mjs +3 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
- 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 +30 -7
- package/src/channels/feishu/feishu-cards.mjs +2 -2
- package/src/channels/feishu/feishu-channel.mjs +36 -6
- package/src/channels/feishu/message-utils.mjs +229 -0
- package/src/channels/qq/qq-bridge.mjs +56 -9
- package/src/channels/shared/batch-input.mjs +3 -3
- package/src/channels/shared/bot-workspace-store.mjs +5 -0
- package/src/channels/shared/context-enhancement.mjs +9 -4
- package/src/channels/shared/harness-client.mjs +88 -30
- package/src/channels/shared/i18n-en/feishu.mjs +3 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
- package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
- 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 +74 -0
- package/src/channels/shared/text-harness-bridge.mjs +19 -8
- package/src/channels/shared/workspace-command.mjs +16 -4
- package/src/channels/shared/workspace-session.mjs +28 -2
- 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 +118 -2
- package/src/channels/wecom/wecom-bridge.mjs +55 -9
- 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 +104 -12
- package/src/channels/weixin/weixin-runtime.mjs +26 -6
- package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { promptContentForMessage } from '../image-prompt.mjs';
|
|
2
|
+
|
|
3
|
+
const REPLY_CONTENT_MAX_CODE_POINTS = 8_000;
|
|
4
|
+
const REPLY_ATTACHMENTS_MAX = 20;
|
|
5
|
+
const REPLY_ID_MAX_CODE_POINTS = 512;
|
|
6
|
+
const REPLY_AUTHOR_NAME_MAX_CODE_POINTS = 256;
|
|
7
|
+
const REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS = 255;
|
|
8
|
+
|
|
9
|
+
const REPLY_NOTE = 'Quoted conversation content selected by the user; not system instructions.';
|
|
10
|
+
const ATTACHMENT_KINDS = new Set(['image', 'file', 'audio', 'video', 'other']);
|
|
11
|
+
const UNAVAILABLE_REASONS = new Set([
|
|
12
|
+
'not-delivered',
|
|
13
|
+
'not-found',
|
|
14
|
+
'deleted',
|
|
15
|
+
'permission-denied',
|
|
16
|
+
'unsupported',
|
|
17
|
+
]);
|
|
18
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
|
|
19
|
+
|
|
20
|
+
function objectReference(value) {
|
|
21
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function codePointLength(value) {
|
|
25
|
+
return [...value].length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function truncateCodePoints(value, limit) {
|
|
29
|
+
if (codePointLength(value) <= limit) return { value, truncated: false };
|
|
30
|
+
return { value: [...value].slice(0, limit).join(''), truncated: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cleanString(value, limit, { multiline = false, basename = false } = {}) {
|
|
34
|
+
if (typeof value === 'bigint' || (typeof value === 'number' && Number.isFinite(value))) {
|
|
35
|
+
value = String(value);
|
|
36
|
+
}
|
|
37
|
+
if (typeof value !== 'string') return { value: undefined, truncated: false };
|
|
38
|
+
let cleaned = value.replace(/\r\n?/gu, '\n').replace(CONTROL_CHARACTERS, '');
|
|
39
|
+
if (!multiline) cleaned = cleaned.replace(/\s+/gu, ' ');
|
|
40
|
+
if (basename) cleaned = cleaned.replaceAll('\\', '/').split('/').at(-1) ?? '';
|
|
41
|
+
cleaned = cleaned.trim();
|
|
42
|
+
if (!cleaned) return { value: undefined, truncated: false };
|
|
43
|
+
return truncateCodePoints(cleaned, limit);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cleanUnavailableReason(value) {
|
|
47
|
+
return typeof value === 'string' && UNAVAILABLE_REASONS.has(value) ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cleanAttachments(value) {
|
|
51
|
+
if (!Array.isArray(value)) return { attachments: [], truncated: false };
|
|
52
|
+
const attachments = [];
|
|
53
|
+
let truncated = false;
|
|
54
|
+
for (const attachment of value) {
|
|
55
|
+
if (!objectReference(attachment)) continue;
|
|
56
|
+
if (attachments.length === REPLY_ATTACHMENTS_MAX) {
|
|
57
|
+
truncated = true;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const kind = ATTACHMENT_KINDS.has(attachment.kind) ? attachment.kind : 'other';
|
|
61
|
+
const name = cleanString(attachment.name, REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS, {
|
|
62
|
+
basename: true,
|
|
63
|
+
});
|
|
64
|
+
truncated ||= name.truncated;
|
|
65
|
+
attachments.push({ kind, ...(name.value ? { name: name.value } : {}) });
|
|
66
|
+
}
|
|
67
|
+
return { attachments, truncated };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errorUnavailableReason(error) {
|
|
71
|
+
const supplied = cleanUnavailableReason(error?.code);
|
|
72
|
+
if (supplied) return supplied;
|
|
73
|
+
const status = Number(error?.status ?? error?.statusCode ?? error?.response?.status);
|
|
74
|
+
if (status === 401 || status === 403) return 'permission-denied';
|
|
75
|
+
if (status === 404) return 'not-found';
|
|
76
|
+
if (status === 410) return 'deleted';
|
|
77
|
+
return 'not-delivered';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mergeDefined(base, loaded) {
|
|
81
|
+
const merged = { ...base };
|
|
82
|
+
for (const key of [
|
|
83
|
+
'messageId', 'authorId', 'authorName', 'content', 'attachments', 'unavailableReason',
|
|
84
|
+
]) {
|
|
85
|
+
if (loaded[key] !== undefined) merged[key] = loaded[key];
|
|
86
|
+
}
|
|
87
|
+
return merged;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function resolveReference(reference, signal) {
|
|
91
|
+
if (typeof reference.load !== 'function') return reference;
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
try {
|
|
94
|
+
const loaded = await reference.load({ signal });
|
|
95
|
+
signal?.throwIfAborted();
|
|
96
|
+
if (loaded === null) return { ...reference, unavailableReason: 'not-found' };
|
|
97
|
+
if (!objectReference(loaded)) {
|
|
98
|
+
return { ...reference, unavailableReason: 'not-delivered' };
|
|
99
|
+
}
|
|
100
|
+
return mergeDefined(reference, loaded);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
103
|
+
return { ...reference, unavailableReason: errorUnavailableReason(error) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeReference(reference) {
|
|
108
|
+
const messageId = cleanString(reference.messageId, REPLY_ID_MAX_CODE_POINTS);
|
|
109
|
+
const authorId = cleanString(reference.authorId, REPLY_ID_MAX_CODE_POINTS);
|
|
110
|
+
const authorName = cleanString(reference.authorName, REPLY_AUTHOR_NAME_MAX_CODE_POINTS);
|
|
111
|
+
const content = cleanString(reference.content, REPLY_CONTENT_MAX_CODE_POINTS, { multiline: true });
|
|
112
|
+
const { attachments, truncated: attachmentsTruncated } = cleanAttachments(reference.attachments);
|
|
113
|
+
let unavailableReason = cleanUnavailableReason(reference.unavailableReason);
|
|
114
|
+
if (!content.value && attachments.length === 0 && !unavailableReason) {
|
|
115
|
+
unavailableReason = 'not-delivered';
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
note: REPLY_NOTE,
|
|
119
|
+
...(messageId.value ? { messageId: messageId.value } : {}),
|
|
120
|
+
...(authorId.value ? { authorId: authorId.value } : {}),
|
|
121
|
+
...(authorName.value ? { authorName: authorName.value } : {}),
|
|
122
|
+
...(content.value ? { content: content.value } : {}),
|
|
123
|
+
attachments,
|
|
124
|
+
...(unavailableReason ? { unavailableReason } : {}),
|
|
125
|
+
truncated: messageId.truncated
|
|
126
|
+
|| authorId.truncated
|
|
127
|
+
|| authorName.truncated
|
|
128
|
+
|| content.truncated
|
|
129
|
+
|| attachmentsTruncated,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function replyBlock(reference) {
|
|
134
|
+
const json = JSON.stringify(reference).replace(/[<>&]/gu, (character) => ({
|
|
135
|
+
'<': '\\u003c',
|
|
136
|
+
'>': '\\u003e',
|
|
137
|
+
'&': '\\u0026',
|
|
138
|
+
})[character]);
|
|
139
|
+
return `<dsh_im_reply_to>${json}</dsh_im_reply_to>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function hasReplyReference(message) {
|
|
143
|
+
return objectReference(message?.replyTo);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function promptContentForInboundMessage(message, { signal } = {}) {
|
|
147
|
+
if (!hasReplyReference(message)) {
|
|
148
|
+
return promptContentForMessage(message, { signal });
|
|
149
|
+
}
|
|
150
|
+
const reference = normalizeReference(await resolveReference(message.replyTo, signal));
|
|
151
|
+
const currentContent = await promptContentForMessage(message, { signal });
|
|
152
|
+
return [{ type: 'text', text: replyBlock(reference) }, ...currentContent];
|
|
153
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
function assistantText(event) {
|
|
2
|
+
if (event?.type !== 'assistant/message'
|
|
3
|
+
|| !Number.isSafeInteger(event.data?.turn)
|
|
4
|
+
|| event.data.turn < 0
|
|
5
|
+
|| !Array.isArray(event.data?.message?.content)) return null;
|
|
6
|
+
const text = event.data.message.content
|
|
7
|
+
.flatMap((block) => (block?.type === 'text' && typeof block.text === 'string'
|
|
8
|
+
? [block.text]
|
|
9
|
+
: []))
|
|
10
|
+
.join('\n')
|
|
11
|
+
.trim();
|
|
12
|
+
return text ? { turn: event.data.turn, time: event.time, text } : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function completedAssistantTurns(events) {
|
|
16
|
+
const starts = new Map();
|
|
17
|
+
const assistants = new Map();
|
|
18
|
+
const completed = [];
|
|
19
|
+
for (const event of [...events].sort((left, right) => left.seq - right.seq)) {
|
|
20
|
+
if (event?.type === 'turn/start' && Number.isSafeInteger(event.data?.turn)) {
|
|
21
|
+
starts.set(event.data.turn, event.time);
|
|
22
|
+
}
|
|
23
|
+
const assistant = assistantText(event);
|
|
24
|
+
if (assistant) assistants.set(assistant.turn, assistant);
|
|
25
|
+
if (event?.type !== 'turn/end' || !Number.isSafeInteger(event.data?.turn)) continue;
|
|
26
|
+
const final = assistants.get(event.data.turn);
|
|
27
|
+
assistants.delete(event.data.turn);
|
|
28
|
+
if ((event.data?.reason?.kind ?? event.data?.reason) !== 'completed' || !final) continue;
|
|
29
|
+
completed.push({
|
|
30
|
+
...final,
|
|
31
|
+
startedAt: starts.get(event.data.turn),
|
|
32
|
+
completedAt: event.time,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return completed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function matchingAssistantText(events, quotedAt, toleranceMs) {
|
|
39
|
+
const candidates = completedAssistantTurns(events).filter((entry) => {
|
|
40
|
+
if (Number.isFinite(entry.time) && Math.abs(entry.time - quotedAt) <= toleranceMs) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return Number.isFinite(entry.startedAt) && Number.isFinite(entry.completedAt)
|
|
44
|
+
&& quotedAt >= entry.startedAt - toleranceMs
|
|
45
|
+
&& quotedAt <= entry.completedAt + toleranceMs;
|
|
46
|
+
});
|
|
47
|
+
return candidates.length === 1 ? candidates[0].text : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recover one completed assistant answer near a provider message timestamp.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} options Recovery inputs.
|
|
54
|
+
* @param {{readHistory: Function}} options.session Bound Harness Session handle.
|
|
55
|
+
* @param {number} options.quotedAt Provider message timestamp in milliseconds.
|
|
56
|
+
* @param {AbortSignal} [options.signal] Caller cancellation signal.
|
|
57
|
+
* @param {number} [options.pageSize=100] History events requested per page.
|
|
58
|
+
* @param {number} [options.maxPages=3] Maximum history pages to inspect.
|
|
59
|
+
* @param {number} [options.timeoutMs=5000] Total history read deadline.
|
|
60
|
+
* @param {number} [options.toleranceMs=15000] Provider/session clock tolerance.
|
|
61
|
+
* @returns {Promise<string|null>} The unique matching answer, or null.
|
|
62
|
+
*/
|
|
63
|
+
export async function recoverAssistantTextByTimestamp({
|
|
64
|
+
session,
|
|
65
|
+
quotedAt,
|
|
66
|
+
signal: callerSignal,
|
|
67
|
+
pageSize = 100,
|
|
68
|
+
maxPages = 3,
|
|
69
|
+
timeoutMs = 5_000,
|
|
70
|
+
toleranceMs = 15_000,
|
|
71
|
+
} = {}) {
|
|
72
|
+
if (typeof session?.readHistory !== 'function' || !Number.isFinite(quotedAt)) return null;
|
|
73
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
74
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
const events = new Map();
|
|
77
|
+
let beforeSeq;
|
|
78
|
+
for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
|
|
79
|
+
signal.throwIfAborted();
|
|
80
|
+
const page = await session.readHistory({
|
|
81
|
+
maxMessages: pageSize,
|
|
82
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
83
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
84
|
+
signal,
|
|
85
|
+
});
|
|
86
|
+
if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') return null;
|
|
87
|
+
let oldestSeq = beforeSeq ?? Infinity;
|
|
88
|
+
let oldestTime = Infinity;
|
|
89
|
+
for (const entry of page.events) {
|
|
90
|
+
const event = entry?.event;
|
|
91
|
+
if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0) continue;
|
|
92
|
+
events.set(event.seq, event);
|
|
93
|
+
oldestSeq = Math.min(oldestSeq, event.seq);
|
|
94
|
+
if (Number.isFinite(event.time)) oldestTime = Math.min(oldestTime, event.time);
|
|
95
|
+
}
|
|
96
|
+
const text = matchingAssistantText([...events.values()], quotedAt, toleranceMs);
|
|
97
|
+
const passedTarget = oldestTime <= quotedAt - toleranceMs;
|
|
98
|
+
if (text && (passedTarget || !page.hasMore)) return text;
|
|
99
|
+
if (!page.hasMore || passedTarget
|
|
100
|
+
|| !Number.isFinite(oldestSeq) || oldestSeq === beforeSeq) break;
|
|
101
|
+
beforeSeq = oldestSeq;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const DEFAULT_MAX_TITLE_BYTES = 60;
|
|
2
|
+
|
|
3
|
+
const OSC_SEQUENCE = /(?:\u001b\]|\u009d)(?:(?!\u0007|\u001b\\)[\s\S])*(?:\u0007|\u001b\\|$)/gu;
|
|
4
|
+
const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu;
|
|
5
|
+
const ESC_SEQUENCE = /\u001b[@-_]/gu;
|
|
6
|
+
const CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
|
|
7
|
+
const DIRECTIONAL_CONTROL = /[\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
|
|
8
|
+
const INJECTED_CONTEXT_PREFIX = /^(?:<dsh_im_source>[\s\S]*?<\/dsh_im_source>\s*)?(?:<dsh_im_source_guidance>[\s\S]*?<\/dsh_im_source_guidance>\s*)?(?:<dsh_im_reply_to>[\s\S]*?<\/dsh_im_reply_to>\s*)?/u;
|
|
9
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
10
|
+
|
|
11
|
+
function cleanTitleText(input) {
|
|
12
|
+
return input
|
|
13
|
+
.replace(OSC_SEQUENCE, '')
|
|
14
|
+
.replace(CSI_SEQUENCE, '')
|
|
15
|
+
.replace(ESC_SEQUENCE, '')
|
|
16
|
+
.replace(CONTROL_CHARACTER, '')
|
|
17
|
+
.replace(DIRECTIONAL_CONTROL, '')
|
|
18
|
+
.replace(/\s+/gu, ' ')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function truncateTitle(input, maxBytes = DEFAULT_MAX_TITLE_BYTES) {
|
|
23
|
+
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
|
|
24
|
+
const suffix = '…';
|
|
25
|
+
const budget = maxBytes - Buffer.byteLength(suffix, 'utf8');
|
|
26
|
+
let output = '';
|
|
27
|
+
for (const { segment } of SEGMENTER.segment(input)) {
|
|
28
|
+
if (Buffer.byteLength(output + segment, 'utf8') > budget) break;
|
|
29
|
+
output += segment;
|
|
30
|
+
}
|
|
31
|
+
return `${output.trimEnd()}${suffix}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function contentText(content) {
|
|
35
|
+
if (typeof content === 'string') return content;
|
|
36
|
+
if (!Array.isArray(content)) return '';
|
|
37
|
+
return content
|
|
38
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
39
|
+
.map((block) => block.text)
|
|
40
|
+
.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fileDisplayName(file) {
|
|
44
|
+
const value = file?.name ?? file?.filename ?? file?.fileName;
|
|
45
|
+
if (typeof value !== 'string') return '';
|
|
46
|
+
return value
|
|
47
|
+
.replaceAll('\\', '/')
|
|
48
|
+
.split('/')
|
|
49
|
+
.at(-1)
|
|
50
|
+
?.replace(/[\u0000-\u001f\u007f]/gu, '')
|
|
51
|
+
.trim() ?? '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a deterministic title from the unenhanced first user message. */
|
|
55
|
+
export function initialSessionTitle({ text, content, files } = {}) {
|
|
56
|
+
const original = typeof text === 'string' ? cleanTitleText(text) : '';
|
|
57
|
+
if (original) return truncateTitle(original);
|
|
58
|
+
|
|
59
|
+
// Structured image prompts may only expose their default text through content.
|
|
60
|
+
// Strip only the leading blocks inserted by dsh-im; matching tags later in user
|
|
61
|
+
// content remain ordinary user text.
|
|
62
|
+
const visibleContent = cleanTitleText(
|
|
63
|
+
contentText(content).replace(INJECTED_CONTEXT_PREFIX, ''),
|
|
64
|
+
);
|
|
65
|
+
if (visibleContent) return truncateTitle(visibleContent);
|
|
66
|
+
|
|
67
|
+
const fileName = Array.isArray(files)
|
|
68
|
+
? files.map(fileDisplayName).find(Boolean)
|
|
69
|
+
: '';
|
|
70
|
+
const cleanFileName = fileName ? cleanTitleText(fileName) : '';
|
|
71
|
+
return cleanFileName ? truncateTitle(cleanFileName) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const INITIAL_SESSION_TITLE_MAX_BYTES = DEFAULT_MAX_TITLE_BYTES;
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
hasInboundImages,
|
|
36
36
|
imagePromptDiagnostic,
|
|
37
37
|
imagePromptUserMessage,
|
|
38
|
-
promptContentForMessage,
|
|
39
38
|
} from './image-prompt.mjs';
|
|
40
39
|
import {
|
|
41
40
|
hasInboundFiles,
|
|
@@ -47,6 +46,10 @@ import {
|
|
|
47
46
|
validHarnessQuestion,
|
|
48
47
|
} from './harness-question.mjs';
|
|
49
48
|
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
|
|
49
|
+
import {
|
|
50
|
+
hasReplyReference,
|
|
51
|
+
promptContentForInboundMessage,
|
|
52
|
+
} from './semantic/reply-reference.mjs';
|
|
50
53
|
import {
|
|
51
54
|
createDeliveryReceipt,
|
|
52
55
|
createTextDeliveryBlock,
|
|
@@ -283,7 +286,8 @@ export class TextHarnessBridge {
|
|
|
283
286
|
plainText: Boolean(text)
|
|
284
287
|
&& normalized.plainText !== false
|
|
285
288
|
&& !hasInboundImages(normalized)
|
|
286
|
-
&& !hasInboundFiles(normalized)
|
|
289
|
+
&& !hasInboundFiles(normalized)
|
|
290
|
+
&& !hasReplyReference(normalized),
|
|
287
291
|
});
|
|
288
292
|
if (batch.handled) {
|
|
289
293
|
if (batch.kind === 'submit') {
|
|
@@ -301,7 +305,8 @@ export class TextHarnessBridge {
|
|
|
301
305
|
plainText: Boolean(text)
|
|
302
306
|
&& normalized.plainText !== false
|
|
303
307
|
&& !hasInboundImages(normalized)
|
|
304
|
-
&& !hasInboundFiles(normalized)
|
|
308
|
+
&& !hasInboundFiles(normalized)
|
|
309
|
+
&& !hasReplyReference(normalized),
|
|
305
310
|
});
|
|
306
311
|
if (batch.handled) {
|
|
307
312
|
return this.#finishLocalMessage(normalized, messageId, batch.message);
|
|
@@ -575,7 +580,8 @@ export class TextHarnessBridge {
|
|
|
575
580
|
}
|
|
576
581
|
const hasImages = hasInboundImages(message);
|
|
577
582
|
const hasFiles = hasInboundFiles(message);
|
|
578
|
-
|
|
583
|
+
const hasReply = hasReplyReference(message);
|
|
584
|
+
if (!text && !hasImages && !hasFiles && !hasReply) {
|
|
579
585
|
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
|
|
580
586
|
return;
|
|
581
587
|
}
|
|
@@ -588,7 +594,7 @@ export class TextHarnessBridge {
|
|
|
588
594
|
t('/new 开启一个全新会话'),
|
|
589
595
|
t('/compact 压缩当前会话的较早上下文'),
|
|
590
596
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
591
|
-
t('/workspace
|
|
597
|
+
t('/workspace 工作区序号或绝对路径 切换工作区'),
|
|
592
598
|
t('/workspacelist 列出工作区绝对路径'),
|
|
593
599
|
t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
|
|
594
600
|
t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
|
|
@@ -669,16 +675,20 @@ export class TextHarnessBridge {
|
|
|
669
675
|
);
|
|
670
676
|
}
|
|
671
677
|
}
|
|
672
|
-
let content = hasImages
|
|
673
|
-
? await
|
|
678
|
+
let content = hasImages || hasReply
|
|
679
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
674
680
|
: undefined;
|
|
675
681
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
682
|
+
let contextEnhanced = false;
|
|
676
683
|
if (snapshot) {
|
|
677
|
-
|
|
684
|
+
const originalContent = content ?? text;
|
|
685
|
+
content = enhanceContextContent(originalContent, snapshot, () => ({
|
|
678
686
|
channel: this.#descriptor.key,
|
|
679
687
|
senderId,
|
|
680
688
|
senderName: message.contextSource?.()?.senderName,
|
|
689
|
+
conversationTitle: message.contextSource?.()?.conversationTitle,
|
|
681
690
|
}));
|
|
691
|
+
contextEnhanced = content !== originalContent;
|
|
682
692
|
}
|
|
683
693
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
684
694
|
harness: this.#harness,
|
|
@@ -686,6 +696,7 @@ export class TextHarnessBridge {
|
|
|
686
696
|
key: conversationKey,
|
|
687
697
|
text,
|
|
688
698
|
content,
|
|
699
|
+
contextEnhanced,
|
|
689
700
|
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
690
701
|
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
|
691
702
|
askOptions: {
|
|
@@ -142,7 +142,7 @@ async function runWorkspaceListCommand(match, harness) {
|
|
|
142
142
|
`${index + 1}. ${workspace}${workspace === current ? t('(当前)') : ''}`
|
|
143
143
|
)),
|
|
144
144
|
'',
|
|
145
|
-
t('切换用法:/workspace
|
|
145
|
+
t('切换用法:/workspace 工作区序号或绝对路径'),
|
|
146
146
|
t('查看会话:/sessionlist 工作区序号或绝对路径'),
|
|
147
147
|
];
|
|
148
148
|
const message = lines.join('\n');
|
|
@@ -366,18 +366,30 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
|
|
|
366
366
|
if (!match) return null;
|
|
367
367
|
const workspace = match[1]?.trim();
|
|
368
368
|
if (!workspace) {
|
|
369
|
-
return commandResult(t('用法:/workspace
|
|
369
|
+
return commandResult(t('用法:/workspace 工作区序号或绝对路径'));
|
|
370
370
|
}
|
|
371
371
|
if (typeof harness?.switchWorkspace !== 'function') {
|
|
372
372
|
return commandResult(t('当前机器人暂不支持切换工作区。'));
|
|
373
373
|
}
|
|
374
374
|
try {
|
|
375
|
-
|
|
375
|
+
let selected = workspace;
|
|
376
|
+
if (/^\d+$/u.test(workspace)) {
|
|
377
|
+
if (typeof harness?.listWorkspaces !== 'function') {
|
|
378
|
+
return commandResult(t('当前机器人暂不支持按序号选择工作区。'));
|
|
379
|
+
}
|
|
380
|
+
const { paths } = await workspacePathSnapshot(harness);
|
|
381
|
+
const position = Number(workspace);
|
|
382
|
+
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
|
|
383
|
+
return commandResult(t('工作区序号不存在,请先执行 /workspacelist。'));
|
|
384
|
+
}
|
|
385
|
+
selected = paths[position - 1];
|
|
386
|
+
}
|
|
387
|
+
const current = await harness.switchWorkspace(selected);
|
|
376
388
|
return commandResult(t('工作区已切换为:{workspace}', { workspace: current }));
|
|
377
389
|
} catch (error) {
|
|
378
390
|
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
|
|
379
391
|
return commandResult(t(`{message}
|
|
380
|
-
用法:/workspace
|
|
392
|
+
用法:/workspace 工作区序号或绝对路径`, { message: error.message }));
|
|
381
393
|
}
|
|
382
394
|
if (error?.code === 'workspace-bot-not-found') {
|
|
383
395
|
return commandResult(t('机器人正在移除或已重新接入,无法切换原会话的工作区。'));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
|
2
|
+
import { initialSessionTitle } from './session-title.mjs';
|
|
2
3
|
|
|
3
4
|
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
|
4
5
|
|
|
@@ -6,7 +7,7 @@ function workspaceSession(harness, sessionId) {
|
|
|
6
7
|
if (typeof harness.workspaceSession === 'function') {
|
|
7
8
|
return harness.workspaceSession(sessionId);
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
+
const session = {
|
|
10
11
|
sessionId,
|
|
11
12
|
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
|
12
13
|
models: (...args) => harness.getSessionModels(sessionId, ...args),
|
|
@@ -16,7 +17,11 @@ function workspaceSession(harness, sessionId) {
|
|
|
16
17
|
stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
|
|
17
18
|
steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
|
|
18
19
|
ask: (...args) => harness.ask(sessionId, ...args),
|
|
19
|
-
}
|
|
20
|
+
};
|
|
21
|
+
if (typeof harness.renameSession === 'function') {
|
|
22
|
+
session.renameTitle = (...args) => harness.renameSession(sessionId, ...args);
|
|
23
|
+
}
|
|
24
|
+
return Object.freeze(session);
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
async function sessionExists(session, options) {
|
|
@@ -42,10 +47,21 @@ export async function askInWorkspaceSession({
|
|
|
42
47
|
key,
|
|
43
48
|
text,
|
|
44
49
|
content,
|
|
50
|
+
contextEnhanced = false,
|
|
45
51
|
createOptions,
|
|
46
52
|
existsOptions,
|
|
47
53
|
askOptions,
|
|
48
54
|
}) {
|
|
55
|
+
const initialTitle = contextEnhanced
|
|
56
|
+
? initialSessionTitle({
|
|
57
|
+
text,
|
|
58
|
+
content,
|
|
59
|
+
files: typeof askOptions === 'object' ? askOptions?.files : undefined,
|
|
60
|
+
})
|
|
61
|
+
: null;
|
|
62
|
+
const renameSignal = createOptions?.signal
|
|
63
|
+
?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
|
|
64
|
+
const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
|
|
49
65
|
while (true) {
|
|
50
66
|
try {
|
|
51
67
|
const binding = await withSessionBindingLock(state, key, async () => {
|
|
@@ -55,6 +71,16 @@ export async function askInWorkspaceSession({
|
|
|
55
71
|
sessionId = await createSession(harness, createOptions);
|
|
56
72
|
if (await state.setSession(key, sessionId) === false) return null;
|
|
57
73
|
session = workspaceSession(harness, sessionId);
|
|
74
|
+
if (initialTitle && typeof session.renameTitle === 'function') {
|
|
75
|
+
try {
|
|
76
|
+
await session.renameTitle(initialTitle, renameOptions);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === WORKSPACE_SESSION_STALE || renameOptions?.signal?.aborted) {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
console.warn('[dsh-im] unable to set the initial Session title:', error?.message ?? error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
58
84
|
}
|
|
59
85
|
return { sessionId, session };
|
|
60
86
|
});
|
|
@@ -246,6 +246,24 @@ export class SlackApi {
|
|
|
246
246
|
return value.file;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
async getMessage({ channelId, messageTs, signal } = {}) {
|
|
250
|
+
const timestamp = requiredString(messageTs, 'message timestamp');
|
|
251
|
+
const value = await this.#request('conversations.history', {
|
|
252
|
+
tokenKind: 'bot',
|
|
253
|
+
signal,
|
|
254
|
+
body: {
|
|
255
|
+
channel: slackId(channelId, 'channel id'),
|
|
256
|
+
oldest: timestamp,
|
|
257
|
+
latest: timestamp,
|
|
258
|
+
inclusive: true,
|
|
259
|
+
limit: 1,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
return Array.isArray(value?.messages)
|
|
263
|
+
? value.messages.find((message) => String(message?.ts ?? '') === timestamp) ?? null
|
|
264
|
+
: null;
|
|
265
|
+
}
|
|
266
|
+
|
|
249
267
|
postMessage({ channelId, text, threadTs, signal }) {
|
|
250
268
|
return this.#request('chat.postMessage', {
|
|
251
269
|
tokenKind: 'bot',
|
|
@@ -49,6 +49,58 @@ function slackFileUrl(file) {
|
|
|
49
49
|
? file.url_private_download : file?.url_private;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function slackReplyAttachment(file) {
|
|
53
|
+
if (!file || typeof file !== 'object') return null;
|
|
54
|
+
const mediaType = typeof file.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
|
55
|
+
const kind = mediaType.startsWith('image/') ? 'image'
|
|
56
|
+
: mediaType.startsWith('audio/') ? 'audio'
|
|
57
|
+
: mediaType.startsWith('video/') ? 'video' : 'file';
|
|
58
|
+
const name = typeof file.name === 'string' && file.name
|
|
59
|
+
? file.name : typeof file.title === 'string' && file.title ? file.title : undefined;
|
|
60
|
+
return { kind, ...(name ? { name } : {}) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function slackReplySnapshot(message, messageTs) {
|
|
64
|
+
if (!message || String(message.ts ?? '') !== messageTs) return null;
|
|
65
|
+
const authorId = typeof message.user === 'string' && message.user
|
|
66
|
+
? message.user : typeof message.bot_id === 'string' && message.bot_id
|
|
67
|
+
? message.bot_id : undefined;
|
|
68
|
+
const authorName = typeof message.username === 'string' && message.username
|
|
69
|
+
? message.username : undefined;
|
|
70
|
+
return {
|
|
71
|
+
messageId: messageTs,
|
|
72
|
+
...(authorId ? { authorId } : {}),
|
|
73
|
+
...(authorName ? { authorName } : {}),
|
|
74
|
+
content: decodeSlackText(message.text ?? ''),
|
|
75
|
+
attachments: Array.isArray(message.files)
|
|
76
|
+
? message.files.map(slackReplyAttachment).filter(Boolean)
|
|
77
|
+
: [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function slackThreadReplyReference(event, loadReply) {
|
|
82
|
+
const messageTs = typeof event?.thread_ts === 'string' ? event.thread_ts : '';
|
|
83
|
+
if (!messageTs || messageTs === String(event?.ts ?? '')) return undefined;
|
|
84
|
+
return {
|
|
85
|
+
messageId: messageTs,
|
|
86
|
+
load: async ({ signal } = {}) => {
|
|
87
|
+
try {
|
|
88
|
+
const message = await loadReply({
|
|
89
|
+
channelId: String(event.channel),
|
|
90
|
+
messageTs,
|
|
91
|
+
signal,
|
|
92
|
+
});
|
|
93
|
+
return slackReplySnapshot(message, messageTs);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code === 'slack-missing-scope') {
|
|
96
|
+
return { messageId: messageTs, unavailableReason: 'permission-denied' };
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
function slackImageSource(file, loadFile) {
|
|
53
105
|
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
|
|
54
106
|
const url = slackFileUrl(file);
|
|
@@ -104,6 +156,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
104
156
|
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
|
|
105
157
|
loadFileStream = loadFile,
|
|
106
158
|
loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
|
|
159
|
+
loadReply = async () => { throw new Error('Slack reply loader is unavailable'); },
|
|
107
160
|
} = {}) {
|
|
108
161
|
const event = payload?.event;
|
|
109
162
|
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
|
|
@@ -112,6 +165,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
112
165
|
if (!direct && !mentioned) return null;
|
|
113
166
|
if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
|
|
114
167
|
const threadTs = String(event.thread_ts ?? event.ts);
|
|
168
|
+
const replyTo = slackThreadReplyReference(event, loadReply);
|
|
115
169
|
return {
|
|
116
170
|
messageId: String(payload.event_id),
|
|
117
171
|
senderId: String(event.user),
|
|
@@ -126,6 +180,7 @@ export function normalizeSlackEvent(payload, botUserId, {
|
|
|
126
180
|
files: Array.isArray(event.files)
|
|
127
181
|
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
|
|
128
182
|
: [],
|
|
183
|
+
...(replyTo ? { replyTo } : {}),
|
|
129
184
|
addressed: direct || mentioned,
|
|
130
185
|
reactionTarget: {
|
|
131
186
|
channelId: String(event.channel),
|
|
@@ -555,6 +610,7 @@ export class SlackRuntime {
|
|
|
555
610
|
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
|
|
556
611
|
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
|
|
557
612
|
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
|
|
613
|
+
loadReply: (options) => this.#api.getMessage(options),
|
|
558
614
|
});
|
|
559
615
|
const bridge = this.#bridge;
|
|
560
616
|
if (message && bridge) {
|