feat: add workspace list command

This commit is contained in:
xmanrui 2026-08-18 00:39:16 +08:00
parent c44340accb
commit 1fe6180813
17 changed files with 570 additions and 118 deletions

View file

@ -15,6 +15,7 @@ const HELP_TEXT = [
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -218,7 +219,9 @@ export class DingtalkHarnessBridge {
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#send(sessionWebhook, workspaceCommand.message);
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#send(sessionWebhook, reply);
}
return;
}

View file

@ -1,5 +1,13 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
function workspacePaths(value) {
if (!Array.isArray(value?.items)) return [];
return value.items.flatMap((item) => (
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
));
}
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
@ -216,6 +224,11 @@ export class HarnessClient {
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
}
async listWorkspaces(options = {}) {
await this.ensureRunning(options);
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
}
async workspaceId(options = {}) {
const { workspace = this.#workspace, ...rpcOptions } = options;
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);

View file

@ -14,6 +14,7 @@ const HELP_TEXT = [
'直接发送问题即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -111,7 +112,9 @@ export class FeishuHarnessBridge {
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#send(event.message.chat_id, workspaceCommand.message);
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#send(event.message.chat_id, reply);
}
return;
}

View file

@ -1,8 +1,16 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function workspacePaths(value) {
if (!Array.isArray(value?.items)) return [];
return value.items.flatMap((item) => (
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
));
}
function messageText(event) {
return (event?.data?.message?.content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -197,6 +205,11 @@ export class HarnessClient {
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
}
async listWorkspaces(options = {}) {
await this.ensureRunning();
return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
}
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});

View file

@ -7,6 +7,7 @@ const HELP_TEXT = [
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -127,7 +128,9 @@ export class QqHarnessBridge {
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#bot.sendText(target, workspaceCommand.message);
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#bot.sendText(target, reply);
}
await this.#state.markSeen(messageId);
return;
}

View file

