feat: add bounded history previews across nine channels

Add shared /history parsing, read-only session access, message filtering, and bounded replies. Default to three messages, cap at five, and exclude saved history commands.

Include channel integration tests, translations, command help, and the Issue #62 implementation plan.
This commit is contained in:
xmanrui 2026-08-28 03:01:53 +08:00
parent bf0e157cd4
commit 8c6c31a1b6
23 changed files with 1965 additions and 221 deletions

View file

@ -13,6 +13,7 @@ import {
} from '../shared/harness-question.mjs';
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import {
isControlCommand,
runControlCommand,
@ -67,6 +68,7 @@ const HELP_TEXT_LINES = [
'直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文',
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@ -522,7 +524,8 @@ export class DingtalkHarnessBridge {
));
}
}
const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -789,6 +792,7 @@ export class DingtalkHarnessBridge {
key,
{
signal: this.#signal,
isDirect: String(message.conversationType) === '1',
hasImages: hasInboundImages(prompt),
hasFiles: hasInboundFiles(prompt),
pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -30,6 +30,7 @@ import {
isBatchInputCommand,
} from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import {
isControlCommand,
runControlCommand,
@ -620,12 +621,15 @@ export class FeishuHarnessBridge {
.finally(() => this.#acceptedMessageIds.delete(messageId));
return processing;
}
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
: (isPresetCommand(commandText) ? runPresetCommand : null));
if (commandRunner && addressed) {
// In all-message group mode, history must still be refused locally rather
// than becoming a normal prompt when no mention is present.
if (commandRunner && (addressed || commandRunner === runHistoryCommand)) {
const processing = this.#processFastCommand(
event,
messageId,
@ -913,6 +917,7 @@ export class FeishuHarnessBridge {
key,
{
signal: this.#signal,
isDirect: event.message.chat_type === 'p2p',
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#hasPendingInteraction(key),
@ -2083,7 +2088,10 @@ export class FeishuHarnessBridge {
try {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
await this.#send(chatId, [
t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
t('发送 /history 查看最近对话。'),
].join('\n'));
await this.#sendMenuCard(key, chatId, { updateMessageId });
} catch (error) {
await this.#sendFailure(chatId, error, {

View file

@ -497,6 +497,7 @@ export function menuHelpText() {
'/status 连接状态',
'/version 查看插件版本',
'/compact 压缩当前会话上下文',
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
'/archived on/off 会话列表显示/隐藏归档',
'',
'👁 关注',
@ -593,6 +594,7 @@ export function helpCard(extraTextLines = []) {
{ tag: 'div', text: markdown([
t(HELP_TEXT_COMMANDS),
t('`/version` — 查看插件版本'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
].join('\n') + extraText) },
{ tag: 'hr' },
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },

View file

@ -1,5 +1,6 @@
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import {
isControlCommand,
runControlCommand,
@ -78,6 +79,7 @@ function helpText() {
t('直接发送文字、图片或文件即可继续当前会话。'),
t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@ -468,7 +470,8 @@ export class QqHarnessBridge {
return this.#finishBatchResult(message, messageId, result);
}
}
const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasQqFileAttachments(message) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -602,6 +605,7 @@ export class QqHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal,
isDirect: message.kind === 'c2c',
hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message),
pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -834,6 +834,9 @@ export function createBotWorkspaceScope(
models(...args) {
return invokeCurrentSession('getSessionModels', args, 'model listing');
},
readHistory(...args) {
return invokeCurrentSession('readSessionHistory', args, 'history read');
},
selectModel(...args) {
return invokeCurrentSession('selectSessionModel', args, 'model selection');
},

View file

@ -911,6 +911,19 @@ export class HarnessClient {
}
}
async readSessionHistory(sessionId, { maxMessages = 50, beforeSeq, timeoutMs = 10_000, ...options } = {}) {
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
if (!Number.isSafeInteger(maxMessages) || maxMessages < 1
|| (beforeSeq !== undefined && (!Number.isSafeInteger(beforeSeq) || beforeSeq < 0))) {
throw new TypeError('Invalid history pagination');
}
return this.rpc('session.history', {
sessionId,
maxMessages,
...(beforeSeq === undefined ? {} : { beforeSeq }),
}, timeoutMs, options);
}
async sessionExists(sessionId, options = {}) {
try {
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);

View file

@ -0,0 +1,193 @@
import { isDeepStrictEqual } from 'node:util';
import { t } from './i18n.mjs';
import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const HISTORY_COMMAND = /^\/history(?=$|\s)([\s\S]*)$/iu;
const HISTORY_USAGE = '用法:/history [数量](默认 3 条,最多 5 条)';
const MAX_MESSAGES = 5;
const PAGE_SIZE = 50;
const MAX_PAGES = 3;
const READ_TIMEOUT_MS = 10_000;
function commandResult(message) {
return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
}
function previewText(content) {
if (!Array.isArray(content)) throw new TypeError('Invalid history message content');
return content.flatMap((block) => {
if (block?.type === 'text' && typeof block.text === 'string') return [block.text];
if (block?.type === 'image') return [t('[图片]')];
if (block?.type === 'file') return [t('[文件]')];
return [];
}).join('\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '').trim()
|| t('本条没有可预览的文字。');
}
function visibleMessages(events) {
const messages = [];
const assistants = new Map();
for (const event of events) {
const data = event.data;
if (event.type === 'user/message' && event.surfaceOp === 'append'
&& data?.source?.kind === 'user') {
// Older sessions may contain commands saved before the local fast path
// existed. Exclude them before counting, so normal messages fill the limit.
if (isHistoryCommand(previewText(data.content))) continue;
messages.push({ seq: event.seq, role: 'user', content: data.content });
} else if (event.type === 'assistant/message' && event.surfaceOp === 'append'
&& Number.isSafeInteger(data?.turn) && data.turn >= 0) {
// Remember even an empty/interrupted final message: never substitute an
// earlier tool-step explanation for the turn's final answer.
assistants.set(data.turn, event);
} else if (event.type === 'turn/end') {
const assistant = assistants.get(data?.turn);
assistants.delete(data?.turn);
if ((data?.reason?.kind ?? data?.reason) !== 'completed'
|| !assistant || assistant.data.interrupted === true) continue;
messages.push({
seq: assistant.seq,
role: 'assistant',
content: assistant.data.message?.content,
});
}
}
return messages.sort((left, right) => left.seq - right.seq);
}
function truncateText(text, limit) {
if (text.length <= limit) return text;
const suffix = t('(已截断)');
let end = Math.max(0, limit - suffix.length);
const last = text.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
return `${text.slice(0, Math.max(0, end))}${suffix}`;
}
function formatHistory(sessionId, records, requested, hasMore) {
const shortId = sessionId.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 12);
const header = t('会话历史|{session}|最近 {count} 条', { session: shortId, count: records.length });
const footer = [t('以上为历史记录,不是本次新回复。')];
if (records.length < requested) {
footer.unshift(hasMore
? t('本次有限读取中仅找到 {count} 条可预览消息。', { count: records.length })
: t('当前会话仅有 {count} 条可预览消息。', { count: records.length }));
}
const entries = records.map((record) => ({ ...record, text: previewText(record.content) }));
let bodyLimit = 500;
let result;
do {
const sections = entries.map((entry, index) => (
`${index + 1}. ${entry.role === 'user' ? t('用户') : t('助手')}\n${truncateText(entry.text, bodyLimit)}`
));
result = commandResult([header, ...sections, footer.join('\n')].join('\n\n'));
bodyLimit = Math.floor(bodyLimit / 2);
} while (bodyLimit > 0 && (result.message.length > 3_000 || result.messages.length > 3));
return result;
}
function historyErrorMessage(error) {
const code = error?.code ?? error?.failure?.code;
if (code === 'session-not-found') return t('当前聊天绑定的会话已不存在,请重新绑定会话。');
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found'
|| code === 'session-binding-changed') return t('会话、工作区或机器人状态已发生变化,请重新执行 /history。');
if (code === 'harness-api-not-found') return t('当前 Harness 暂不支持读取会话历史。');
if (error?.name === 'AbortError') return t('历史读取已取消。');
if (error?.name === 'TimeoutError' || code === 'harness-timeout') return t('读取历史超时,请稍后重试。');
return t('暂时无法读取会话历史,请稍后重试。');
}
export function isHistoryCommand(text) {
return typeof text === 'string' && HISTORY_COMMAND.test(text.trim());
}
/** Read the current binding only; never create/resume a session or prompt the model. */
export async function runHistoryCommand(text, harness, state, key, {
signal: callerSignal,
isDirect = false,
hasImages = false,
hasFiles = false,
} = {}) {
if (!isHistoryCommand(text)) return null;
const argument = HISTORY_COMMAND.exec(text.trim())[1].trim();
if (argument && (!/^\d+$/u.test(argument) || /^0+$/u.test(argument))) {
return commandResult(t(HISTORY_USAGE));
}
const count = argument ? Math.min(Number(argument), MAX_MESSAGES) : 3;
if (!isDirect) return commandResult(t('请在与机器人的私聊中使用 /history。'));
if (hasImages || hasFiles) return commandResult(t('/history 仅支持文字命令,请移除图片或文件后重试。'));
const sessionId = state?.sessionFor?.(key);
if (typeof sessionId !== 'string' || !sessionId) {
return commandResult(t('当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。'));
}
try {
const session = harness?.workspaceSession?.(sessionId);
if (typeof session?.readHistory !== 'function') {
return commandResult(t('当前 Harness 暂不支持读取会话历史。'));
}
const timeout = AbortSignal.timeout(READ_TIMEOUT_MS);
const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
const deadline = Date.now() + READ_TIMEOUT_MS;
const events = new Map();
let beforeSeq;
let snapshotEnd;
let records = [];
let hasMore = false;
for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
signal.throwIfAborted();
if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
const page = await session.readHistory({
maxMessages: PAGE_SIZE,
...(beforeSeq === undefined ? {} : { beforeSeq }),
timeoutMs: Math.max(1, deadline - Date.now()),
signal,
});
signal.throwIfAborted();
if (Date.now() >= deadline) throw new DOMException('History read timed out', 'TimeoutError');
if (state.sessionFor(key) !== sessionId) {
const error = new Error('Session binding changed during history read');
error.code = 'session-binding-changed';
throw error;
}
if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') {
throw new TypeError('Invalid history page');
}
let oldestSeq = beforeSeq ?? Infinity;
for (const entry of page.events) {
const event = entry?.event;
if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0
|| typeof event.type !== 'string' || !event.type) throw new TypeError('Invalid history event');
// Subsequent pages must not extend the snapshot taken by the first read.
if (snapshotEnd !== undefined && event.seq > snapshotEnd) continue;
const previous = events.get(event.seq);
if (previous) {
if (!isDeepStrictEqual(previous, event)) throw new TypeError('Conflicting history events');
} else {
if (beforeSeq !== undefined && event.seq >= beforeSeq) throw new TypeError('Invalid history cursor');
events.set(event.seq, event);
}
oldestSeq = Math.min(oldestSeq, event.seq);
}
const ordered = [...events.values()].sort((left, right) => left.seq - right.seq);
snapshotEnd ??= ordered.at(-1)?.seq;
hasMore = page.hasMore;
if (hasMore && (!Number.isFinite(oldestSeq) || oldestSeq === beforeSeq)) {
throw new TypeError('History cursor did not advance');
}
records = visibleMessages(ordered).slice(-count);
if (records.length >= count || !hasMore) break;
beforeSeq = oldestSeq;
}
if (records.length === 0) {
return commandResult(hasMore
? t('本次有限读取中未找到可预览的历史消息。')
: t('当前会话暂无可预览的历史消息。'));
}
return formatHistory(sessionId, records, count, hasMore);
} catch (error) {
return commandResult(historyErrorMessage(error));
}
}

View file

@ -75,6 +75,7 @@ export default {
'当前消息缺少可绑定的会话上下文。':
'The current message lacks a conversation context to bind to.',
'当前聊天已绑定会话:': 'This chat is now bound to the Session:',
'发送 /history 查看最近对话。': 'Send /history to preview recent conversation messages.',
'标题:{title}': 'Title: {title}',
'归档:{archived}': 'Archived: {archived}',
'是': 'Yes',

View file

@ -1,5 +1,45 @@
// English translations (shared-c area). Keys are exact Chinese literals passed to t().
export default {
// history-command.mjs / command help
'/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)':
'/history [count] Preview recent messages (default 3, maximum 5)',
'用法:/history [数量](默认 3 条,最多 5 条)':
'Usage: /history [count] (default 3, maximum 5)',
'[图片]': '[Image]',
'[文件]': '[File]',
'本条没有可预览的文字。': 'This message has no text to preview.',
'(已截断)': ' (truncated)',
'会话历史|{session}|最近 {count} 条':
'Session history | {session} | Recent messages: {count}',
'以上为历史记录,不是本次新回复。':
'These are history records, not a new reply.',
'本次有限读取中仅找到 {count} 条可预览消息。':
'Messages available to preview within this bounded read: {count}.',
'当前会话仅有 {count} 条可预览消息。':
'Messages available to preview in this Session: {count}.',
'用户': 'User',
'助手': 'Assistant',
'当前聊天绑定的会话已不存在,请重新绑定会话。':
'The Session bound to this chat no longer exists. Please bind a Session again.',
'会话、工作区或机器人状态已发生变化,请重新执行 /history。':
'The Session, workspace, or bot state has changed. Please run /history again.',
'当前 Harness 暂不支持读取会话历史。':
'This Harness does not support reading Session history.',
'历史读取已取消。': 'History reading was cancelled.',
'读取历史超时,请稍后重试。': 'Reading history timed out. Please try again later.',
'暂时无法读取会话历史,请稍后重试。':
'Unable to read Session history right now. Please try again later.',
'请在与机器人的私聊中使用 /history。':
'Please use /history in a direct chat with the bot.',
'/history 仅支持文字命令,请移除图片或文件后重试。':
'/history supports text commands only. Remove images or files and try again.',
'当前聊天尚未绑定会话,请先发送消息或使用 /session 绑定会话。':
'This chat has no bound Session. Send a message or use /session to bind one first.',
'本次有限读取中未找到可预览的历史消息。':
'No history messages were available to preview within this bounded read.',
'当前会话暂无可预览的历史消息。':
'This Session has no history messages available to preview yet.',
// harness-approval.mjs
'请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。':
'Please reply exactly with 「批准」 (approve) or 「拒绝」 (reject). Also accepted: 同意 / 不同意 / yes / no.',

View file

@ -1,6 +1,7 @@
import { t } from './i18n.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
import {
isControlCommand,
runControlCommand,
@ -260,7 +261,8 @@ export class TextHarnessBridge {
}
const collectingBatch = normalized.kind === 'direct'
&& this.#batches.status(key).phase === 'collecting';
const commandRunner = collectingBatch || hasInboundFiles(normalized) ? null : isControlCommand(text)
const commandRunner = collectingBatch ? null : isHistoryCommand(text) ? runHistoryCommand
: hasInboundFiles(normalized) ? null : isControlCommand(text)
? runControlCommand
: (isModelCommand(text)
? runModelCommand
@ -417,6 +419,7 @@ export class TextHarnessBridge {
key,
{
signal: this.#signal,
isDirect: message.kind === 'direct',
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)
@ -529,6 +532,7 @@ export class TextHarnessBridge {
t('直接发送文字、图片或文件即可继续当前会话。'),
t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),

View file

@ -344,6 +344,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
t('标题:{title}', { title }),
`ID:${boundSessionId}`,
t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }),
t('发送 /history 查看最近对话。'),
].join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) {

View file

@ -12,6 +12,7 @@ import {
isBatchInputCommand,
} from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import {
isControlCommand,
runControlCommand,
@ -61,6 +62,7 @@ function helpText() {
t('直接发送文字、图片或文件即可继续当前会话。'),
t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@ -591,7 +593,8 @@ export class WecomHarnessBridge {
return this.#finishBatchResult(frame, messageId, chatId, result);
}
}
const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -768,6 +771,7 @@ export class WecomHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(message.content, this.#harness, this.#state, key, {
signal: this.#signal,
isDirect: bodyOf(frame).chattype === 'single',
hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key)

View file

@ -18,6 +18,7 @@ import {
isBatchInputCommand,
} from '../shared/batch-input.mjs';
import { runCompactCommand } from '../shared/compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
import {
isControlCommand,
runControlCommand,
@ -68,6 +69,7 @@ const HELP_TEXT = () => [
t('直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。'),
t('/new 开启一个全新会话'),
t('/compact 压缩当前会话的较早上下文'),
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
t('/workspace 工作区绝对路径 切换工作区'),
t('/workspacelist 列出工作区绝对路径'),
t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
@ -364,7 +366,8 @@ export class WeixinHarnessBridge {
);
}
}
const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
: hasWeixinFileItems(message) ? null : isControlCommand(commandText)
? runControlCommand
: (isModelCommand(commandText)
? runModelCommand
@ -541,6 +544,7 @@ export class WeixinHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString();
const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal,
isDirect: true,
hasImages: hasWeixinImageItems(message),
hasFiles: hasWeixinFileItems(message),
pendingInteraction: this.#pendingInteractions.has(key)