mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
408 lines
17 KiB
JavaScript
408 lines
17 KiB
JavaScript
import { realpath, stat } from 'node:fs/promises';
|
||
import { isAbsolute, resolve } from 'node:path';
|
||
|
||
import { t } from './i18n.mjs';
|
||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||
|
||
const WORKSPACE_COMMAND = /^\/(?:workspace|ws)(?:\s+([\s\S]+))?$/i;
|
||
const WORKSPACE_LIST_COMMAND = /^\/(?:workspacelist|workspaces|wsl)(?:\s+([\s\S]+))?$/i;
|
||
const SESSION_LIST_COMMAND = /^\/(?:sessionlist|sessions)(?:\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 或当前工作区序号(/session N)';
|
||
const SESSION_LIST_USAGE = [
|
||
'用法:',
|
||
'/sessionlist 列出当前工作区会话',
|
||
'/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
|
||
'/sessionlist 工作区绝对路径 列出指定工作区会话',
|
||
].join('\n');
|
||
|
||
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) || UNSAFE_DISPLAY_TEXT.test(value)) return null;
|
||
return resolve(value);
|
||
}
|
||
|
||
function safeDisplayText(value) {
|
||
if (typeof value !== 'string') return '';
|
||
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);
|
||
if (!workspace) return null;
|
||
try {
|
||
if (!(await stat(workspace)).isDirectory()) return null;
|
||
return normalizedWorkspacePath(await realpath(workspace));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}));
|
||
return [...new Set(checked.filter(Boolean))];
|
||
}
|
||
|
||
async function selectedWorkspacePath(value) {
|
||
if (typeof value !== 'string' || !isAbsolute(value.trim())) {
|
||
return { error: t(`工作区必须是绝对路径。
|
||
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
|
||
}
|
||
const workspace = normalizedWorkspacePath(value.trim());
|
||
if (!workspace) {
|
||
return { error: t(`工作区路径包含不支持的字符或长度超过限制。
|
||
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
|
||
}
|
||
let info;
|
||
try {
|
||
info = await stat(workspace);
|
||
} catch {
|
||
return { error: t(`工作区路径不存在。
|
||
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
|
||
}
|
||
if (!info.isDirectory()) {
|
||
return { error: t(`工作区路径必须指向一个目录。
|
||
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
|
||
}
|
||
try {
|
||
const canonical = normalizedWorkspacePath(await realpath(workspace));
|
||
return canonical
|
||
? { workspace: canonical }
|
||
: { error: t(`工作区路径包含不支持的字符或长度超过限制。
|
||
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
|
||
} catch {
|
||
return { error: t(`工作区路径不存在。
|
||
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
|
||
}
|
||
}
|
||
|
||
export async function workspacePathSnapshot(harness, options = {}) {
|
||
const listed = await harness.listWorkspaces(options);
|
||
const currentValue = typeof harness?.currentWorkspace === 'function'
|
||
? harness.currentWorkspace()
|
||
: null;
|
||
const [current] = currentValue ? await existingWorkspacePaths([currentValue]) : [];
|
||
const registered = await existingWorkspacePaths(Array.isArray(listed) ? listed : []);
|
||
const paths = [...new Set([...(current ? [current] : []), ...registered])];
|
||
harness.assertWorkspaceScope?.();
|
||
return { current: current ?? null, paths };
|
||
}
|
||
|
||
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(t('用法:/workspacelist'));
|
||
if (typeof harness?.listWorkspaces !== 'function') {
|
||
return commandResult(t('当前机器人暂不支持列出工作区。'));
|
||
}
|
||
try {
|
||
const { current, paths } = await workspacePathSnapshot(harness);
|
||
if (paths.length === 0) {
|
||
return commandResult(t('当前 Harness Host 上没有仍然存在的已登记工作区。'));
|
||
}
|
||
const lines = [
|
||
t('当前 Harness Host 上存在的工作区({count}):', { count: paths.length }),
|
||
...paths.map((workspace, index) => (
|
||
`${index + 1}. ${workspace}${workspace === current ? t('(当前)') : ''}`
|
||
)),
|
||
'',
|
||
t('切换用法:/workspace 工作区序号或绝对路径'),
|
||
t('查看会话:/sessionlist 工作区序号或绝对路径'),
|
||
];
|
||
const message = lines.join('\n');
|
||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||
} catch (error) {
|
||
if (error?.code === 'workspace-bot-not-found') {
|
||
return commandResult(t('机器人正在移除或已重新接入,无法列出原会话的工作区。'));
|
||
}
|
||
return commandResult(t('暂时无法获取工作区列表,请稍后重试。'));
|
||
}
|
||
}
|
||
|
||
export async function resolveSessionListWorkspace(selector, harness, options = {}) {
|
||
if (!selector) {
|
||
if (typeof harness?.currentWorkspace !== 'function') {
|
||
return { error: t('当前机器人没有可用的工作区。') };
|
||
}
|
||
const selected = await selectedWorkspacePath(harness.currentWorkspace());
|
||
harness.assertWorkspaceScope?.();
|
||
return selected;
|
||
}
|
||
|
||
if (/^\d+$/u.test(selector)) {
|
||
if (typeof harness?.listWorkspaces !== 'function') {
|
||
return { error: t('当前机器人暂不支持按序号选择工作区。') };
|
||
}
|
||
const { paths } = await workspacePathSnapshot(harness, options);
|
||
const position = Number(selector);
|
||
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
|
||
return { error: t('工作区序号不存在,请先执行 /workspacelist。') };
|
||
}
|
||
return { workspace: paths[position - 1] };
|
||
}
|
||
|
||
const selected = await selectedWorkspacePath(selector);
|
||
harness.assertWorkspaceScope?.();
|
||
return selected;
|
||
}
|
||
|
||
function formatSessionRelativeTime(value) {
|
||
const ms = typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||
if (ms === null) return '';
|
||
const date = new Date(ms);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||
const now = new Date();
|
||
const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
|
||
if (dayDiff === 0) return t('今天 {time}', { time: hm });
|
||
if (dayDiff === 1) return t('昨天 {time}', { time: hm });
|
||
if (dayDiff === 2) return t('前天 {time}', { time: hm });
|
||
if (date.getFullYear() === now.getFullYear()) {
|
||
return t('{month}月{day}日 {time}', { month: date.getMonth() + 1, day: date.getDate(), time: hm });
|
||
}
|
||
return t('{year}年{month}月{day}日', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
||
}
|
||
|
||
function sessionListMessage(workspace, sessions, { currentWorkspace = false } = {}) {
|
||
const rows = sessions.map((session) => {
|
||
const sessionId = safeDisplayText(session?.sessionId);
|
||
if (!sessionId) throw new TypeError('Harness returned an invalid session id');
|
||
const title = session?.summaryAvailable === false
|
||
? t('标题暂不可用')
|
||
: safeDisplayText(session?.title) || t('暂无标题');
|
||
const timeText = formatSessionRelativeTime(session?.time);
|
||
const annotation = `${timeText ? ` · ${timeText}` : ''}${session?.archived === true ? t('(已归档)') : ''}`;
|
||
return `${title}${annotation}\n ID: ${sessionId}`;
|
||
});
|
||
if (rows.length === 0) return t(`工作区:{workspace}
|
||
该工作区暂无会话。`, { workspace });
|
||
return [
|
||
t('工作区:{workspace}', { workspace }),
|
||
t('会话({count}):', { count: rows.length }),
|
||
'',
|
||
...rows.map((row, index) => `${index + 1}. ${row}`),
|
||
'',
|
||
currentWorkspace
|
||
? t('绑定用法:/session Session ID 或当前工作区序号(/session N)')
|
||
: t(`绑定用法:/session Session ID
|
||
提示:/session N 只按机器人当前工作区的序号绑定。`),
|
||
].join('\n');
|
||
}
|
||
|
||
async function currentSessionListWorkspace(harness) {
|
||
if (typeof harness?.currentWorkspace !== 'function') return null;
|
||
const [current] = await existingWorkspacePaths([harness.currentWorkspace()]);
|
||
harness.assertWorkspaceScope?.();
|
||
return current ?? null;
|
||
}
|
||
|
||
async function runSessionListCommand(match, harness) {
|
||
if (typeof harness?.listWorkspaceSessions !== 'function') {
|
||
return commandResult(t('当前机器人暂不支持列出工作区会话。'));
|
||
}
|
||
const selector = match[1]?.trim() ?? '';
|
||
try {
|
||
const resolved = await resolveSessionListWorkspace(selector, harness);
|
||
if (resolved.error) return commandResult(resolved.error);
|
||
const listed = await harness.listWorkspaceSessions(resolved.workspace);
|
||
if (!listed || !Array.isArray(listed.sessions)) {
|
||
throw new TypeError('Harness returned an invalid workspace session list');
|
||
}
|
||
harness.assertWorkspaceScope?.();
|
||
const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
|
||
const currentWorkspace = await currentSessionListWorkspace(harness);
|
||
const message = sessionListMessage(workspace, listed.sessions, {
|
||
currentWorkspace: workspace === currentWorkspace,
|
||
});
|
||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||
} catch (error) {
|
||
if (error?.code === 'workspace-bot-not-found') {
|
||
return commandResult(t('机器人正在移除或已重新接入,无法列出原会话的工作区会话。'));
|
||
}
|
||
return commandResult(t('暂时无法获取工作区会话列表,请稍后重试。'));
|
||
}
|
||
}
|
||
|
||
function sessionBindErrorMessage(error) {
|
||
if (error?.code === 'session-id-invalid') {
|
||
return t(`Session ID 格式无效。
|
||
{usage}`, { usage: t(SESSION_BIND_USAGE) });
|
||
}
|
||
if (['session-not-registered', 'session-not-found'].includes(error?.code)) {
|
||
return t('未找到该会话,请先执行 /sessionlist 确认 Session ID。');
|
||
}
|
||
if (error?.code === 'session-subagent-unsupported') {
|
||
return t('子代理会话不能绑定到机器人对话,请选择普通会话。');
|
||
}
|
||
if (error?.code === 'session-workspace-ambiguous') {
|
||
return t('该会话的工作区归属不明确,暂时无法绑定。');
|
||
}
|
||
if (error?.code === 'session-summary-unavailable') {
|
||
return t('暂时无法读取该会话的信息,请稍后重试。');
|
||
}
|
||
if (error?.code === 'workspace-bot-not-found') {
|
||
return t('机器人正在移除或已重新接入,无法绑定原对话的会话。');
|
||
}
|
||
if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict']
|
||
.includes(error?.code)) {
|
||
return t('工作区或会话状态已发生变化,请重试。');
|
||
}
|
||
return t('暂时无法绑定会话,请稍后重试。');
|
||
}
|
||
|
||
async function runSessionBindCommand(command, harness, conversationKey) {
|
||
const match = SESSION_BIND_COMMAND.exec(command);
|
||
let sessionId = match?.[1];
|
||
if (typeof sessionId === 'string' && /^\d+$/u.test(sessionId)) {
|
||
// 序号模式:把 /session N 解析成当前工作区会话列表中的第 N 个会话
|
||
if (typeof harness?.listWorkspaceSessions !== 'function'
|
||
|| typeof harness?.currentWorkspace !== 'function') {
|
||
return commandResult(t('当前机器人暂不支持按序号绑定,请使用 /session Session ID。'));
|
||
}
|
||
try {
|
||
const selected = await selectedWorkspacePath(harness.currentWorkspace());
|
||
if (selected.error) return commandResult(selected.error);
|
||
const listed = await harness.listWorkspaceSessions(selected.workspace);
|
||
if (!listed || !Array.isArray(listed.sessions)) {
|
||
throw new TypeError('Harness returned an invalid workspace session list');
|
||
}
|
||
harness.assertWorkspaceScope?.();
|
||
const position = Number(sessionId);
|
||
if (!Number.isSafeInteger(position) || position < 1
|
||
|| position > listed.sessions.length) {
|
||
return commandResult(t('会话序号不存在,请先执行 /sessionlist 查看序号。'));
|
||
}
|
||
const selectedSessionId = listed.sessions[position - 1]?.sessionId;
|
||
if (!validSessionId(selectedSessionId)) {
|
||
throw new TypeError('Harness returned an invalid session id');
|
||
}
|
||
sessionId = selectedSessionId;
|
||
} catch (error) {
|
||
if (error?.code === 'workspace-bot-not-found') {
|
||
return commandResult(sessionBindErrorMessage(error));
|
||
}
|
||
return commandResult(t('暂时无法获取会话列表,请稍后重试。'));
|
||
}
|
||
}
|
||
if (!validSessionId(sessionId)) return commandResult(t(SESSION_BIND_USAGE));
|
||
if (typeof harness?.bindWorkspaceSession !== 'function') {
|
||
return commandResult(t('当前机器人暂不支持绑定已有会话。'));
|
||
}
|
||
if (typeof conversationKey !== 'string' || !conversationKey) {
|
||
return commandResult(t('当前消息缺少可绑定的会话上下文。'));
|
||
}
|
||
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) || t('暂无标题');
|
||
const message = [
|
||
t('当前聊天已绑定会话:'),
|
||
t('工作区:{workspace}', { workspace }),
|
||
t('标题:{title}', { title }),
|
||
`ID:${boundSessionId}`,
|
||
t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }),
|
||
t('发送 /history 查看最近对话。'),
|
||
].join('\n');
|
||
return commandResult(message, splitWorkspaceCommandMessage(message));
|
||
} catch (error) {
|
||
return commandResult(sessionBindErrorMessage(error));
|
||
}
|
||
}
|
||
|
||
export async function runWorkspaceCommand(text, harness, conversationKey) {
|
||
if (!isWorkspaceCommand(text)) 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);
|
||
if (listMatch) return runWorkspaceListCommand(listMatch, harness);
|
||
const match = WORKSPACE_COMMAND.exec(command);
|
||
if (!match) return null;
|
||
const workspace = match[1]?.trim();
|
||
if (!workspace) {
|
||
return commandResult(t('用法:/workspace 工作区序号或绝对路径'));
|
||
}
|
||
if (typeof harness?.switchWorkspace !== 'function') {
|
||
return commandResult(t('当前机器人暂不支持切换工作区。'));
|
||
}
|
||
try {
|
||
let selected = workspace;
|
||
if (/^\d+$/u.test(workspace)) {
|
||
if (typeof harness?.listWorkspaces !== 'function') {
|
||
return commandResult(t('当前机器人暂不支持按序号选择工作区。'));
|
||
}
|
||
const { paths } = await workspacePathSnapshot(harness);
|
||
const position = Number(workspace);
|
||
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
|
||
return commandResult(t('工作区序号不存在,请先执行 /workspacelist。'));
|
||
}
|
||
selected = paths[position - 1];
|
||
}
|
||
const current = await harness.switchWorkspace(selected);
|
||
return commandResult(t('工作区已切换为:{workspace}', { workspace: current }));
|
||
} catch (error) {
|
||
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
|
||
return commandResult(t(`{message}
|
||
用法:/workspace 工作区序号或绝对路径`, { message: error.message }));
|
||
}
|
||
if (error?.code === 'workspace-bot-not-found') {
|
||
return commandResult(t('机器人正在移除或已重新接入,无法切换原会话的工作区。'));
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export function isWorkspaceCommand(text) {
|
||
if (typeof text !== 'string') return false;
|
||
const command = text.trim();
|
||
return SESSION_BIND_PREFIX.test(command)
|
||
|| SESSION_LIST_COMMAND.test(command)
|
||
|| WORKSPACE_LIST_COMMAND.test(command)
|
||
|| WORKSPACE_COMMAND.test(command);
|
||
}
|