@ -398,7 +398,41 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
const sessionGenerations = new Map();
const scopedHarness = new Proxy(harness, {
get(target, property) {
if (property === 'currentWorkspace') return () => workspaces.workspaceFor(botId);
if (property === 'currentWorkspace') {
return () => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
return workspaces.workspaceFor(botId);
};
}
if (property === 'assertWorkspaceScope') {
return () => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
};
}
if (property === 'listWorkspaces' && typeof target.listWorkspaces === 'function') {
return async (...args) => {
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const paths = await target.listWorkspaces(...args);
if (!isCurrentScope()) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
return paths;
};
}
if (property === 'switchWorkspace') {
return (workspace) => {
if (!isCurrentScope()) {

View file

@ -101,6 +101,7 @@ export class TextHarnessBridge {
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n'));
@ -115,7 +116,9 @@ export class TextHarnessBridge {
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#bot.sendText(target, workspaceCommand.message);
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#bot.sendText(target, reply);
}
await this.#state.markSeen(messageId);
return;
}

View file

@ -1,25 +1,114 @@
import { stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path';
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
const WORKSPACE_LIST_COMMAND = /^\/workspacelist(?:\s+([\s\S]+))?$/i;
const MAX_WORKSPACE_PATH_LENGTH = 4_096;
const MAX_COMMAND_MESSAGE_LENGTH = 1_800;
function commandResult(message, messages = [message]) {
return { handled: true, message, messages };
}
function normalizedWorkspacePath(value) {
if (typeof value !== 'string' || value.length > MAX_WORKSPACE_PATH_LENGTH
|| !isAbsolute(value) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return null;
return resolve(value);
}
async function existingWorkspacePaths(values) {
const unique = [...new Set(values.map(normalizedWorkspacePath).filter(Boolean))];
const checked = await Promise.all(unique.map(async (workspace) => {
try {
return (await stat(workspace)).isDirectory() ? workspace : null;
} catch {
return null;
}
}));
return checked.filter(Boolean);
}
export function splitWorkspaceCommandMessage(message) {
const messages = [];
let offset = 0;
while (offset < message.length) {
let end = Math.min(offset + MAX_COMMAND_MESSAGE_LENGTH, message.length);
if (end < message.length) {
const lineBreak = message.lastIndexOf('\n', end - 1);
if (lineBreak >= offset) {
end = lineBreak + 1;
} else {
const trailing = message.charCodeAt(end - 1);
const leading = message.charCodeAt(end);
if (trailing >= 0xd800 && trailing <= 0xdbff
&& leading >= 0xdc00 && leading <= 0xdfff) end -= 1;
}
}
messages.push(message.slice(offset, end));
offset = end;
}
return messages;
}
async function runWorkspaceListCommand(match, harness) {
if (match[1]?.trim()) return commandResult('用法:/workspacelist');
if (typeof harness?.listWorkspaces !== 'function') {
return commandResult('当前机器人暂不支持列出工作区。');
}
try {
const listed = await harness.listWorkspaces();
const current = typeof harness.currentWorkspace === 'function'
? normalizedWorkspacePath(harness.currentWorkspace())
: null;
const paths = await existingWorkspacePaths([
...(current ? [current] : []),
...(Array.isArray(listed) ? listed : []),
]);
harness.assertWorkspaceScope?.();
if (paths.length === 0) {
return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。');
}
const lines = [
`当前 Harness Host 上存在的工作区(${paths.length}):`,
...paths.map((workspace, index) => (
`${index + 1}. ${workspace}${workspace === current ? '(当前)' : ''}`
)),
'',
'切换用法:/workspace 工作区绝对路径',
];
const message = lines.join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) {
if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区。');
}
return commandResult('暂时无法获取工作区列表,请稍后重试。');
}
}
export async function runWorkspaceCommand(text, harness) {
if (typeof text !== 'string') return null;
const match = WORKSPACE_COMMAND.exec(text.trim());
const command = text.trim();
const listMatch = WORKSPACE_LIST_COMMAND.exec(command);
if (listMatch) return runWorkspaceListCommand(listMatch, harness);
const match = WORKSPACE_COMMAND.exec(command);
if (!match) return null;
const workspace = match[1]?.trim();
if (!workspace) {
return { handled: true, message: '用法:/workspace 工作区绝对路径' };
return commandResult('用法:/workspace 工作区绝对路径');
}
if (typeof harness?.switchWorkspace !== 'function') {
return { handled: true, message: '当前机器人暂不支持切换工作区。' };
return commandResult('当前机器人暂不支持切换工作区。');
}
try {
const current = await harness.switchWorkspace(workspace);
return { handled: true, message: `工作区已切换为:${current}` };
return commandResult(`工作区已切换为:${current}`);
} catch (error) {
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
return { handled: true, message: `${error.message}\n用法:/workspace 工作区绝对路径` };
return commandResult(`${error.message}\n用法:/workspace 工作区绝对路径`);
}
if (error?.code === 'workspace-bot-not-found') {
return { handled: true, message: '机器人正在移除或已重新接入,无法切换原会话的工作区。' };
return commandResult('机器人正在移除或已重新接入,无法切换原会话的工作区。');
}
throw error;
}

View file

@ -8,6 +8,7 @@ const HELP_TEXT = [
'直接发送文字即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -187,7 +188,9 @@ export class WecomHarnessBridge {
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#sendImmediate(frame, chatId, workspaceCommand.message);
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#sendImmediate(frame, chatId, reply);
}
await this.#state.markSeen(messageId);
return;
}

View file

@ -1,8 +1,16 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function workspacePaths(value) {
if (!Array.isArray(value?.items)) return [];
return value.items.flatMap((item) => (
typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
));
}
function assistantMessageText(event) {
return (event?.data?.message?.content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
@ -187,6 +195,11 @@ export class HarnessClient {
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
}
async listWorkspaces(options = {}) {
await this.ensureRunning();
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
}
async workspaceId(options = {}) {
const workspace = options.workspace ?? this.#workspace;
const { items } = await this.rpc('workspace.list', {});

View file

@ -12,6 +12,7 @@ const HELP_TEXT = [
'直接发送文字或带文字识别结果的语音即可继续当前会话。',
'/new 开启一个全新会话',
'/workspace 工作区绝对路径 切换工作区',
'/workspacelist 列出工作区绝对路径',
'/status 检查连接状态',
'/help 显示本帮助',
].join('\n');
@ -138,7 +139,9 @@ export class WeixinHarnessBridge {
}
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
if (workspaceCommand) {
await this.#send(sender, workspaceCommand.message, contextToken, runId);
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#send(sender, reply, contextToken, runId);
}
await this.#state.markSeen(messageId);
return;
}