diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0949000..77831bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -150,6 +150,25 @@ export type { CommandOperationResult, } from './commands/index.js'; +// System Commands (: 前缀) +export { + getSystemCommandRegistry, + resetSystemCommandRegistry, + SystemCommandExecutor, + isSystemCommand, + parseSystemCommand, + executeSystemCommand, + initializeSystemCommands, + builtinSystemCommands, +} from './system-commands/index.js'; +export type { + SystemCommand, + SystemCommandContext, + SystemCommandResult, + SystemCommandAction, + SystemCommandInput, +} from './system-commands/index.js'; + // Checkpoint export { CheckpointManager, diff --git a/packages/core/src/system-commands/builtin/clear.ts b/packages/core/src/system-commands/builtin/clear.ts new file mode 100644 index 0000000..6152f72 --- /dev/null +++ b/packages/core/src/system-commands/builtin/clear.ts @@ -0,0 +1,22 @@ +/** + * :clear 系统命令 + * + * 清空当前会话的历史记录 + */ + +import type { SystemCommand } from '../types.js'; + +export const clearCommand: SystemCommand = { + name: 'clear', + description: '清空当前会话的历史记录', + aliases: ['cls', 'c'], + execute: async (_context) => { + // 实际的清空操作由 Server 层处理 + // 这里只返回操作指令 + return { + success: true, + message: '会话已清空', + action: { type: 'clear_messages' }, + }; + }, +}; diff --git a/packages/core/src/system-commands/builtin/index.ts b/packages/core/src/system-commands/builtin/index.ts new file mode 100644 index 0000000..d0758bb --- /dev/null +++ b/packages/core/src/system-commands/builtin/index.ts @@ -0,0 +1,15 @@ +/** + * 内置系统命令 + */ + +import type { SystemCommand } from '../types.js'; +import { clearCommand } from './clear.js'; + +/** + * 所有内置系统命令 + */ +export const builtinSystemCommands: SystemCommand[] = [ + clearCommand, +]; + +export { clearCommand }; diff --git a/packages/core/src/system-commands/executor.ts b/packages/core/src/system-commands/executor.ts new file mode 100644 index 0000000..34a5a90 --- /dev/null +++ b/packages/core/src/system-commands/executor.ts @@ -0,0 +1,135 @@ +/** + * System Command 执行器 + * + * 解析和执行系统命令 + */ + +import type { + SystemCommandContext, + SystemCommandInput, + SystemCommandResult, +} from './types.js'; +import { getSystemCommandRegistry } from './registry.js'; + +/** + * System Command 执行器 + */ +export class SystemCommandExecutor { + /** + * 检查输入是否为系统命令 + */ + static isSystemCommand(input: string): boolean { + return input.trim().startsWith(':'); + } + + /** + * 解析系统命令输入 + */ + static parseInput(input: string): SystemCommandInput | null { + const trimmed = input.trim(); + + if (!trimmed.startsWith(':')) { + return null; + } + + // 移除 : 前缀 + const content = trimmed.slice(1); + + // 分割命令和参数 + const spaceIndex = content.indexOf(' '); + + if (spaceIndex === -1) { + return { + command: content.toLowerCase(), + arguments: '', + args: [], + }; + } + + const command = content.slice(0, spaceIndex).toLowerCase(); + const argsString = content.slice(spaceIndex + 1).trim(); + + return { + command, + arguments: argsString, + args: argsString ? argsString.split(/\s+/) : [], + }; + } + + /** + * 执行系统命令 + */ + static async execute( + input: string, + context: Partial = {} + ): Promise { + const parsed = this.parseInput(input); + + if (!parsed) { + return { + success: false, + error: '无效的系统命令格式', + }; + } + + const registry = getSystemCommandRegistry(); + const command = registry.get(parsed.command); + + if (!command) { + // 列出可用命令作为提示 + const available = registry + .list() + .map((c) => `:${c.name}`) + .join(', '); + + return { + success: false, + error: `未知的系统命令: :${parsed.command}`, + message: available ? `可用的系统命令: ${available}` : undefined, + }; + } + + try { + const result = await command.execute({ + arguments: parsed.arguments, + args: parsed.args, + sessionId: context.sessionId, + workdir: context.workdir, + }); + + return result; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : '系统命令执行失败', + }; + } + } +} + +/** + * 便捷函数:检查是否为系统命令 + */ +export function isSystemCommand(input: string): boolean { + return SystemCommandExecutor.isSystemCommand(input); +} + +/** + * 便捷函数:解析系统命令 + */ +export function parseSystemCommand( + input: string +): SystemCommandInput | null { + return SystemCommandExecutor.parseInput(input); +} + +/** + * 便捷函数:执行系统命令 + */ +export async function executeSystemCommand( + input: string, + context?: Partial +): Promise { + return SystemCommandExecutor.execute(input, context); +} diff --git a/packages/core/src/system-commands/index.ts b/packages/core/src/system-commands/index.ts new file mode 100644 index 0000000..e9f7ad9 --- /dev/null +++ b/packages/core/src/system-commands/index.ts @@ -0,0 +1,46 @@ +/** + * System Commands 模块 + * + * 提供系统命令(: 前缀)的所有功能导出 + */ + +// 类型 +export type { + SystemCommand, + SystemCommandContext, + SystemCommandResult, + SystemCommandAction, + SystemCommandInput, +} from './types.js'; + +// 注册表 +export { + SystemCommandRegistry, + getSystemCommandRegistry, + resetSystemCommandRegistry, +} from './registry.js'; + +// 执行器 +export { + SystemCommandExecutor, + isSystemCommand, + parseSystemCommand, + executeSystemCommand, +} from './executor.js'; + +// 内置命令 +export { builtinSystemCommands, clearCommand } from './builtin/index.js'; + +// 初始化函数 +import { getSystemCommandRegistry } from './registry.js'; +import { builtinSystemCommands } from './builtin/index.js'; + +/** + * 初始化系统命令注册表 + * + * 注册所有内置系统命令 + */ +export function initializeSystemCommands(): void { + const registry = getSystemCommandRegistry(); + registry.registerAll(builtinSystemCommands); +} diff --git a/packages/core/src/system-commands/registry.ts b/packages/core/src/system-commands/registry.ts new file mode 100644 index 0000000..407a247 --- /dev/null +++ b/packages/core/src/system-commands/registry.ts @@ -0,0 +1,99 @@ +/** + * System Command 注册表 + * + * 管理所有系统命令的注册和查找 + */ + +import type { SystemCommand } from './types.js'; + +/** + * System Command 注册表 + */ +export class SystemCommandRegistry { + private commands = new Map(); + private aliases = new Map(); + + /** + * 注册系统命令 + */ + register(command: SystemCommand): void { + this.commands.set(command.name, command); + + // 注册别名 + if (command.aliases) { + for (const alias of command.aliases) { + this.aliases.set(alias, command.name); + } + } + } + + /** + * 批量注册系统命令 + */ + registerAll(commands: SystemCommand[]): void { + for (const command of commands) { + this.register(command); + } + } + + /** + * 获取系统命令 + */ + get(name: string): SystemCommand | undefined { + // 先尝试直接查找 + const command = this.commands.get(name); + if (command) { + return command; + } + + // 再尝试别名查找 + const aliasTarget = this.aliases.get(name); + if (aliasTarget) { + return this.commands.get(aliasTarget); + } + + return undefined; + } + + /** + * 检查命令是否存在 + */ + has(name: string): boolean { + return this.commands.has(name) || this.aliases.has(name); + } + + /** + * 获取所有命令 + */ + list(): SystemCommand[] { + return Array.from(this.commands.values()); + } + + /** + * 清空注册表 + */ + clear(): void { + this.commands.clear(); + this.aliases.clear(); + } +} + +// 全局单例 +let globalRegistry: SystemCommandRegistry | null = null; + +/** + * 获取全局 System Command 注册表 + */ +export function getSystemCommandRegistry(): SystemCommandRegistry { + if (!globalRegistry) { + globalRegistry = new SystemCommandRegistry(); + } + return globalRegistry; +} + +/** + * 重置全局注册表(用于测试) + */ +export function resetSystemCommandRegistry(): void { + globalRegistry = null; +} diff --git a/packages/core/src/system-commands/types.ts b/packages/core/src/system-commands/types.ts new file mode 100644 index 0000000..e6e467a --- /dev/null +++ b/packages/core/src/system-commands/types.ts @@ -0,0 +1,74 @@ +/** + * System Command 系统类型定义 + * + * System Command 是以 : 开头的系统操作命令。 + * 与普通 Command 不同,System Command 不调用 Agent, + * 而是直接执行系统操作(如清空会话、切换模型等)。 + */ + +/** + * System Command 定义 + */ +export interface SystemCommand { + /** 命令名称(不含 : 前缀) */ + name: string; + /** 命令描述 */ + description: string; + /** 命令别名 */ + aliases?: string[]; + /** 执行函数 */ + execute: (context: SystemCommandContext) => Promise; +} + +/** + * System Command 执行上下文 + */ +export interface SystemCommandContext { + /** 原始参数字符串 */ + arguments: string; + /** 解析后的参数数组 */ + args: string[]; + /** 当前会话 ID */ + sessionId?: string; + /** 当前工作目录 */ + workdir?: string; +} + +/** + * System Command 执行结果 + */ +export interface SystemCommandResult { + /** 是否成功 */ + success: boolean; + /** 返回消息(显示给用户) */ + message?: string; + /** 错误信息 */ + error?: string; + /** 附加数据 */ + data?: Record; + /** 特殊操作指令 */ + action?: SystemCommandAction; +} + +/** + * System Command 操作指令 + * + * 用于通知前端执行特定操作 + */ +export type SystemCommandAction = + | { type: 'clear_messages' } + | { type: 'new_session'; sessionId?: string } + | { type: 'switch_model'; model: string } + | { type: 'reload' }; + +/** + * System Command 输入解析结果 + */ +export interface SystemCommandInput { + /** 命令名称(不含 : 前缀) */ + command: string; + /** 原始参数字符串 */ + arguments: string; + /** 解析后的参数数组 */ + args: string[]; +} diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index 9839130..e36555c 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -643,3 +643,16 @@ export function submitUserInput(toolCallId: string, answer: string): boolean { const userInputWaiter = getUserInputWaiter(); return userInputWaiter.submitInput(toolCallId, answer); } + +/** + * 清空 Agent 的对话历史(内存中) + * + * 用于系统命令 :clear + */ +export function clearAgentHistory(sessionId: string): void { + const agent = agentCache.get(sessionId); + if (agent) { + agent.clearHistory(); + console.log(`[Agent] Cleared history for session ${sessionId}`); + } +} diff --git a/packages/server/src/agent/index.ts b/packages/server/src/agent/index.ts index bceff34..a7e818d 100644 --- a/packages/server/src/agent/index.ts +++ b/packages/server/src/agent/index.ts @@ -17,6 +17,8 @@ export { compressContext, // 用户输入响应 submitUserInput, + // 系统命令支持 + clearAgentHistory, // 类型导出 type TokenUsage, type CompressionResult, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fdb8b03..3cd5734 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,7 +9,7 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { createBunWebSocket } from 'hono/bun'; -import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter } from './routes/index.js'; +import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter, agentsRouter, checkpointsRouter, providersRouter, servicesRouter, contextRouter, lspRouter, systemCommandsRouter } from './routes/index.js'; import { handleWebSocket, handleWebSocketMessage, @@ -90,6 +90,7 @@ api.route('/checkpoints', checkpointsRouter); api.route('/providers', providersRouter); api.route('/services', servicesRouter); api.route('/lsp', lspRouter); +api.route('/system-commands', systemCommandsRouter); // 上下文压缩相关(挂载到根路径,内部路由包含 /sessions/:id/context) api.route('/', contextRouter); diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 2590c75..620229f 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -17,3 +17,4 @@ export { providersRouter } from './providers.js'; export { servicesRouter } from './services.js'; export { contextRouter } from './context.js'; export { lspRouter } from './lsp.js'; +export { systemCommandsRouter } from './system-commands.js'; diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index aadb0ad..4022247 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -104,6 +104,47 @@ sessionsRouter.delete('/:id', async (c) => { }); }); +/** + * POST /sessions/:id/clear - 清空会话消息 + * + * 删除会话的所有消息和相关数据,但保留会话本身 + */ +sessionsRouter.post('/:id/clear', async (c) => { + const id = c.req.param('id'); + + if (!sessionManager.exists(id)) { + return c.json( + { + success: false, + error: 'Session not found', + }, + 404 + ); + } + + try { + // 删除会话的所有消息 + await MessageStorage.removeBySession(id); + + // 重置会话状态 + sessionManager.updateStatus(id, 'idle'); + + return c.json({ + success: true, + message: 'Session messages cleared', + }); + } catch (error) { + console.error('[Sessions] Failed to clear messages:', error); + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear messages', + }, + 500 + ); + } +}); + /** * GET /sessions/:id/messages - 获取会话消息 * diff --git a/packages/server/src/routes/system-commands.ts b/packages/server/src/routes/system-commands.ts new file mode 100644 index 0000000..34ec2a6 --- /dev/null +++ b/packages/server/src/routes/system-commands.ts @@ -0,0 +1,57 @@ +/** + * System Commands API Routes + * + * 系统命令(: 前缀)相关的 REST API + */ + +import { Hono } from 'hono'; +import { getSystemCommandRegistry } from '@ai-assistant/core'; + +export const systemCommandsRouter = new Hono(); + +/** + * GET /system-commands - 列出所有系统命令 + */ +systemCommandsRouter.get('/', (c) => { + const registry = getSystemCommandRegistry(); + const commands = registry.list(); + + return c.json({ + success: true, + data: { + commands: commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + aliases: cmd.aliases || [], + })), + }, + }); +}); + +/** + * GET /system-commands/:name - 获取单个系统命令 + */ +systemCommandsRouter.get('/:name', (c) => { + const name = c.req.param('name'); + const registry = getSystemCommandRegistry(); + const command = registry.get(name); + + if (!command) { + return c.json( + { + success: false, + error: 'System command not found', + }, + 404 + ); + } + + return c.json({ + success: true, + data: { + name: command.name, + description: command.description, + aliases: command.aliases || [], + }, + }); +}); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 282fdbf..fcba7c9 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -82,7 +82,7 @@ export type AgentModeType = 'build' | 'plan'; // 客户端发送的消息 export interface ClientMessage { - type: 'message' | 'cancel' | 'tool_response' | 'permission_response' | 'config_update' | 'mode_switch' | 'user_input_response'; + type: 'message' | 'cancel' | 'tool_response' | 'permission_response' | 'config_update' | 'mode_switch' | 'user_input_response' | 'system_command'; sessionId: string; payload?: { content?: string; @@ -122,7 +122,9 @@ export interface ServerMessage { | 'subagent:end' // 子 Agent 执行结束 | 'subagent:stream' // 子 Agent 流式输出 | 'subagent:tool_start' // 子 Agent 工具调用开始 - | 'subagent:tool_end'; // 子 Agent 工具调用结束 + | 'subagent:tool_end' // 子 Agent 工具调用结束 + // 系统命令响应 + | 'system_command_result'; // 系统命令执行结果 sessionId: string; payload?: unknown; } diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index 481f173..558c1ee 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -6,9 +6,18 @@ import type { WSContext } from 'hono/ws'; import { getSessionManager } from './session/manager.js'; -import { processMessage, cancelProcessing, getOrCreateAgent, submitUserInput } from './agent/index.js'; +import { processMessage, cancelProcessing, getOrCreateAgent, submitUserInput, clearAgentHistory } from './agent/index.js'; import { handlePermissionResponse, setSessionAutoApprove } from './permission/handler.js'; import type { ClientMessage, ServerMessage } from './types.js'; +import { + isSystemCommand, + executeSystemCommand, + initializeSystemCommands, + MessageStorage, +} from '@ai-assistant/core'; + +// 初始化系统命令 +initializeSystemCommands(); // 存储活跃的 WebSocket 连接 const connections: Map> = new Map(); @@ -109,6 +118,12 @@ export async function handleWebSocketMessage( const agentMode = message.payload?.agentMode as 'build' | 'plan' | undefined; const autoApprove = message.payload?.autoApprove as boolean | undefined; + // 检测系统命令(: 前缀) + if (isSystemCommand(content)) { + await handleSystemCommand(sessionId, content); + break; + } + // 将 @filepath 转换为 ./filepath 格式(方便 AI 识别为文件路径) content = content.replace(/@([\w./-]+)/g, './$1'); @@ -281,3 +296,45 @@ export function getConnectionStats(): { sessions: number; connections: number } connections: totalConnections, }; } + +/** + * 处理系统命令(: 前缀) + */ +async function handleSystemCommand(sessionId: string, content: string): Promise { + const sessionManager = getSessionManager(); + + console.log(`[WS] System command: ${content}`); + + // 执行系统命令 + const result = await executeSystemCommand(content, { sessionId }); + + // 处理特殊操作 + if (result.success && result.action) { + switch (result.action.type) { + case 'clear_messages': { + // 清空存储的消息 + await MessageStorage.removeBySession(sessionId); + + // 清空 Agent 内存中的对话历史 + clearAgentHistory(sessionId); + + // 重置会话状态 + sessionManager.updateStatus(sessionId, 'idle'); + break; + } + // 可以在这里添加其他操作类型的处理 + } + } + + // 发送结果给客户端 + broadcastToSession(sessionId, { + type: 'system_command_result', + sessionId, + payload: { + success: result.success, + message: result.message, + error: result.error, + action: result.action, + }, + }); +} diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 1317ba9..5a45cff 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -53,6 +53,9 @@ import type { // LSP types LSPServer, DiagnosticsResponse, + // System Commands types + SystemCommandListResponse, + SystemCommandInfo, } from './types.js'; // Re-export types @@ -145,6 +148,9 @@ export type { SingleFileDiagnosticsResponse, AllFilesDiagnosticsResponse, DiagnosticsResponse, + // System Commands types + SystemCommandInfo, + SystemCommandListResponse, } from './types.js'; // API Configuration @@ -368,6 +374,29 @@ export async function deleteCommand( return request('DELETE', `/commands/${encodeURIComponent(name)}`); } +// ============ System Commands API (: 前缀) ============ + +/** + * 获取所有系统命令列表 + */ +export async function listSystemCommands(): Promise<{ + success: boolean; + data: SystemCommandListResponse; +}> { + return request('GET', '/system-commands'); +} + +/** + * 获取单个系统命令 + */ +export async function getSystemCommand(name: string): Promise<{ + success: boolean; + data?: SystemCommandInfo; + error?: string; +}> { + return request('GET', `/system-commands/${encodeURIComponent(name)}`); +} + // ============ MCP API ============ /** diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index a4e88e8..3466a30 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -291,6 +291,23 @@ export interface CommandContent { sourcePath?: string; } +// ============ System Commands (: 前缀) 相关 ============ + +/** 系统命令信息 */ +export interface SystemCommandInfo { + /** 命令名称(不含 : 前缀) */ + name: string; + /** 命令描述 */ + description: string; + /** 命令别名 */ + aliases: string[]; +} + +/** 系统命令列表响应 */ +export interface SystemCommandListResponse { + commands: SystemCommandInfo[]; +} + // ============ MCP 相关 ============ /** MCP 服务器状态类型 */ diff --git a/packages/ui/src/components/ChatInput.tsx b/packages/ui/src/components/ChatInput.tsx index 1b7af0b..c55ec0d 100644 --- a/packages/ui/src/components/ChatInput.tsx +++ b/packages/ui/src/components/ChatInput.tsx @@ -3,6 +3,7 @@ * * 支持响应式:responsive=true 时适配移动端键盘和触摸操作 * 支持斜杠命令:输入 / 时显示命令菜单 + * 支持系统命令:输入 : 时显示系统命令菜单 * 支持文件提及:输入 @ 时显示文件搜索菜单 */ @@ -11,10 +12,12 @@ import { Square, Sparkles } from 'lucide-react'; import { motion } from 'framer-motion'; import clsx from 'clsx'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; +import { SystemCommandMenu, type SystemCommandMenuItem } from './SystemCommandMenu.js'; import { FileMenu, type FileMenuItem } from './FileMenu.js'; import { FileMentionTag } from './FileMentionTag.js'; import { AgentModeSelector, type AgentModeType } from './AgentModeSelector.js'; import { useCommands } from '../hooks/useCommands.js'; +import { useSystemCommands } from '../hooks/useSystemCommands.js'; import { useFileMention } from '../hooks/useFileMention.js'; interface ChatInputProps { @@ -54,6 +57,8 @@ export function ChatInput({ const [input, setInput] = useState(''); const [showCommandMenu, setShowCommandMenu] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); + const [showSystemCommandMenu, setShowSystemCommandMenu] = useState(false); + const [selectedSystemCommandIndex, setSelectedSystemCommandIndex] = useState(0); const textareaRef = useRef(null); // 命令系统 @@ -63,6 +68,13 @@ export function ChatInput({ filterCommands, } = useCommands({ autoLoad: enableCommands }); + // 系统命令系统 + const { + filteredCommands: filteredSystemCommands, + isLoading: systemCommandsLoading, + filterCommands: filterSystemCommands, + } = useSystemCommands({ autoLoad: enableCommands }); + // 文件提及系统 const { isOpen: showFileMenu, @@ -130,17 +142,41 @@ export function ChatInput({ [enableCommands, filterCommands] ); + // 检测系统命令输入 + const checkSystemCommandTrigger = useCallback( + (value: string) => { + if (!enableCommands) return; + + // 检测是否在输入系统命令 + // 条件:以 : 开头,且 : 后面没有空格(还在输入命令名) + const colonMatch = value.match(/^:(\S*)$/); + + if (colonMatch) { + const query = colonMatch[1]; // : 后面的内容 + setShowSystemCommandMenu(true); + setSelectedSystemCommandIndex(0); + filterSystemCommands(query); + } else { + setShowSystemCommandMenu(false); + } + }, + [enableCommands, filterSystemCommands] + ); + // 处理输入变化 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPos = e.target.selectionStart; setInput(value); - // 检查命令触发 + // 检查斜杠命令触发 checkCommandTrigger(value); + // 检查系统命令触发 + checkSystemCommandTrigger(value); + // 检查文件提及触发(只在非命令输入模式下) - if (enableFileMention && !value.startsWith('/')) { + if (enableFileMention && !value.startsWith('/') && !value.startsWith(':')) { checkFileTrigger(value, cursorPos); } else { closeFileMenu(); @@ -189,12 +225,28 @@ export function ChatInput({ setShowCommandMenu(false); }, []); + // 选择系统命令 + const handleSystemCommandSelect = useCallback((command: SystemCommandMenuItem) => { + // 直接发送系统命令 + setInput(`:${command.name}`); + setShowSystemCommandMenu(false); + + // 聚焦输入框 + textareaRef.current?.focus(); + }, []); + + // 关闭系统命令菜单 + const handleSystemCommandMenuClose = useCallback(() => { + setShowSystemCommandMenu(false); + }, []); + const handleSubmit = () => { const trimmed = input.trim(); if (!trimmed || isLoading || disabled) return; - // 关闭菜单 + // 关闭所有菜单 setShowCommandMenu(false); + setShowSystemCommandMenu(false); closeFileMenu(); onSend(trimmed); @@ -222,7 +274,7 @@ export function ChatInput({ } } - // 命令菜单处理 + // 斜杠命令菜单处理 if (showCommandMenu && filteredCommands.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 CommandMenu 处理,阻止默认行为 @@ -238,6 +290,22 @@ export function ChatInput({ } } + // 系统命令菜单处理 + if (showSystemCommandMenu && filteredSystemCommands.length > 0) { + if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { + // 这些键由 SystemCommandMenu 处理,阻止默认行为 + return; + } + if (e.key === 'Enter' && !e.shiftKey) { + // Enter 选择系统命令 + e.preventDefault(); + if (filteredSystemCommands[selectedSystemCommandIndex]) { + handleSystemCommandSelect(filteredSystemCommands[selectedSystemCommandIndex]); + } + return; + } + } + // Enter 发送,Shift+Enter 换行 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -252,7 +320,7 @@ export function ChatInput({ responsive ? 'p-3 md:p-4 safe-area-pb' : 'p-4' )} > - {/* Command Menu */} + {/* Command Menu (斜杠命令) */} {enableCommands && ( )} + {/* System Command Menu (系统命令) */} + {enableCommands && ( + + )} + {/* File Menu */} {enableFileMention && ( / commands + + : + system + @ files diff --git a/packages/ui/src/components/SystemCommandMenu.tsx b/packages/ui/src/components/SystemCommandMenu.tsx new file mode 100644 index 0000000..efa1818 --- /dev/null +++ b/packages/ui/src/components/SystemCommandMenu.tsx @@ -0,0 +1,228 @@ +/** + * System Command Menu Component + * + * 系统命令(: 前缀)选择菜单,支持键盘导航 + */ + +import { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Settings, Trash2, RefreshCw } from 'lucide-react'; +import clsx from 'clsx'; + +export interface SystemCommandMenuItem { + name: string; + description: string; + aliases?: string[]; +} + +interface SystemCommandMenuProps { + /** 命令列表 */ + commands: SystemCommandMenuItem[]; + /** 是否显示 */ + isOpen: boolean; + /** 当前选中索引 */ + selectedIndex: number; + /** 选择命令回调 */ + onSelect: (command: SystemCommandMenuItem) => void; + /** 关闭菜单回调 */ + onClose: () => void; + /** 选中索引变化回调 */ + onSelectedIndexChange: (index: number) => void; + /** 是否正在加载 */ + isLoading?: boolean; +} + +// 根据命令名称获取图标 +function getCommandIcon(name: string) { + switch (name) { + case 'clear': + return ; + case 'reload': + return ; + default: + return ; + } +} + +export function SystemCommandMenu({ + commands, + isOpen, + selectedIndex, + onSelect, + onClose, + onSelectedIndexChange, + isLoading = false, +}: SystemCommandMenuProps) { + const menuRef = useRef(null); + const selectedRef = useRef(null); + + // 滚动选中项到可见区域 + useEffect(() => { + if (isOpen && selectedRef.current) { + selectedRef.current.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + }, [selectedIndex, isOpen]); + + // 键盘导航 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + onSelectedIndexChange( + selectedIndex < commands.length - 1 ? selectedIndex + 1 : 0 + ); + break; + case 'ArrowUp': + e.preventDefault(); + onSelectedIndexChange( + selectedIndex > 0 ? selectedIndex - 1 : commands.length - 1 + ); + break; + case 'Enter': + e.preventDefault(); + if (commands[selectedIndex]) { + onSelect(commands[selectedIndex]); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + case 'Tab': + e.preventDefault(); + if (commands[selectedIndex]) { + onSelect(commands[selectedIndex]); + } + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, commands, selectedIndex, onSelect, onClose, onSelectedIndexChange]); + + return ( + + {isOpen && ( + +
+ {/* Header */} +
+
+ + System Commands + {commands.length > 0 && ( + ({commands.length}) + )} +
+
+ + {/* Loading */} + {isLoading && commands.length === 0 && ( +
+ Loading system commands... +
+ )} + + {/* Empty state */} + {!isLoading && commands.length === 0 && ( +
+ No system commands found +
+ )} + + {/* Command list */} + {commands.length > 0 && ( +
+ {commands.map((command, index) => ( + + ))} +
+ )} + + {/* Footer hint */} +
+
+ + + + {' '}navigate + + + Tab + {' '}select + + + Esc + {' '}close + +
+
+
+
+ )} +
+ ); +} diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index d312a10..3680720 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -564,6 +564,33 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate }, 1000); break; } + + // ============ 系统命令结果 ============ + + case 'system_command_result': { + const payload = message.payload as { + success: boolean; + message?: string; + error?: string; + action?: { type: string }; + }; + + // 处理清空消息操作 + if (payload.success && payload.action?.type === 'clear_messages') { + setState((prev) => ({ + ...prev, + messages: [], + streamingMessage: null, + isLoading: false, + })); + } + + // 如果有错误,通过 onError 回调通知 + if (!payload.success && payload.error) { + onErrorRef.current?.(new Error(payload.error)); + } + break; + } } } catch { // 忽略解析错误 @@ -693,6 +720,23 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate [sessionId] ); + // 清空会话消息(系统命令 :clear) + const clearMessages = useCallback(() => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onErrorRef.current?.(new Error('WebSocket not connected')); + return; + } + + // 发送 :clear 系统命令 + wsRef.current.send( + JSON.stringify({ + type: 'message', + sessionId, + payload: { content: ':clear' }, + }) + ); + }, [sessionId]); + // 回答问题(ask_user_question 工具) const answerQuestion = useCallback( (questionPartId: string, answers: string[]) => { @@ -813,5 +857,6 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate setAgentMode, setAutoApprove, answerQuestion, + clearMessages, }; } diff --git a/packages/ui/src/hooks/useSystemCommands.ts b/packages/ui/src/hooks/useSystemCommands.ts new file mode 100644 index 0000000..5880af0 --- /dev/null +++ b/packages/ui/src/hooks/useSystemCommands.ts @@ -0,0 +1,112 @@ +/** + * System Commands Hook + * + * 管理系统命令(: 前缀)的加载和过滤 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { listSystemCommands } from '../api/client.js'; +import type { SystemCommandInfo } from '../api/types.js'; + +interface UseSystemCommandsOptions { + /** 是否在挂载时自动加载命令列表 */ + autoLoad?: boolean; +} + +interface UseSystemCommandsState { + /** 所有系统命令列表 */ + commands: SystemCommandInfo[]; + /** 过滤后的命令列表 */ + filteredCommands: SystemCommandInfo[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 错误信息 */ + error: string | null; +} + +export function useSystemCommands(options: UseSystemCommandsOptions = {}) { + const { autoLoad = true } = options; + + const [state, setState] = useState({ + commands: [], + filteredCommands: [], + isLoading: false, + error: null, + }); + + const loadedRef = useRef(false); + + // 加载系统命令列表 + const loadCommands = useCallback(async () => { + if (state.isLoading) return; + + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const result = await listSystemCommands(); + if (result.success) { + setState({ + commands: result.data.commands, + filteredCommands: result.data.commands, + isLoading: false, + error: null, + }); + } else { + setState((prev) => ({ + ...prev, + isLoading: false, + error: 'Failed to load system commands', + })); + } + } catch (error) { + setState((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + })); + } + }, [state.isLoading]); + + // 搜索/过滤系统命令 + const filterCommands = useCallback( + (query: string) => { + // 空查询显示所有命令 + if (!query.trim()) { + setState((prev) => ({ + ...prev, + filteredCommands: prev.commands, + })); + return; + } + + // 本地过滤 + const queryLower = query.toLowerCase(); + const localFiltered = state.commands.filter( + (cmd) => + cmd.name.toLowerCase().includes(queryLower) || + cmd.description?.toLowerCase().includes(queryLower) || + cmd.aliases?.some((alias) => alias.toLowerCase().includes(queryLower)) + ); + + setState((prev) => ({ + ...prev, + filteredCommands: localFiltered, + })); + }, + [state.commands] + ); + + // 自动加载 + useEffect(() => { + if (autoLoad && !loadedRef.current) { + loadedRef.current = true; + loadCommands(); + } + }, [autoLoad, loadCommands]); + + return { + ...state, + loadCommands, + filterCommands, + }; +}