mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 06:30:46 +08:00
feat: bind bot chats to existing harness sessions
This commit is contained in:
parent
76d5430f1f
commit
1af8610cd5
21 changed files with 1807 additions and 130 deletions
|
|
@ -17,6 +17,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -218,7 +219,7 @@ export class DingtalkHarnessBridge {
|
|||
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sessionWebhook, reply);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
||||
|
||||
function workspacePaths(value) {
|
||||
if (!Array.isArray(value?.items)) return [];
|
||||
return value.items.flatMap((item) => (
|
||||
|
|
@ -279,6 +281,10 @@ export class HarnessClient {
|
|||
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
||||
}
|
||||
|
||||
async adoptWorkspaceSession(value, options = {}) {
|
||||
return adoptRegisteredWorkspaceSession(this, value, options);
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const { workspace = this.#workspace, ...rpcOptions } = options;
|
||||
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -111,7 +112,7 @@ export class FeishuHarnessBridge {
|
|||
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(event.message.chat_id, reply);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function workspacePaths(value) {
|
||||
|
|
@ -260,6 +262,10 @@ export class HarnessClient {
|
|||
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
||||
}
|
||||
|
||||
async adoptWorkspaceSession(value, options = {}) {
|
||||
return adoptRegisteredWorkspaceSession(this, value, options, 30000);
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const workspace = options.workspace ?? this.#workspace;
|
||||
const { items } = await this.rpc('workspace.list', {});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -127,7 +128,7 @@ export class QqHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,37 @@
|
|||
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
|
||||
|
||||
function workspaceSessionStale(message) {
|
||||
const error = new Error(message);
|
||||
error.code = WORKSPACE_SESSION_STALE;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function canonicalWorkspacePath(value) {
|
||||
return resolve(await realpath(value));
|
||||
}
|
||||
|
||||
async function sameWorkspacePath(left, right) {
|
||||
if (left === right) return true;
|
||||
try {
|
||||
return await canonicalWorkspacePath(left) === await canonicalWorkspacePath(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function botIdOf(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) {
|
||||
throw new TypeError('Invalid bot id');
|
||||
|
|
@ -179,6 +206,72 @@ export class BotWorkspaceStore {
|
|||
});
|
||||
}
|
||||
|
||||
async bindWorkspaceSession(botId, value, {
|
||||
conversationKey,
|
||||
sessionId,
|
||||
clearSessions,
|
||||
setSession,
|
||||
incarnation,
|
||||
expectedGeneration,
|
||||
} = {}) {
|
||||
const id = botIdOf(botId);
|
||||
if (typeof conversationKey !== 'string' || !conversationKey
|
||||
|| typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('conversationKey and sessionId are required');
|
||||
}
|
||||
if (typeof clearSessions !== 'function' || typeof setSession !== 'function') {
|
||||
throw new TypeError('session state callbacks are required');
|
||||
}
|
||||
if (!this.has(id)
|
||||
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id)
|
||||
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (expectedGeneration !== undefined
|
||||
&& expectedGeneration !== this.generationFor(id)) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed before the session binding could be committed.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await sameWorkspacePath(workspace, this.workspaceFor(id)))) {
|
||||
const previous = this.#workspaces[id];
|
||||
// Fence every session resolved before this transition, then remove
|
||||
// the old workspace mappings before publishing the new workspace.
|
||||
this.#generations.set(id, this.#freshGeneration());
|
||||
await clearSessions();
|
||||
this.#workspaces[id] = workspace;
|
||||
try {
|
||||
await this.#persist();
|
||||
} catch (error) {
|
||||
// Session mappings stay cleared and the advanced generation stays
|
||||
// fenced. Restoring either could pair an old session with a state
|
||||
// transition whose durable outcome is unknown.
|
||||
this.#workspaces[id] = previous;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// This write remains inside the same bot transition as the workspace
|
||||
// mutation, so another switch or bind cannot interleave between them.
|
||||
await setSession(conversationKey, sessionId);
|
||||
return {
|
||||
workspace,
|
||||
sessionId,
|
||||
generation: this.#generations.get(id),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async invalidateSessions(botId, { clearSessions } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
return this.#enqueue(id, async () => {
|
||||
|
|
@ -447,6 +540,62 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
});
|
||||
};
|
||||
}
|
||||
if (property === 'bindWorkspaceSession') {
|
||||
return async (conversationKey, sessionId) => {
|
||||
if (typeof conversationKey !== 'string' || !conversationKey
|
||||
|| typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('conversationKey and sessionId are required');
|
||||
}
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (typeof target.adoptWorkspaceSession !== 'function') {
|
||||
throw new TypeError('Harness does not support adopting workspace sessions');
|
||||
}
|
||||
const expectedGeneration = workspaces.generationFor(botId);
|
||||
const adopted = await target.adoptWorkspaceSession(sessionId);
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (expectedGeneration !== workspaces.generationFor(botId)) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed while the session was being adopted.',
|
||||
);
|
||||
}
|
||||
if (!adopted || typeof adopted !== 'object'
|
||||
|| adopted.sessionId !== sessionId || typeof adopted.workspace !== 'string') {
|
||||
throw new TypeError('Harness returned an invalid adopted workspace session');
|
||||
}
|
||||
const bound = await workspaces.bindWorkspaceSession(botId, adopted.workspace, {
|
||||
conversationKey,
|
||||
sessionId,
|
||||
clearSessions: () => state.clearSessions(),
|
||||
setSession: (key, selectedSessionId) => state.setSession(key, selectedSessionId),
|
||||
incarnation,
|
||||
expectedGeneration,
|
||||
});
|
||||
if (!isCurrentScope()) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
if (bound.generation !== workspaces.generationFor(botId)) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed before the session binding completed.',
|
||||
);
|
||||
}
|
||||
sessionGenerations.set(sessionId, bound.generation);
|
||||
return {
|
||||
...adopted,
|
||||
workspace: bound.workspace,
|
||||
sessionId: bound.sessionId,
|
||||
};
|
||||
};
|
||||
}
|
||||
if (property === 'createSession') {
|
||||
return async (options = {}) => {
|
||||
await workspaces.whenBotIdle(botId);
|
||||
|
|
@ -464,6 +613,37 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
|
|||
return sessionId;
|
||||
};
|
||||
}
|
||||
if (property === 'workspaceSession') {
|
||||
return (sessionId) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('sessionId is required');
|
||||
}
|
||||
const generation = sessionGenerations.get(sessionId)
|
||||
?? workspaces.generationFor(botId);
|
||||
// Transfer the mutable provenance entry into this immutable handle.
|
||||
// A later handle for the same id captures its own generation instead
|
||||
// of sharing deletion or rebinding state with this call.
|
||||
sessionGenerations.delete(sessionId);
|
||||
const isCurrentSession = () => isCurrentScope()
|
||||
&& generation === workspaces.generationFor(botId);
|
||||
return Object.freeze({
|
||||
sessionId,
|
||||
async sessionExists(...args) {
|
||||
if (!isCurrentSession()) return false;
|
||||
const exists = await target.sessionExists(sessionId, ...args);
|
||||
return isCurrentSession() && exists;
|
||||
},
|
||||
ask(...args) {
|
||||
if (!isCurrentSession()) {
|
||||
throw workspaceSessionStale(
|
||||
'The bot workspace changed before this prompt started.',
|
||||
);
|
||||
}
|
||||
return target.ask(sessionId, ...args);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
if (property === 'sessionExists') {
|
||||
return (sessionId, ...args) => {
|
||||
if (!isCurrentScope()) return false;
|
||||
|
|
|
|||
110
src/channels/shared/harness-session-binding.mjs
Normal file
110
src/channels/shared/harness-session-binding.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MAX_SESSION_ID_LENGTH = 256;
|
||||
const UNSAFE_SESSION_ID = /[\p{White_Space}\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
||||
const UNSAFE_WORKSPACE_PATH = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
||||
|
||||
function bindingError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function validatedSessionId(value) {
|
||||
if (typeof value !== 'string' || !value || value.length > MAX_SESSION_ID_LENGTH
|
||||
|| UNSAFE_SESSION_ID.test(value)) {
|
||||
throw bindingError('session-id-invalid', 'A non-empty, safe session id is required');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sessionWorkspace(sessionId, value) {
|
||||
if (!Array.isArray(value?.items) || !Array.isArray(value?.archivedSessionIds)
|
||||
|| value.archivedSessionIds.some((id) => typeof id !== 'string' || !id)) {
|
||||
throw new Error('Harness returned an invalid response for workspace.list');
|
||||
}
|
||||
|
||||
const owners = [];
|
||||
for (const workspace of value.items) {
|
||||
if (typeof workspace?.workspaceId !== 'string' || !workspace.workspaceId
|
||||
|| typeof workspace.path !== 'string' || !isAbsolute(workspace.path)
|
||||
|| !Array.isArray(workspace.sessionIds)
|
||||
|| workspace.sessionIds.some((id) => typeof id !== 'string' || !id)) {
|
||||
throw new Error('Harness returned an invalid response for workspace.list');
|
||||
}
|
||||
for (const accountedId of workspace.sessionIds) {
|
||||
if (accountedId === sessionId) owners.push(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
if (owners.length === 0) {
|
||||
throw bindingError('session-not-registered', 'The session is not registered to a Harness workspace');
|
||||
}
|
||||
if (owners.length !== 1) {
|
||||
throw bindingError(
|
||||
'session-workspace-ambiguous',
|
||||
'The session is registered to more than one Harness workspace',
|
||||
);
|
||||
}
|
||||
if (UNSAFE_WORKSPACE_PATH.test(owners[0].path)) {
|
||||
throw new Error('Harness returned an unsafe workspace path for the session');
|
||||
}
|
||||
return {
|
||||
workspace: owners[0],
|
||||
archived: value.archivedSessionIds.includes(sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function sessionSummary(sessionId, value) {
|
||||
if (!Array.isArray(value?.items)
|
||||
|| value.items.some((item) => typeof item?.sessionId !== 'string' || !item.sessionId)) {
|
||||
throw new Error('Harness returned an invalid response for session.list');
|
||||
}
|
||||
const matches = value.items.filter((item) => item.sessionId === sessionId);
|
||||
if (matches.length === 0) {
|
||||
throw bindingError('session-summary-unavailable', 'The session is no longer available from Harness');
|
||||
}
|
||||
if (matches.length !== 1) {
|
||||
throw new Error('Harness returned duplicate session summaries for session.list');
|
||||
}
|
||||
|
||||
const [summary] = matches;
|
||||
if (summary.origin === 'subagent') {
|
||||
throw bindingError(
|
||||
'session-subagent-unsupported',
|
||||
'Subagent sessions cannot be adopted as a bot conversation',
|
||||
);
|
||||
}
|
||||
if (summary.origin !== undefined) {
|
||||
throw new Error('Harness returned an invalid session origin for session.list');
|
||||
}
|
||||
const title = summary.projections?.values?.title;
|
||||
if (title !== undefined && title !== null && typeof title !== 'string') {
|
||||
throw new Error('Harness returned an invalid session title for session.list');
|
||||
}
|
||||
return { title: typeof title === 'string' ? title : null };
|
||||
}
|
||||
|
||||
export async function adoptRegisteredWorkspaceSession(client, value, options = {}, timeoutMs = 30_000) {
|
||||
const sessionId = validatedSessionId(value);
|
||||
await client.ensureRunning(options);
|
||||
const workspaceList = await client.rpc('workspace.list', {}, timeoutMs, options);
|
||||
const { workspace, archived } = sessionWorkspace(sessionId, workspaceList);
|
||||
const summary = sessionSummary(
|
||||
sessionId,
|
||||
await client.rpc('session.list', {}, timeoutMs, options),
|
||||
);
|
||||
const adopted = await client.rpc('session.create', {
|
||||
workspaceId: workspace.workspaceId,
|
||||
sessionId,
|
||||
}, timeoutMs, options);
|
||||
if (!adopted || adopted.sessionId !== sessionId) {
|
||||
throw new Error('Harness returned an invalid response for session.create');
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
workspace: workspace.path,
|
||||
title: summary.title,
|
||||
archived,
|
||||
};
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@ export class TextHarnessBridge {
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n'));
|
||||
|
|
@ -115,7 +116,8 @@ export class TextHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const conversationKey = `${message.kind}:${message.conversationId}`;
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#bot.sendText(target, reply);
|
||||
|
|
@ -123,7 +125,6 @@ export class TextHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const conversationKey = `${message.kind}:${message.conversationId}`;
|
||||
if (command === '/new') {
|
||||
await this.#state.clearSession(conversationKey);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { isAbsolute, resolve } from 'node:path';
|
||||
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
|
||||
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
|
||||
const SESSION_LIST_COMMAND = /^\/sessionlist(?:\s+([\s\S]+))?$/i;
|
||||
const SESSION_BIND_PREFIX = /^\/session(?=$|\s)/i;
|
||||
const SESSION_BIND_COMMAND = /^\/session[ \t]+([^\s]+)$/i;
|
||||
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
|
||||
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
|
||||
const MAX_SESSION_ID_LENGTH = 256;
|
||||
const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
||||
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
||||
const SESSION_BIND_USAGE = '用法:/session Session ID';
|
||||
const SESSION_LIST_USAGE = [
|
||||
'用法:',
|
||||
'/sessionlist 列出当前工作区会话',
|
||||
|
|
@ -30,6 +36,14 @@ function safeDisplayText(value) {
|
|||
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
function validSessionId(value) {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= MAX_SESSION_ID_LENGTH
|
||||
&& !/\p{White_Space}/u.test(value)
|
||||
&& !UNSAFE_DISPLAY_TEXT.test(value);
|
||||
}
|
||||
|
||||
async function existingWorkspacePaths(values) {
|
||||
const checked = await Promise.all(values.map(async (value) => {
|
||||
const workspace = normalizedWorkspacePath(value);
|
||||
|
|
@ -176,6 +190,8 @@ function sessionListMessage(workspace, sessions) {
|
|||
`会话(${rows.length}):`,
|
||||
'',
|
||||
...rows.map((row, index) => `${index + 1}. ${row}`),
|
||||
'',
|
||||
'绑定用法:/session Session ID',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
@ -203,9 +219,70 @@ async function runSessionListCommand(match, harness) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function runWorkspaceCommand(text, harness) {
|
||||
function sessionBindErrorMessage(error) {
|
||||
if (error?.code === 'session-id-invalid') {
|
||||
return `Session ID 格式无效。\n${SESSION_BIND_USAGE}`;
|
||||
}
|
||||
if (['session-not-registered', 'session-not-found'].includes(error?.code)) {
|
||||
return '未找到该会话,请先执行 /sessionlist 确认 Session ID。';
|
||||
}
|
||||
if (error?.code === 'session-subagent-unsupported') {
|
||||
return '子代理会话不能绑定到机器人对话,请选择普通会话。';
|
||||
}
|
||||
if (error?.code === 'session-workspace-ambiguous') {
|
||||
return '该会话的工作区归属不明确,暂时无法绑定。';
|
||||
}
|
||||
if (error?.code === 'session-summary-unavailable') {
|
||||
return '暂时无法读取该会话的信息,请稍后重试。';
|
||||
}
|
||||
if (error?.code === 'workspace-bot-not-found') {
|
||||
return '机器人正在移除或已重新接入,无法绑定原对话的会话。';
|
||||
}
|
||||
if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict']
|
||||
.includes(error?.code)) {
|
||||
return '工作区或会话状态已发生变化,请重试。';
|
||||
}
|
||||
return '暂时无法绑定会话,请稍后重试。';
|
||||
}
|
||||
|
||||
async function runSessionBindCommand(command, harness, conversationKey) {
|
||||
const match = SESSION_BIND_COMMAND.exec(command);
|
||||
const sessionId = match?.[1];
|
||||
if (!validSessionId(sessionId)) return commandResult(SESSION_BIND_USAGE);
|
||||
if (typeof harness?.bindWorkspaceSession !== 'function') {
|
||||
return commandResult('当前机器人暂不支持绑定已有会话。');
|
||||
}
|
||||
if (typeof conversationKey !== 'string' || !conversationKey) {
|
||||
return commandResult('当前消息缺少可绑定的会话上下文。');
|
||||
}
|
||||
try {
|
||||
const bound = await harness.bindWorkspaceSession(conversationKey, sessionId);
|
||||
harness.assertWorkspaceScope?.();
|
||||
const workspace = normalizedWorkspacePath(bound?.workspace);
|
||||
const boundSessionId = safeDisplayText(bound?.sessionId);
|
||||
if (!workspace || !boundSessionId) {
|
||||
throw new TypeError('Harness returned an invalid bound session');
|
||||
}
|
||||
const title = safeDisplayText(bound?.title) || '暂无标题';
|
||||
const message = [
|
||||
'当前聊天已绑定会话:',
|
||||
`工作区:${workspace}`,
|
||||
`标题:${title}`,
|
||||
`ID:${boundSessionId}`,
|
||||
`归档:${bound?.archived === true ? '是' : '否'}`,
|
||||
].join('\n');
|
||||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||||
} catch (error) {
|
||||
return commandResult(sessionBindErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWorkspaceCommand(text, harness, conversationKey) {
|
||||
if (typeof text !== 'string') return null;
|
||||
const command = text.trim();
|
||||
if (SESSION_BIND_PREFIX.test(command)) {
|
||||
return runSessionBindCommand(command, harness, conversationKey);
|
||||
}
|
||||
const sessionListMatch = SESSION_LIST_COMMAND.exec(command);
|
||||
if (sessionListMatch) return runSessionListCommand(sessionListMatch, harness);
|
||||
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
||||
|
||||
async function sessionExists(harness, sessionId, options) {
|
||||
function workspaceSession(harness, sessionId) {
|
||||
if (typeof harness.workspaceSession === 'function') {
|
||||
return harness.workspaceSession(sessionId);
|
||||
}
|
||||
return Object.freeze({
|
||||
sessionId,
|
||||
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
||||
ask: (...args) => harness.ask(sessionId, ...args),
|
||||
});
|
||||
}
|
||||
|
||||
async function sessionExists(session, options) {
|
||||
return options === undefined
|
||||
? harness.sessionExists(sessionId)
|
||||
: harness.sessionExists(sessionId, options);
|
||||
? session.sessionExists()
|
||||
: session.sessionExists(options);
|
||||
}
|
||||
|
||||
async function createSession(harness, options) {
|
||||
|
|
@ -27,15 +38,17 @@ export async function askInWorkspaceSession({
|
|||
askOptions,
|
||||
}) {
|
||||
while (true) {
|
||||
let sessionId = state.sessionFor(key);
|
||||
if (!sessionId || !(await sessionExists(harness, sessionId, existsOptions))) {
|
||||
sessionId = await createSession(harness, createOptions);
|
||||
if (await state.setSession(key, sessionId) === false) continue;
|
||||
}
|
||||
try {
|
||||
let sessionId = state.sessionFor(key);
|
||||
let session = sessionId ? workspaceSession(harness, sessionId) : null;
|
||||
if (!session || !(await sessionExists(session, existsOptions))) {
|
||||
sessionId = await createSession(harness, createOptions);
|
||||
if (await state.setSession(key, sessionId) === false) continue;
|
||||
session = workspaceSession(harness, sessionId);
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
answer: await harness.ask(sessionId, text, askOptions),
|
||||
answer: await session.ask(text, askOptions),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -187,7 +188,7 @@ export class WecomHarnessBridge {
|
|||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#sendImmediate(frame, chatId, reply);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function workspacePaths(value) {
|
||||
|
|
@ -250,6 +252,10 @@ export class HarnessClient {
|
|||
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
||||
}
|
||||
|
||||
async adoptWorkspaceSession(value, options = {}) {
|
||||
return adoptRegisteredWorkspaceSession(this, value, options);
|
||||
}
|
||||
|
||||
async workspaceId(options = {}) {
|
||||
const workspace = options.workspace ?? this.#workspace;
|
||||
const { items } = await this.rpc('workspace.list', {});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const HELP_TEXT = [
|
|||
'/workspace 工作区绝对路径 切换工作区',
|
||||
'/workspacelist 列出工作区绝对路径',
|
||||
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
||||
'/session Session ID 将当前聊天绑定到指定会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n');
|
||||
|
|
@ -121,6 +122,7 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
|
||||
const command = text.trim().toLowerCase();
|
||||
const key = conversationKey(sender);
|
||||
if (command === '/help') {
|
||||
await this.#send(sender, HELP_TEXT, contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
|
|
@ -133,12 +135,12 @@ export class WeixinHarnessBridge {
|
|||
return;
|
||||
}
|
||||
if (command === '/new') {
|
||||
await this.#state.clearSession(conversationKey(sender));
|
||||
await this.#state.clearSession(key);
|
||||
await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
||||
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
||||
if (workspaceCommand) {
|
||||
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
||||
await this.#send(sender, reply, contextToken, runId);
|
||||
|
|
@ -147,7 +149,6 @@ export class WeixinHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const key = conversationKey(sender);
|
||||
const { answer } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue