mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 03:50:45 +08:00
feat: support compact command across IM channels
This commit is contained in:
parent
4545cfe9c5
commit
1cc7a26f30
40 changed files with 728 additions and 136 deletions
|
|
@ -9,6 +9,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -346,6 +348,17 @@ export class DingtalkHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(sessionWebhook, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.#api.createAiCard === 'function'
|
||||
&& typeof this.#api.updateAiCard === 'function'
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送问题即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -322,6 +324,17 @@ export class FeishuHarnessBridge {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(event.message.chat_id, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
|
|
@ -14,6 +15,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -241,6 +243,18 @@ export class QqHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
let streamFinished = false;
|
||||
|
|
|
|||
|
|
@ -668,6 +668,19 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
return target.ask(sessionId, ...args);
|
||||
};
|
||||
}
|
||||
if (property === 'executeCommand' && typeof target.executeCommand === 'function') {
|
||||
return (sessionId, ...args) => {
|
||||
const generation = sessionGenerations.get(sessionId);
|
||||
sessionGenerations.delete(sessionId);
|
||||
if (!isCurrentScope()
|
||||
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
||||
const error = new Error('The bot workspace changed before this command started.');
|
||||
error.code = WORKSPACE_SESSION_STALE;
|
||||
throw error;
|
||||
}
|
||||
return target.executeCommand(sessionId, ...args);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
|
|
|
|||
95
src/channels/shared/compact-command.mjs
Normal file
95
src/channels/shared/compact-command.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const COMPACT_COMMAND = /^\/compact(?=$|\s)([\s\S]*)$/i;
|
||||
const COMPACT_USAGE = '用法:/compact(不带参数)';
|
||||
|
||||
const COMPACT_RESULT_TEXT = new Map([
|
||||
['No compactable history yet.', '暂无可压缩的历史记录。'],
|
||||
[
|
||||
'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
|
||||
'当前会话正在生成回复或执行压缩,请稍后重试。',
|
||||
],
|
||||
['Compaction cancelled.', '上下文压缩已取消。'],
|
||||
[
|
||||
'The history selected for compaction changed before it could be replaced. The conversation is unchanged; the attempt is recorded in the session log.',
|
||||
'压缩期间会话历史发生变化,本次未修改会话,请重试。',
|
||||
],
|
||||
[
|
||||
'Compaction could not produce a useful summary. The conversation is unchanged; the attempt is recorded in the session log.',
|
||||
'未能生成有效的压缩摘要,本次未修改会话。',
|
||||
],
|
||||
[
|
||||
'Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.',
|
||||
'上下文压缩未正常完成,部分会话历史可能已变化,请检查会话后再重试。',
|
||||
],
|
||||
[
|
||||
'Compaction finished, but the session could not be saved.',
|
||||
'上下文已压缩,但会话保存失败。',
|
||||
],
|
||||
]);
|
||||
|
||||
function commandResult(message) {
|
||||
return { handled: true, message, messages: [message] };
|
||||
}
|
||||
|
||||
function compactResultText(result) {
|
||||
if (!result || typeof result !== 'object'
|
||||
|| !['success', 'error'].includes(result.kind)
|
||||
|| (result.text !== undefined && typeof result.text !== 'string')) {
|
||||
throw new TypeError('Harness returned an invalid /compact result');
|
||||
}
|
||||
const text = result.text?.trim() ?? '';
|
||||
const compacted = /^Compacted (\d+) history items \(~(\d+) tokens\)\.$/u.exec(text);
|
||||
if (compacted) {
|
||||
return `已压缩 ${compacted[1]} 条历史记录(约 ${compacted[2]} 个 token)。`;
|
||||
}
|
||||
if (COMPACT_RESULT_TEXT.has(text)) return COMPACT_RESULT_TEXT.get(text);
|
||||
if (text) return text;
|
||||
return result.kind === 'success' ? '上下文压缩完成。' : '上下文压缩失败。';
|
||||
}
|
||||
|
||||
function compactErrorMessage(error) {
|
||||
const code = error?.code ?? error?.failure?.code;
|
||||
if (code === 'session-not-found') {
|
||||
return '当前聊天绑定的会话已不存在,请发送新消息开启会话。';
|
||||
}
|
||||
if (code === 'agent-busy') return '当前会话正在生成回复,请稍后重试。';
|
||||
if (code === 'cancelled' || error?.name === 'AbortError') return '上下文压缩已取消。';
|
||||
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
|
||||
return '工作区或机器人状态已发生变化,请重试。';
|
||||
}
|
||||
if (code === 'commands-unavailable') {
|
||||
return '当前 Harness 暂不支持从机器人执行上下文压缩。';
|
||||
}
|
||||
return '上下文压缩失败,请稍后重试。';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the explicit Harness compaction command for an existing IM conversation Session.
|
||||
* Unknown input returns null so the caller may continue ordinary message routing.
|
||||
*/
|
||||
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const match = COMPACT_COMMAND.exec(text.trim());
|
||||
if (!match) return null;
|
||||
if (match[1].trim()) return commandResult(COMPACT_USAGE);
|
||||
if (typeof state?.sessionFor !== 'function') {
|
||||
return commandResult('当前机器人没有可用的会话状态。');
|
||||
}
|
||||
const sessionId = state.sessionFor(conversationKey);
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
return commandResult('当前聊天还没有可压缩的会话,请先发送一条消息。');
|
||||
}
|
||||
if (typeof harness?.executeCommand !== 'function') {
|
||||
return commandResult('当前机器人暂不支持上下文压缩。');
|
||||
}
|
||||
try {
|
||||
const execution = await harness.executeCommand(sessionId, '/compact', options);
|
||||
if (execution === undefined) {
|
||||
return commandResult('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。');
|
||||
}
|
||||
return commandResult(compactResultText(execution?.result));
|
||||
} catch (error) {
|
||||
return commandResult(compactErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +318,7 @@ export class HarnessClient {
|
|||
#interactionReconnectDelayMs;
|
||||
#rpcIdPrefix;
|
||||
#logPrefix;
|
||||
#commandExecutor;
|
||||
#managedProcess = null;
|
||||
#interactionRegistry;
|
||||
#interactionOwnerships;
|
||||
|
|
@ -334,6 +335,7 @@ export class HarnessClient {
|
|||
interactionReconnectDelayMs = 500,
|
||||
rpcIdPrefix = 'im',
|
||||
logPrefix = 'dsh-im',
|
||||
commandExecutor,
|
||||
}) {
|
||||
if (typeof createWebSocket !== 'function') {
|
||||
throw new TypeError('createWebSocket must be a function');
|
||||
|
|
@ -347,6 +349,9 @@ export class HarnessClient {
|
|||
if (typeof logPrefix !== 'string' || !logPrefix.trim()) {
|
||||
throw new TypeError('logPrefix must be a non-empty string');
|
||||
}
|
||||
if (commandExecutor !== undefined && typeof commandExecutor !== 'function') {
|
||||
throw new TypeError('commandExecutor must be a function');
|
||||
}
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
this.#workspace = workspace;
|
||||
this.#agentPreset = agentPreset;
|
||||
|
|
@ -357,6 +362,7 @@ export class HarnessClient {
|
|||
this.#interactionReconnectDelayMs = interactionReconnectDelayMs;
|
||||
this.#rpcIdPrefix = rpcIdPrefix.trim();
|
||||
this.#logPrefix = logPrefix.trim();
|
||||
this.#commandExecutor = commandExecutor;
|
||||
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
|
||||
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
|
||||
this.#interactionClaims = this.#interactionRegistry.claims;
|
||||
|
|
@ -459,6 +465,24 @@ export class HarnessClient {
|
|||
return created.sessionId;
|
||||
}
|
||||
|
||||
async executeCommand(sessionId, line, options = {}) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||
if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
|
||||
if (!this.#commandExecutor) {
|
||||
const error = new Error('Harness command execution is unavailable');
|
||||
error.code = 'commands-unavailable';
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return await this.#commandExecutor(sessionId, line, options);
|
||||
} catch (error) {
|
||||
if (error?.failure && typeof error.failure === 'object') {
|
||||
throw new HarnessRpcError('commands.execute', error.failure);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sessionExists(sessionId, options = {}) {
|
||||
try {
|
||||
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||
import { runCompactCommand } from './compact-command.mjs';
|
||||
import { askInWorkspaceSession } from './workspace-session.mjs';
|
||||
import { HarnessApprovalQueue } from './harness-approval.mjs';
|
||||
import {
|
||||
|
|
@ -218,6 +219,7 @@ export class TextHarnessBridge {
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -244,6 +246,17 @@ export class TextHarnessBridge {
|
|||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
conversationKey,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#bot.sendText(target, compactCommand.message);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#bot.sendTyping?.(target).catch((error) => {
|
||||
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -321,6 +323,18 @@ export class WecomHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#sendImmediate(frame, chatId, compactCommand.message);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
streamId = this.#generateReqId('stream');
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
||||
import { runCompactCommand } from '../shared/compact-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ const HELP_TEXT = [
|
|||
'',
|
||||
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/compact 压缩当前会话的较早上下文',
|
||||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
|
|
@ -248,6 +250,18 @@ export class WeixinHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const compactCommand = await runCompactCommand(
|
||||
text,
|
||||
this.#harness,
|
||||
this.#state,
|
||||
key,
|
||||
{ signal: this.#signal },
|
||||
);
|
||||
if (compactCommand) {
|
||||
await this.#send(sender, compactCommand.message, contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let answer;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue