@xmanrui/dsh-im 3.0.8 → 3.1.1

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.
@@ -126,7 +126,37 @@ function deliveryUuid(file, chatId, messageType) {
126
126
 
127
127
  function summaryOf(text) {
128
128
  const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
129
- return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
129
+ return summary.length <= 50 ? summary : `${streamTextPrefix(summary, 49)}…`;
130
+ }
131
+
132
+ function streamTextPrefix(text, maxChars) {
133
+ let end = Math.min(text.length, maxChars);
134
+ // Keep a UTF-16 surrogate pair together when the limit lands inside an emoji.
135
+ const before = text.charCodeAt(end - 1);
136
+ const after = text.charCodeAt(end);
137
+ if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) end -= 1;
138
+ return text.slice(0, end);
139
+ }
140
+
141
+ function streamPreview(text) {
142
+ if (text.length <= MAX_STREAM_CHARS) return text;
143
+ const notice = `\n\n${t('内容较长,生成完成后将分段发送完整回答。')}`;
144
+ return streamTextPrefix(text, MAX_STREAM_CHARS - notice.length) + notice;
145
+ }
146
+
147
+ function splitStreamContent(text) {
148
+ const chunks = [];
149
+ let remaining = text;
150
+ while (remaining.length > MAX_STREAM_CHARS) {
151
+ const prefix = streamTextPrefix(remaining, MAX_STREAM_CHARS);
152
+ let end = prefix.lastIndexOf('\n') + 1;
153
+ if (end < MAX_STREAM_CHARS * 0.6) end = prefix.length;
154
+ chunks.push(remaining.slice(0, end));
155
+ // Unlike plain-text fallback splitting, keep all whitespace in the answer.
156
+ remaining = remaining.slice(end);
157
+ }
158
+ if (remaining) chunks.push(remaining);
159
+ return chunks;
130
160
  }
131
161
 
132
162
  function streamingCard(initialText) {
@@ -164,7 +194,7 @@ export class VerifiedFeishuChannel {
164
194
  fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
165
195
  }) {
166
196
  this.#client = client;
167
- this.#initialText = initialText ?? t(DEFAULT_INITIAL_TEXT);
197
+ this.#initialText = String(initialText ?? t(DEFAULT_INITIAL_TEXT)) || '…';
168
198
  this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
169
199
  this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
170
200
  }
@@ -174,59 +204,40 @@ export class VerifiedFeishuChannel {
174
204
  throw new Error('Feishu stream requires a markdown producer');
175
205
  }
176
206
 
177
- let messageId = null;
178
- const cardResponse = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
179
- data: {
180
- type: 'card_json',
181
- data: JSON.stringify(streamingCard(this.#initialText)),
182
- },
183
- }));
184
- const cardId = cardResponse?.data?.card_id;
185
- if (!cardId) throw new Error('Feishu card.create returned no card_id');
186
-
207
+ const cards = [];
187
208
  try {
188
- messageId = await this.#sendCard(chatId, cardId, options.replyTo);
189
- let sequence = 0;
209
+ const firstCard = await this.#createStreamCard(chatId, options.replyTo);
210
+ cards.push(firstCard);
190
211
  let lastContent = this.#initialText;
191
212
  const controller = {
192
- messageId,
213
+ messageId: firstCard.messageId,
193
214
  setContent: async (content) => {
194
215
  const next = String(content ?? '') || '…';
195
- if (next === lastContent) return;
196
- if (next.length > MAX_STREAM_CHARS) {
197
- throw new Error(`Feishu stream content exceeds ${MAX_STREAM_CHARS} characters`);
198
- }
199
- const response = await this.#client.cardkit.v1.cardElement.content({
200
- path: { card_id: cardId, element_id: STREAM_ELEMENT_ID },
201
- data: {
202
- content: next,
203
- sequence: ++sequence,
204
- uuid: `content_${cardId}_${sequence}`,
205
- },
206
- });
207
- assertApiSuccess('Feishu cardElement.content', response);
216
+ await this.#updateStreamCard(firstCard, streamPreview(next));
217
+ // Updates are replaceable snapshots, including progress/tool text.
218
+ // Retain the full latest snapshot even when its preview is unchanged.
208
219
  lastContent = next;
209
220
  },
210
221
  };
211
222
 
212
223
  await input.markdown(controller);
213
- const finishResponse = await this.#client.cardkit.v1.card.settings({
214
- path: { card_id: cardId },
215
- data: {
216
- settings: JSON.stringify({
217
- config: {
218
- streaming_mode: false,
219
- summary: { content: summaryOf(lastContent) || t('回答完成') },
220
- },
221
- }),
222
- sequence: ++sequence,
223
- uuid: `settings_${cardId}_${sequence}`,
224
- },
225
- });
226
- assertApiSuccess('Feishu card.settings', finishResponse);
227
- return { messageId };
224
+ const chunks = splitStreamContent(lastContent);
225
+ for (const [index, chunk] of chunks.entries()) {
226
+ const card = index === 0
227
+ ? firstCard
228
+ : await this.#createStreamCard(chatId, options.replyTo);
229
+ if (index > 0) cards.push(card);
230
+ await this.#updateStreamCard(card, chunk);
231
+ await this.#finishStreamCard(card);
232
+ }
233
+ return {
234
+ messageId: firstCard.messageId,
235
+ providerMessageIds: cards.map((card) => card.messageId),
236
+ };
228
237
  } catch (error) {
229
- if (messageId) await this.#recall(messageId);
238
+ // Preserve the existing provider-error fallback contract: the bridge
239
+ // resends the completed answer, so remove any cards it would duplicate.
240
+ for (const card of cards) await this.#recall(card.messageId);
230
241
  throw error;
231
242
  }
232
243
  }
@@ -369,6 +380,51 @@ export class VerifiedFeishuChannel {
369
380
  });
370
381
  }
371
382
 
383
+ async #createStreamCard(chatId, replyTo) {
384
+ const content = streamPreview(this.#initialText);
385
+ const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
386
+ data: {
387
+ type: 'card_json',
388
+ data: JSON.stringify(streamingCard(content)),
389
+ },
390
+ }));
391
+ const cardId = response?.data?.card_id;
392
+ if (!cardId) throw new Error('Feishu card.create returned no card_id');
393
+ const messageId = await this.#sendCard(chatId, cardId, replyTo);
394
+ return { cardId, messageId, content, sequence: 0 };
395
+ }
396
+
397
+ async #updateStreamCard(card, content) {
398
+ if (content === card.content) return;
399
+ const response = await this.#client.cardkit.v1.cardElement.content({
400
+ path: { card_id: card.cardId, element_id: STREAM_ELEMENT_ID },
401
+ data: {
402
+ content,
403
+ sequence: ++card.sequence,
404
+ uuid: `content_${card.cardId}_${card.sequence}`,
405
+ },
406
+ });
407
+ assertApiSuccess('Feishu cardElement.content', response);
408
+ card.content = content;
409
+ }
410
+
411
+ async #finishStreamCard(card) {
412
+ const response = await this.#client.cardkit.v1.card.settings({
413
+ path: { card_id: card.cardId },
414
+ data: {
415
+ settings: JSON.stringify({
416
+ config: {
417
+ streaming_mode: false,
418
+ summary: { content: summaryOf(card.content) || t('回答完成') },
419
+ },
420
+ }),
421
+ sequence: ++card.sequence,
422
+ uuid: `settings_${card.cardId}_${card.sequence}`,
423
+ },
424
+ });
425
+ assertApiSuccess('Feishu card.settings', response);
426
+ }
427
+
372
428
  async #sendCard(chatId, cardId, replyTo) {
373
429
  const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
374
430
  const response = replyTo
@@ -1,5 +1,6 @@
1
1
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
2
  import { runCompactCommand } from '../shared/compact-command.mjs';
3
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
3
4
  import {
4
5
  isControlCommand,
5
6
  runControlCommand,
@@ -78,6 +79,7 @@ function helpText() {
78
79
  t('直接发送文字、图片或文件即可继续当前会话。'),
79
80
  t('/new 开启一个全新会话'),
80
81
  t('/compact 压缩当前会话的较早上下文'),
82
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
81
83
  t('/workspace 工作区绝对路径 切换工作区'),
82
84
  t('/workspacelist 列出工作区绝对路径'),
83
85
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -468,7 +470,8 @@ export class QqHarnessBridge {
468
470
  return this.#finishBatchResult(message, messageId, result);
469
471
  }
470
472
  }
471
- const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
473
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
474
+ : hasQqFileAttachments(message) ? null : isControlCommand(commandText)
472
475
  ? runControlCommand
473
476
  : (isModelCommand(commandText)
474
477
  ? runModelCommand
@@ -602,6 +605,7 @@ export class QqHarnessBridge {
602
605
  this.#status.lastMessageAt = new Date().toISOString();
603
606
  const result = await runner(text, this.#harness, this.#state, key, {
604
607
  signal: this.#signal,
608
+ isDirect: message.kind === 'c2c',
605
609
  hasImages: hasQqImageAttachments(message),
606
610
  hasFiles: hasQqFileAttachments(message),
607
611
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -834,6 +834,9 @@ export function createBotWorkspaceScope(
834
834
  models(...args) {
835
835
  return invokeCurrentSession('getSessionModels', args, 'model listing');
836
836
  },
837
+ readHistory(...args) {
838
+ return invokeCurrentSession('readSessionHistory', args, 'history read');
839
+ },
837
840
  selectModel(...args) {
838
841
  return invokeCurrentSession('selectSessionModel', args, 'model selection');
839
842
  },
@@ -911,6 +911,19 @@ export class HarnessClient {
911
911
  }
912
912
  }
913
913
 
914
+ async readSessionHistory(sessionId, { maxMessages = 50, beforeSeq, timeoutMs = 10_000, ...options } = {}) {
915
+ if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
916
+ if (!Number.isSafeInteger(maxMessages) || maxMessages < 1
917
+ || (beforeSeq !== undefined && (!Number.isSafeInteger(beforeSeq) || beforeSeq < 0))) {
918
+ throw new TypeError('Invalid history pagination');
919
+ }
920
+ return this.rpc('session.history', {
921
+ sessionId,
922
+ maxMessages,
923
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
924
+ }, timeoutMs, options);
925
+ }
926
+
914
927
  async sessionExists(sessionId, options = {}) {
915
928
  try {
916
929
  await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
@@ -0,0 +1,193 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ import { t } from './i18n.mjs';
4
+ import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
5
+ import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
6
+
7
+ const HISTORY_COMMAND = /^\/history(?=$|\s)([\s\S]*)$/iu;
8
+ const HISTORY_USAGE = '用法:/history [数量](默认 3 条,最多 5 条)';
9
+ const MAX_MESSAGES = 5;
10
+ const PAGE_SIZE = 50;
11
+ const MAX_PAGES = 3;
12
+ const READ_TIMEOUT_MS = 10_000;
13
+
14
+ function commandResult(message) {
15
+ return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
16
+ }
17
+
18
+ function previewText(content) {
19
+ if (!Array.isArray(content)) throw new TypeError('Invalid history message content');
20
+ return content.flatMap((block) => {
21
+ if (block?.type === 'text' && typeof block.text === 'string') return [block.text];
22
+ if (block?.type === 'image') return [t('[图片]')];
23
+ if (block?.type === 'file') return [t('[文件]')];
24
+ return [];
25
+ }).join('\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').trim()
26
+ || t('本条没有可预览的文字。');
27
+ }
28
+
29
+ function visibleMessages(events) {
30
+ const messages = [];
31
+ const assistants = new Map();
32
+ for (const event of events) {
33
+ const data = event.data;
34
+ if (event.type === 'user/message' && event.surfaceOp === 'append'
35
+ && data?.source?.kind === 'user') {
36
+ // Older sessions may contain commands saved before the local fast path
37
+ // existed. Exclude them before counting, so normal messages fill the limit.
38
+ if (isHistoryCommand(previewText(data.content))) continue;
39
+ messages.push({ seq: event.seq, role: 'user', content: data.content });
40
+ } else if (event.type === 'assistant/message' && event.surfaceOp === 'append'
41
+ && Number.isSafeInteger(data?.turn) && data.turn >= 0) {
42
+ // Remember even an empty/interrupted final message: never substitute an
43
+ // earlier tool-step explanation for the turn's final answer.
44
+ assistants.set(data.turn, event);
45
+ } else if (event.type === 'turn/end') {
46
+ const assistant = assistants.get(data?.turn);
47
+ assistants.delete(data?.turn);
48
+ if ((data?.reason?.kind ?? data?.reason) !== 'completed'
49
+ || !assistant || assistant.data.interrupted === true) continue;
50
+ messages.push({
51
+ seq: assistant.seq,
52
+ role: 'assistant',
53
+ content: assistant.data.message?.content,
54
+ });
55
+ }
56
+ }
57
+ return messages.sort((left, right) => left.seq - right.seq);
58
+ }
59
+
60
+ function truncateText(text, limit) {
61
+ if (text.length <= limit) return text;
62
+ const suffix = t('(已截断)');
63
+ let end = Math.max(0, limit - suffix.length);
64
+ const last = text.charCodeAt(end - 1);
65
+ if (last >= 0xd800 && last <= 0xdbff) end -= 1;
66
+ return `${text.slice(0, Math.max(0, end))}${suffix}`;
67
+ }
68
+
69
+ function formatHistory(sessionId, records, requested, hasMore) {
70
+ const shortId = sessionId.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 12);
71
+ const header = t('会话历史|{session}|最近 {count} 条', { session: shortId, count: records.length });
72
+ const footer = [t('以上为历史记录,不是本次新回复。')];
73
+ if (records.length < requested) {
74
+ footer.unshift(hasMore
75
+ ? t('本次有限读取中仅找到 {count} 条可预览消息。', { count: records.length })
76
+ : t('当前会话仅有 {count} 条可预览消息。', { count: records.length }));
77
+ }
78
+ const entries = records.map((record) => ({ ...record, text: previewText(record.content) }));
79
+ let bodyLimit = 500;
80
+ let result;
81
+ do {
82
+ const sections = entries.map((entry, index) => (
83
+ `${index + 1}. ${entry.role === 'user' ? t('用户') : t('助手')}\n${truncateText(entry.text, bodyLimit)}`
84
+ ));
85
+ result = commandResult([header, ...sections, footer.join('\n')].join('\n\n'));
86
+ bodyLimit = Math.floor(bodyLimit / 2);
87
+ } while (bodyLimit > 0 && (result.message.length > 3_000 || result.messages.length > 3));
88
+ return result;
89
+ }
90
+
91
+ function historyErrorMessage(error) {
92
+ const code = error?.code ?? error?.failure?.code;
93
+ if (code === 'session-not-found') return t('当前聊天绑定的会话已不存在,请重新绑定会话。');
94
+ if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found'
95
+ || code === 'session-binding-changed') return t('会话、工作区或机器人状态已发生变化,请重新执行 /history。');
96
+ if (code === 'harness-api-not-found') return t('当前 Harness 暂不支持读取会话历史。');
97
+ if (error?.name === 'AbortError') return t('历史读取已取消。');
98
+ if (error?.name === 'TimeoutError' || code === 'harness-timeout') return t('读取历史超时,请稍后重试。');
99
+ return t('暂时无法读取会话历史,请稍后重试。');
100
+ }
101
+
102
+ export function isHistoryCommand(text) {
103
+ return typeof text === 'string' && HISTORY_COMMAND.test(text.trim());
104
+ }
105
+
106
+ /** Read the current binding only; never create/resume a session or prompt the model. */
107
+ export async function runHistoryCommand(text, harness, state, key, {
108
+ signal: callerSignal,
109
+ isDirect = false,
110
+ hasImages = false,
111
+ hasFiles = false,
112
+ } = {}) {
113
+ if (!isHistoryCommand(text)) return null;
114
+ const argument = HISTORY_COMMAND.exec(text.trim())[1].trim();
115
+ if (argument && (!/^\d+$/u.test(argument) || /^0+$/u.test(argument))) {
116
+ return commandResult(t(HISTORY_USAGE));
117
+ }
118
+ const count = argument ? Math.min(Number(argument), MAX_MESSAGES) : 3;
119
+ if (!isDirect) return commandResult(t('请在与机器人的私聊中使用 /history。'));
120
+ if (hasImages || hasFiles) return commandResult(t('/history 仅支持文字命令,请移除图片或文件后重试。'));
121
+ const sessionId = state?.sessionFor?.(key);
122
+ if (typeof sessionId !== 'string' || !sessionId) {
123
+ return commandResult(t('当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。'));
124
+ }
125
+
126
+ try {
127
+ const session = harness?.workspaceSession?.(sessionId);
128
+ if (typeof session?.readHistory !== 'function') {
129
+ return commandResult(t('当前 Harness 暂不支持读取会话历史。'));
130
+ }
131
+ const timeout = AbortSignal.timeout(READ_TIMEOUT_MS);
132
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
133
+ const deadline = Date.now() + READ_TIMEOUT_MS;
134
+ const events = new Map();
135
+ let beforeSeq;
136
+ let snapshotEnd;
137
+ let records = [];
138
+ let hasMore = false;
139
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
140
+ signal.throwIfAborted();
141
+ if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
142
+ const page = await session.readHistory({
143
+ maxMessages: PAGE_SIZE,
144
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
145
+ timeoutMs: Math.max(1, deadline - Date.now()),
146
+ signal,
147
+ });
148
+ signal.throwIfAborted();
149
+ if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
150
+ if (state.sessionFor(key) !== sessionId) {
151
+ const error = new Error('Session binding changed during history read');
152
+ error.code = 'session-binding-changed';
153
+ throw error;
154
+ }
155
+ if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') {
156
+ throw new TypeError('Invalid history page');
157
+ }
158
+ let oldestSeq = beforeSeq ?? Infinity;
159
+ for (const entry of page.events) {
160
+ const event = entry?.event;
161
+ if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0
162
+ || typeof event.type !== 'string' || !event.type) throw new TypeError('Invalid history event');
163
+ // Subsequent pages must not extend the snapshot taken by the first read.
164
+ if (snapshotEnd !== undefined && event.seq > snapshotEnd) continue;
165
+ const previous = events.get(event.seq);
166
+ if (previous) {
167
+ if (!isDeepStrictEqual(previous, event)) throw new TypeError('Conflicting history events');
168
+ } else {
169
+ if (beforeSeq !== undefined && event.seq >= beforeSeq) throw new TypeError('Invalid history cursor');
170
+ events.set(event.seq, event);
171
+ }
172
+ oldestSeq = Math.min(oldestSeq, event.seq);
173
+ }
174
+ const ordered = [...events.values()].sort((left, right) => left.seq - right.seq);
175
+ snapshotEnd ??= ordered.at(-1)?.seq;
176
+ hasMore = page.hasMore;
177
+ if (hasMore && (!Number.isFinite(oldestSeq) || oldestSeq === beforeSeq)) {
178
+ throw new TypeError('History cursor did not advance');
179
+ }
180
+ records = visibleMessages(ordered).slice(-count);
181
+ if (records.length >= count || !hasMore) break;
182
+ beforeSeq = oldestSeq;
183
+ }
184
+ if (records.length === 0) {
185
+ return commandResult(hasMore
186
+ ? t('本次有限读取中未找到可预览的历史消息。')
187
+ : t('当前会话暂无可预览的历史消息。'));
188
+ }
189
+ return formatHistory(sessionId, records, count, hasMore);
190
+ } catch (error) {
191
+ return commandResult(historyErrorMessage(error));
192
+ }
193
+ }
@@ -335,6 +335,8 @@ export default {
335
335
  // feishu/feishu-channel.mjs
336
336
  '正在生成…': 'Generating…',
337
337
  '回答完成': 'Answer complete',
338
+ '内容较长,生成完成后将分段发送完整回答。':
339
+ 'This response is long. The complete answer will be sent in parts when generation finishes.',
338
340
  '飞书机器人': 'Feishu bot',
339
341
 
340
342
  // feishu/message-utils.mjs
@@ -75,6 +75,7 @@ export default {
75
75
  '当前消息缺少可绑定的会话上下文。':
76
76
  'The current message lacks a conversation context to bind to.',
77
77
  '当前聊天已绑定会话:': 'This chat is now bound to the Session:',
78
+ '发送 /history 查看最近对话。': 'Send /history to preview recent conversation messages.',
78
79
  '标题:{title}': 'Title: {title}',
79
80
  '归档:{archived}': 'Archived: {archived}',
80
81
  '是': 'Yes',
@@ -1,5 +1,45 @@
1
1
  // English translations (shared-c area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ // history-command.mjs / command help
4
+ '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)':
5
+ '/history [count] Preview recent messages (default 3, maximum 5)',
6
+ '用法:/history [数量](默认 3 条,最多 5 条)':
7
+ 'Usage: /history [count] (default 3, maximum 5)',
8
+ '[图片]': '[Image]',
9
+ '[文件]': '[File]',
10
+ '本条没有可预览的文字。': 'This message has no text to preview.',
11
+ '(已截断)': ' (truncated)',
12
+ '会话历史|{session}|最近 {count} 条':
13
+ 'Session history | {session} | Recent messages: {count}',
14
+ '以上为历史记录,不是本次新回复。':
15
+ 'These are history records, not a new reply.',
16
+ '本次有限读取中仅找到 {count} 条可预览消息。':
17
+ 'Messages available to preview within this bounded read: {count}.',
18
+ '当前会话仅有 {count} 条可预览消息。':
19
+ 'Messages available to preview in this Session: {count}.',
20
+ '用户': 'User',
21
+ '助手': 'Assistant',
22
+ '当前聊天绑定的会话已不存在,请重新绑定会话。':
23
+ 'The Session bound to this chat no longer exists. Please bind a Session again.',
24
+ '会话、工作区或机器人状态已发生变化,请重新执行 /history。':
25
+ 'The Session, workspace, or bot state has changed. Please run /history again.',
26
+ '当前 Harness 暂不支持读取会话历史。':
27
+ 'This Harness does not support reading Session history.',
28
+ '历史读取已取消。': 'History reading was cancelled.',
29
+ '读取历史超时,请稍后重试。': 'Reading history timed out. Please try again later.',
30
+ '暂时无法读取会话历史,请稍后重试。':
31
+ 'Unable to read Session history right now. Please try again later.',
32
+ '请在与机器人的私聊中使用 /history。':
33
+ 'Please use /history in a direct chat with the bot.',
34
+ '/history 仅支持文字命令,请移除图片或文件后重试。':
35
+ '/history supports text commands only. Remove images or files and try again.',
36
+ '当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。':
37
+ 'This chat has no bound Session. Send a message or use /session to bind one first.',
38
+ '本次有限读取中未找到可预览的历史消息。':
39
+ 'No history messages were available to preview within this bounded read.',
40
+ '当前会话暂无可预览的历史消息。':
41
+ 'This Session has no history messages available to preview yet.',
42
+
3
43
  // harness-approval.mjs
4
44
  '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。':
5
45
  'Please reply exactly with 「批准」 (approve) or 「拒绝」 (reject). Also accepted: 同意 / 不同意 / yes / no.',
@@ -1,6 +1,7 @@
1
1
  import { t } from './i18n.mjs';
2
2
  import { runWorkspaceCommand } from './workspace-command.mjs';
3
3
  import { runCompactCommand } from './compact-command.mjs';
4
+ import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
4
5
  import {
5
6
  isControlCommand,
6
7
  runControlCommand,
@@ -260,7 +261,8 @@ export class TextHarnessBridge {
260
261
  }
261
262
  const collectingBatch = normalized.kind === 'direct'
262
263
  && this.#batches.status(key).phase === 'collecting';
263
- const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
264
+ const commandRunner = collectingBatch ? null : isHistoryCommand(text) ? runHistoryCommand
265
+ : hasInboundFiles(normalized) ? null : isControlCommand(text)
264
266
  ? runControlCommand
265
267
  : (isModelCommand(text)
266
268
  ? runModelCommand
@@ -417,6 +419,7 @@ export class TextHarnessBridge {
417
419
  key,
418
420
  {
419
421
  signal: this.#signal,
422
+ isDirect: message.kind === 'direct',
420
423
  hasImages: hasInboundImages(message),
421
424
  hasFiles: hasInboundFiles(message),
422
425
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -529,6 +532,7 @@ export class TextHarnessBridge {
529
532
  t('直接发送文字、图片或文件即可继续当前会话。'),
530
533
  t('/new 开启一个全新会话'),
531
534
  t('/compact 压缩当前会话的较早上下文'),
535
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
532
536
  t('/workspace 工作区绝对路径 切换工作区'),
533
537
  t('/workspacelist 列出工作区绝对路径'),
534
538
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -344,6 +344,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
344
344
  t('标题:{title}', { title }),
345
345
  `ID:${boundSessionId}`,
346
346
  t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }),
347
+ t('发送 /history 查看最近对话。'),
347
348
  ].join('\n');
348
349
  return commandResult(message, splitWorkspaceCommandMessage(message));
349
350
  } catch (error) {
@@ -12,6 +12,7 @@ import {
12
12
  isBatchInputCommand,
13
13
  } from '../shared/batch-input.mjs';
14
14
  import { runCompactCommand } from '../shared/compact-command.mjs';
15
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
15
16
  import {
16
17
  isControlCommand,
17
18
  runControlCommand,
@@ -61,6 +62,7 @@ function helpText() {
61
62
  t('直接发送文字、图片或文件即可继续当前会话。'),
62
63
  t('/new 开启一个全新会话'),
63
64
  t('/compact 压缩当前会话的较早上下文'),
65
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
64
66
  t('/workspace 工作区绝对路径 切换工作区'),
65
67
  t('/workspacelist 列出工作区绝对路径'),
66
68
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -591,7 +593,8 @@ export class WecomHarnessBridge {
591
593
  return this.#finishBatchResult(frame, messageId, chatId, result);
592
594
  }
593
595
  }
594
- const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
596
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
597
+ : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
595
598
  ? runControlCommand
596
599
  : (isModelCommand(commandText)
597
600
  ? runModelCommand
@@ -768,6 +771,7 @@ export class WecomHarnessBridge {
768
771
  this.#status.lastMessageAt = new Date().toISOString();
769
772
  const result = await runner(message.content, this.#harness, this.#state, key, {
770
773
  signal: this.#signal,
774
+ isDirect: bodyOf(frame).chattype === 'single',
771
775
  hasImages: hasInboundImages(message),
772
776
  hasFiles: hasInboundFiles(message),
773
777
  pendingInteraction: this.#pendingInteractions.has(key)
@@ -18,6 +18,7 @@ import {
18
18
  isBatchInputCommand,
19
19
  } from '../shared/batch-input.mjs';
20
20
  import { runCompactCommand } from '../shared/compact-command.mjs';
21
+ import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
21
22
  import {
22
23
  isControlCommand,
23
24
  runControlCommand,
@@ -68,6 +69,7 @@ const HELP_TEXT = () => [
68
69
  t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
69
70
  t('/new 开启一个全新会话'),
70
71
  t('/compact 压缩当前会话的较早上下文'),
72
+ t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
71
73
  t('/workspace 工作区绝对路径 切换工作区'),
72
74
  t('/workspacelist 列出工作区绝对路径'),
73
75
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@@ -364,7 +366,8 @@ export class WeixinHarnessBridge {
364
366
  );
365
367
  }
366
368
  }
367
- const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
369
+ const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
370
+ : hasWeixinFileItems(message) ? null : isControlCommand(commandText)
368
371
  ? runControlCommand
369
372
  : (isModelCommand(commandText)
370
373
  ? runModelCommand
@@ -541,6 +544,7 @@ export class WeixinHarnessBridge {
541
544
  this.#status.lastMessageAt = new Date().toISOString();
542
545
  const result = await runner(text, this.#harness, this.#state, key, {
543
546
  signal: this.#signal,
547
+ isDirect: true,
544
548
  hasImages: hasWeixinImageItems(message),
545
549
  hasFiles: hasWeixinFileItems(message),
546
550
  pendingInteraction: this.#pendingInteractions.has(key)