From 9365e07df166ee6e3e43972c1e87ea9bd345c698 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 12 Dec 2025 21:02:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20=E6=B7=BB=E5=8A=A0=20Hooks=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Server Hooks API 路由 (CRUD + 测试执行) - 新增 HooksPanel 组件用于管理所有钩子类型 - 新增 HookEditor 组件用于编辑单个钩子规则 - 支持 file_edited/file_created/file_deleted/session_completed 四种钩子 - 集成到 web 和 desktop 应用 --- packages/desktop/src/App.tsx | 6 + packages/desktop/src/pages/Chat.tsx | 19 +- packages/server/src/index.ts | 3 +- packages/server/src/routes/hooks.ts | 581 ++++++++++++++++++++ packages/server/src/routes/index.ts | 1 + packages/ui/src/api/client.ts | 132 +++++ packages/ui/src/api/types.ts | 45 ++ packages/ui/src/components/HookEditor.tsx | 548 +++++++++++++++++++ packages/ui/src/components/HooksPanel.tsx | 616 ++++++++++++++++++++++ packages/ui/src/index.ts | 19 + packages/web/src/App.tsx | 6 + packages/web/src/pages/Chat.tsx | 19 +- 12 files changed, 1990 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/routes/hooks.ts create mode 100644 packages/ui/src/components/HookEditor.tsx create mode 100644 packages/ui/src/components/HooksPanel.tsx diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 04b425f..9d230c7 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -9,6 +9,7 @@ import { ConfigPanel, CommandPanel, MCPPanel, + HooksPanel, Toaster, listSessions, createSession, @@ -23,6 +24,7 @@ export function App() { const [showConfig, setShowConfig] = useState(false); const [showCommands, setShowCommands] = useState(false); const [showMCP, setShowMCP] = useState(false); + const [showHooks, setShowHooks] = useState(false); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); // 初始化:加载或创建会话 @@ -95,6 +97,7 @@ export function App() { onOpenConfig={() => setShowConfig(true)} onOpenCommands={() => setShowCommands(true)} onOpenMCP={() => setShowMCP(true)} + onOpenHooks={() => setShowHooks(true)} /> ) : (
@@ -124,6 +127,9 @@ export function App() { {/* MCP 面板 */} {showMCP && setShowMCP(false)} />} + {/* Hooks 面板 */} + {showHooks && setShowHooks(false)} />} + {/* Toast 通知 */}
diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx index c68c8a5..fb0317b 100644 --- a/packages/desktop/src/pages/Chat.tsx +++ b/packages/desktop/src/pages/Chat.tsx @@ -3,7 +3,7 @@ */ import { useEffect, useRef } from 'react'; -import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug } from 'lucide-react'; +import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug, Zap } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useChat, @@ -22,6 +22,7 @@ interface ChatPageProps { onOpenConfig?: () => void; onOpenCommands?: () => void; onOpenMCP?: () => void; + onOpenHooks?: () => void; } export function ChatPage({ @@ -32,6 +33,7 @@ export function ChatPage({ onOpenConfig, onOpenCommands, onOpenMCP, + onOpenHooks, }: ChatPageProps) { const { messages, @@ -125,8 +127,21 @@ export function ChatPage({ {/* 工具栏按钮 */} - {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && ( + {(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks) && (
+ {/* Hooks 按钮 */} + {onOpenHooks && ( + + + + )} + {/* MCP 按钮 */} {onOpenMCP && ( Promise; + loadHookConfig: (directory: string) => Promise; + getConfigFilePath: (directory: string) => Promise; + createDefaultConfig: (directory: string) => Promise; +} + +interface ShellCommandConfig { + command: string[]; + environment?: Record; + timeout?: number; + cwd?: string; +} + +interface FileHookConfig { + [pattern: string]: ShellCommandConfig[]; +} + +interface HookConfig { + file_edited?: FileHookConfig; + file_created?: FileHookConfig; + file_deleted?: FileHookConfig; + session_completed?: ShellCommandConfig[]; +} + +interface ProjectConfig { + hooks?: HookConfig; + plugins?: string[]; + [key: string]: unknown; +} + +interface HookTestResult { + success: boolean; + stdout: string; + stderr: string; + exitCode: number; + duration: number; +} + +export const hooksRouter = new Hono(); + +// Core 模块缓存 +let hooksModule: HooksModule | null = null; + +/** + * 初始化 Hooks 模块 + */ +async function initHooksModule(): Promise { + if (hooksModule) return hooksModule; + + try { + const corePath = '@ai-assistant/core'; + const core = (await import(corePath)) as Record; + + if ( + typeof core.loadProjectConfig !== 'function' || + typeof core.loadHookConfig !== 'function' || + typeof core.getConfigFilePath !== 'function' || + typeof core.createDefaultConfig !== 'function' + ) { + console.warn('[Hooks] Core module missing Hooks exports'); + return null; + } + + hooksModule = { + loadProjectConfig: core.loadProjectConfig as HooksModule['loadProjectConfig'], + loadHookConfig: core.loadHookConfig as HooksModule['loadHookConfig'], + getConfigFilePath: core.getConfigFilePath as HooksModule['getConfigFilePath'], + createDefaultConfig: core.createDefaultConfig as HooksModule['createDefaultConfig'], + }; + + console.log('[Hooks] Hooks module initialized'); + return hooksModule; + } catch (error) { + console.warn('[Hooks] Failed to load Hooks module:', error); + return null; + } +} + +/** + * 移除 JSON 中的注释(支持 JSONC 格式) + */ +function stripJsonComments(jsonString: string): string { + let result = jsonString.replace(/\/\/.*$/gm, ''); + result = result.replace(/\/\*[\s\S]*?\*\//g, ''); + return result; +} + +/** + * 读取配置文件 + */ +async function readConfigFile(configPath: string): Promise { + try { + const content = await fs.readFile(configPath, 'utf-8'); + const cleanContent = stripJsonComments(content); + return JSON.parse(cleanContent); + } catch { + return null; + } +} + +/** + * 写入配置文件 + */ +async function writeConfigFile(configPath: string, config: ProjectConfig): Promise { + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); +} + +/** + * 获取或创建配置文件路径 + */ +async function getOrCreateConfigPath(workdir: string): Promise { + const module = await initHooksModule(); + if (module) { + const existingPath = await module.getConfigFilePath(workdir); + if (existingPath) return existingPath; + } + return path.join(workdir, '.ai-assistant.json'); +} + +/** + * GET /hooks/config - 获取完整钩子配置 + */ +hooksRouter.get('/config', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + const config = getConfig(); + const hookConfig = await module.loadHookConfig(config.workdir); + + return c.json({ + success: true, + data: hookConfig || { + file_edited: {}, + file_created: {}, + file_deleted: {}, + session_completed: [], + }, + }); +}); + +/** + * PUT /hooks/config - 更新完整钩子配置 + */ +hooksRouter.put('/config', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + try { + const newHookConfig = await c.req.json(); + const config = getConfig(); + const configPath = await getOrCreateConfigPath(config.workdir); + + // 读取现有配置 + let projectConfig = await readConfigFile(configPath); + if (!projectConfig) { + projectConfig = {}; + } + + // 更新 hooks 部分 + projectConfig.hooks = newHookConfig; + + // 写入配置文件 + await writeConfigFile(configPath, projectConfig); + + return c.json({ + success: true, + data: newHookConfig, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update config', + }, + 500 + ); + } +}); + +/** + * GET /hooks/file-edited - 获取 file_edited 钩子 + */ +hooksRouter.get('/file-edited', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + const config = getConfig(); + const hookConfig = await module.loadHookConfig(config.workdir); + + return c.json({ + success: true, + data: hookConfig?.file_edited || {}, + }); +}); + +/** + * PUT /hooks/file-edited - 更新 file_edited 钩子 + */ +hooksRouter.put('/file-edited', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + try { + const newFileEditedHooks = await c.req.json(); + const config = getConfig(); + const configPath = await getOrCreateConfigPath(config.workdir); + + let projectConfig = await readConfigFile(configPath); + if (!projectConfig) { + projectConfig = {}; + } + if (!projectConfig.hooks) { + projectConfig.hooks = {}; + } + + projectConfig.hooks.file_edited = newFileEditedHooks; + await writeConfigFile(configPath, projectConfig); + + return c.json({ + success: true, + data: newFileEditedHooks, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update file_edited hooks', + }, + 500 + ); + } +}); + +/** + * GET /hooks/file-created - 获取 file_created 钩子 + */ +hooksRouter.get('/file-created', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + const config = getConfig(); + const hookConfig = await module.loadHookConfig(config.workdir); + + return c.json({ + success: true, + data: hookConfig?.file_created || {}, + }); +}); + +/** + * PUT /hooks/file-created - 更新 file_created 钩子 + */ +hooksRouter.put('/file-created', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + try { + const newFileCreatedHooks = await c.req.json(); + const config = getConfig(); + const configPath = await getOrCreateConfigPath(config.workdir); + + let projectConfig = await readConfigFile(configPath); + if (!projectConfig) { + projectConfig = {}; + } + if (!projectConfig.hooks) { + projectConfig.hooks = {}; + } + + projectConfig.hooks.file_created = newFileCreatedHooks; + await writeConfigFile(configPath, projectConfig); + + return c.json({ + success: true, + data: newFileCreatedHooks, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update file_created hooks', + }, + 500 + ); + } +}); + +/** + * GET /hooks/file-deleted - 获取 file_deleted 钩子 + */ +hooksRouter.get('/file-deleted', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + const config = getConfig(); + const hookConfig = await module.loadHookConfig(config.workdir); + + return c.json({ + success: true, + data: hookConfig?.file_deleted || {}, + }); +}); + +/** + * PUT /hooks/file-deleted - 更新 file_deleted 钩子 + */ +hooksRouter.put('/file-deleted', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + try { + const newFileDeletedHooks = await c.req.json(); + const config = getConfig(); + const configPath = await getOrCreateConfigPath(config.workdir); + + let projectConfig = await readConfigFile(configPath); + if (!projectConfig) { + projectConfig = {}; + } + if (!projectConfig.hooks) { + projectConfig.hooks = {}; + } + + projectConfig.hooks.file_deleted = newFileDeletedHooks; + await writeConfigFile(configPath, projectConfig); + + return c.json({ + success: true, + data: newFileDeletedHooks, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update file_deleted hooks', + }, + 500 + ); + } +}); + +/** + * GET /hooks/session-completed - 获取 session_completed 钩子 + */ +hooksRouter.get('/session-completed', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + const config = getConfig(); + const hookConfig = await module.loadHookConfig(config.workdir); + + return c.json({ + success: true, + data: hookConfig?.session_completed || [], + }); +}); + +/** + * PUT /hooks/session-completed - 更新 session_completed 钩子 + */ +hooksRouter.put('/session-completed', async (c) => { + const module = await initHooksModule(); + + if (!module) { + return c.json( + { + success: false, + error: 'Hooks module not available', + }, + 503 + ); + } + + try { + const newSessionCompletedHooks = await c.req.json(); + const config = getConfig(); + const configPath = await getOrCreateConfigPath(config.workdir); + + let projectConfig = await readConfigFile(configPath); + if (!projectConfig) { + projectConfig = {}; + } + if (!projectConfig.hooks) { + projectConfig.hooks = {}; + } + + projectConfig.hooks.session_completed = newSessionCompletedHooks; + await writeConfigFile(configPath, projectConfig); + + return c.json({ + success: true, + data: newSessionCompletedHooks, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update session_completed hooks', + }, + 500 + ); + } +}); + +/** + * POST /hooks/test - 测试执行钩子命令 + */ +hooksRouter.post('/test', async (c) => { + try { + const commandConfig = await c.req.json(); + const config = getConfig(); + + // 验证命令配置 + if (!commandConfig.command || !Array.isArray(commandConfig.command) || commandConfig.command.length === 0) { + return c.json( + { + success: false, + error: 'Invalid command configuration: command must be a non-empty array', + }, + 400 + ); + } + + const startTime = Date.now(); + const timeout = commandConfig.timeout || 30000; + const cwd = commandConfig.cwd || config.workdir; + + // 执行命令 + const result = await new Promise((resolve) => { + const [cmd, ...args] = commandConfig.command; + const proc = spawn(cmd, args, { + cwd, + env: { + ...process.env, + ...commandConfig.environment, + }, + shell: true, + timeout, + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + resolve({ + success: code === 0, + stdout, + stderr, + exitCode: code ?? -1, + duration: Date.now() - startTime, + }); + }); + + proc.on('error', (error) => { + resolve({ + success: false, + stdout, + stderr: error.message, + exitCode: -1, + duration: Date.now() - startTime, + }); + }); + }); + + return c.json({ + success: true, + data: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to test command', + }, + 500 + ); + } +}); diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 25cea22..5bd4fa4 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -10,3 +10,4 @@ export { configRouter, getConfig, setConfig } from './config.js'; export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js'; export { commandsRouter } from './commands.js'; export { mcpRouter } from './mcp.js'; +export { hooksRouter } from './hooks.js'; diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 5f7f262..615ddae 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -20,6 +20,10 @@ import type { MCPServerStatus, MCPToolInfo, MCPConfig, + HookConfig, + FileHookConfig, + ShellCommandConfig, + HookTestResult, } from './types.js'; // Re-export types @@ -45,6 +49,11 @@ export type { MCPToolInfo, MCPConfig, MCPServerConfigInfo, + // Hooks types + HookConfig, + FileHookConfig, + ShellCommandConfig, + HookTestResult, } from './types.js'; // API Configuration @@ -344,3 +353,126 @@ export async function getMCPConfig(): Promise<{ }> { return request('GET', '/mcp/config'); } + +// ============ Hooks API ============ + +/** + * 获取完整钩子配置 + */ +export async function getHooksConfig(): Promise<{ + success: boolean; + data: HookConfig; + error?: string; +}> { + return request('GET', '/hooks/config'); +} + +/** + * 更新完整钩子配置 + */ +export async function updateHooksConfig(config: HookConfig): Promise<{ + success: boolean; + data: HookConfig; + error?: string; +}> { + return request('PUT', '/hooks/config', config); +} + +/** + * 获取 file_edited 钩子配置 + */ +export async function getFileEditedHooks(): Promise<{ + success: boolean; + data: FileHookConfig; + error?: string; +}> { + return request('GET', '/hooks/file-edited'); +} + +/** + * 更新 file_edited 钩子配置 + */ +export async function updateFileEditedHooks(hooks: FileHookConfig): Promise<{ + success: boolean; + data: FileHookConfig; + error?: string; +}> { + return request('PUT', '/hooks/file-edited', hooks); +} + +/** + * 获取 file_created 钩子配置 + */ +export async function getFileCreatedHooks(): Promise<{ + success: boolean; + data: FileHookConfig; + error?: string; +}> { + return request('GET', '/hooks/file-created'); +} + +/** + * 更新 file_created 钩子配置 + */ +export async function updateFileCreatedHooks(hooks: FileHookConfig): Promise<{ + success: boolean; + data: FileHookConfig; + error?: string; +}> { + return request('PUT', '/hooks/file-created', hooks); +} + +/** + * 获取 file_deleted 钩子配置 + */ +export async function getFileDeletedHooks(): Promise<{ + success: boolean; + data: FileHookConfig; + error?: string; +}> { + return request('GET', '/hooks/file-deleted'); +} + +/** + * 更新 file_deleted 钩子配置 + */ +export async function updateFileDeletedHooks(hooks: FileHookConfig): Promise<{ + success: boolean; + data: FileHookConfig; + error?: string; +}> { + return request('PUT', '/hooks/file-deleted', hooks); +} + +/** + * 获取 session_completed 钩子配置 + */ +export async function getSessionCompletedHooks(): Promise<{ + success: boolean; + data: ShellCommandConfig[]; + error?: string; +}> { + return request('GET', '/hooks/session-completed'); +} + +/** + * 更新 session_completed 钩子配置 + */ +export async function updateSessionCompletedHooks(hooks: ShellCommandConfig[]): Promise<{ + success: boolean; + data: ShellCommandConfig[]; + error?: string; +}> { + return request('PUT', '/hooks/session-completed', hooks); +} + +/** + * 测试执行钩子命令 + */ +export async function testHookCommand(command: ShellCommandConfig): Promise<{ + success: boolean; + data?: HookTestResult; + error?: string; +}> { + return request('POST', '/hooks/test', command); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 8b34f0a..68c33c8 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -236,3 +236,48 @@ export interface MCPServerConfigInfo { enabled?: boolean; timeout?: number; } + +// ============ Hooks 相关 ============ + +/** Shell 命令配置 */ +export interface ShellCommandConfig { + /** 命令数组,如 ['npm', 'run', 'lint'] */ + command: string[]; + /** 环境变量 */ + environment?: Record; + /** 超时时间(毫秒) */ + timeout?: number; + /** 工作目录 */ + cwd?: string; +} + +/** 文件钩子配置 (glob pattern -> commands) */ +export interface FileHookConfig { + [pattern: string]: ShellCommandConfig[]; +} + +/** 完整钩子配置 */ +export interface HookConfig { + /** 文件编辑后触发 */ + file_edited?: FileHookConfig; + /** 文件创建后触发 */ + file_created?: FileHookConfig; + /** 文件删除后触发 */ + file_deleted?: FileHookConfig; + /** 会话结束时触发 */ + session_completed?: ShellCommandConfig[]; +} + +/** 钩子命令测试结果 */ +export interface HookTestResult { + /** 是否成功 */ + success: boolean; + /** 标准输出 */ + stdout: string; + /** 标准错误输出 */ + stderr: string; + /** 退出码 */ + exitCode: number; + /** 执行时间(毫秒) */ + duration: number; +} diff --git a/packages/ui/src/components/HookEditor.tsx b/packages/ui/src/components/HookEditor.tsx new file mode 100644 index 0000000..f8c7765 --- /dev/null +++ b/packages/ui/src/components/HookEditor.tsx @@ -0,0 +1,548 @@ +/** + * HookEditor Component + * + * 钩子编辑器:用于添加和编辑单个钩子规则 + */ + +import { useState } from 'react'; +import { + X, + Plus, + Trash2, + Play, + RefreshCw, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import type { ShellCommandConfig } from '../api/client.js'; + +type HookType = 'file_edited' | 'file_created' | 'file_deleted' | 'session_completed'; + +interface HookEditorProps { + type: HookType; + pattern?: string; + commands: ShellCommandConfig[]; + isNew: boolean; + onSave: (type: HookType, pattern: string, commands: ShellCommandConfig[]) => Promise; + onCancel: () => void; + onTest: (command: ShellCommandConfig, id: string) => Promise; + saving: boolean; + responsive?: boolean; +} + +interface CommandEditorState { + command: string[]; + environment: Record; + timeout?: number; + cwd?: string; + showAdvanced: boolean; +} + +export function HookEditor({ + type, + pattern: initialPattern, + commands: initialCommands, + isNew, + onSave, + onCancel, + onTest, + saving, + responsive = false, +}: HookEditorProps) { + const isFileHook = type !== 'session_completed'; + + // 表单状态 + const [pattern, setPattern] = useState(initialPattern || ''); + const [commandStates, setCommandStates] = useState(() => { + if (initialCommands.length > 0) { + return initialCommands.map((cmd) => ({ + command: cmd.command, + environment: cmd.environment || {}, + timeout: cmd.timeout, + cwd: cmd.cwd, + showAdvanced: !!(cmd.environment && Object.keys(cmd.environment).length > 0) || !!cmd.timeout || !!cmd.cwd, + })); + } + return [{ + command: [''], + environment: {}, + timeout: undefined, + cwd: undefined, + showAdvanced: false, + }]; + }); + + const [testingIndex, setTestingIndex] = useState(null); + const [errors, setErrors] = useState<{ pattern?: string; commands?: string[] }>({}); + + // 验证表单 + const validate = () => { + const newErrors: { pattern?: string; commands?: string[] } = {}; + const commandErrors: string[] = []; + + if (isFileHook && !pattern.trim()) { + newErrors.pattern = 'Pattern is required'; + } + + commandStates.forEach((state, idx) => { + const cmdStr = state.command.filter(Boolean).join(' ').trim(); + if (!cmdStr) { + commandErrors[idx] = 'Command is required'; + } + }); + + if (commandErrors.some(Boolean)) { + newErrors.commands = commandErrors; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // 保存 + const handleSave = async () => { + if (!validate()) return; + + const commands: ShellCommandConfig[] = commandStates.map((state) => { + const cmd: ShellCommandConfig = { + command: state.command.filter(Boolean), + }; + if (Object.keys(state.environment).length > 0) { + cmd.environment = state.environment; + } + if (state.timeout) { + cmd.timeout = state.timeout; + } + if (state.cwd) { + cmd.cwd = state.cwd; + } + return cmd; + }); + + await onSave(type, pattern, commands); + }; + + // 测试命令 + const handleTest = async (index: number) => { + const state = commandStates[index]; + const cmd: ShellCommandConfig = { + command: state.command.filter(Boolean), + }; + if (Object.keys(state.environment).length > 0) { + cmd.environment = state.environment; + } + if (state.timeout) { + cmd.timeout = state.timeout; + } + if (state.cwd) { + cmd.cwd = state.cwd; + } + + if (cmd.command.length === 0) return; + + setTestingIndex(index); + await onTest(cmd, `editor-${index}`); + setTestingIndex(null); + }; + + // 更新命令 + const updateCommand = (index: number, updates: Partial) => { + setCommandStates((prev) => { + const newStates = [...prev]; + newStates[index] = { ...newStates[index], ...updates }; + return newStates; + }); + }; + + // 更新命令参数 + const updateCommandArg = (cmdIndex: number, argIndex: number, value: string) => { + setCommandStates((prev) => { + const newStates = [...prev]; + const newCommand = [...newStates[cmdIndex].command]; + newCommand[argIndex] = value; + newStates[cmdIndex] = { ...newStates[cmdIndex], command: newCommand }; + return newStates; + }); + }; + + // 添加命令参数 + const addCommandArg = (cmdIndex: number) => { + setCommandStates((prev) => { + const newStates = [...prev]; + newStates[cmdIndex] = { + ...newStates[cmdIndex], + command: [...newStates[cmdIndex].command, ''], + }; + return newStates; + }); + }; + + // 删除命令参数 + const removeCommandArg = (cmdIndex: number, argIndex: number) => { + setCommandStates((prev) => { + const newStates = [...prev]; + const newCommand = newStates[cmdIndex].command.filter((_, i) => i !== argIndex); + newStates[cmdIndex] = { + ...newStates[cmdIndex], + command: newCommand.length > 0 ? newCommand : [''], + }; + return newStates; + }); + }; + + // 添加环境变量 + const addEnvVar = (cmdIndex: number) => { + setCommandStates((prev) => { + const newStates = [...prev]; + const newEnv = { ...newStates[cmdIndex].environment }; + newEnv[`VAR_${Object.keys(newEnv).length + 1}`] = ''; + newStates[cmdIndex] = { ...newStates[cmdIndex], environment: newEnv }; + return newStates; + }); + }; + + // 更新环境变量 + const updateEnvVar = (cmdIndex: number, oldKey: string, newKey: string, value: string) => { + setCommandStates((prev) => { + const newStates = [...prev]; + const newEnv = { ...newStates[cmdIndex].environment }; + if (oldKey !== newKey) { + delete newEnv[oldKey]; + } + newEnv[newKey] = value; + newStates[cmdIndex] = { ...newStates[cmdIndex], environment: newEnv }; + return newStates; + }); + }; + + // 删除环境变量 + const removeEnvVar = (cmdIndex: number, key: string) => { + setCommandStates((prev) => { + const newStates = [...prev]; + const newEnv = { ...newStates[cmdIndex].environment }; + delete newEnv[key]; + newStates[cmdIndex] = { ...newStates[cmdIndex], environment: newEnv }; + return newStates; + }); + }; + + // 添加新命令 + const addCommand = () => { + setCommandStates((prev) => [ + ...prev, + { + command: [''], + environment: {}, + timeout: undefined, + cwd: undefined, + showAdvanced: false, + }, + ]); + }; + + // 删除命令 + const removeCommand = (index: number) => { + if (commandStates.length <= 1) return; + setCommandStates((prev) => prev.filter((_, i) => i !== index)); + }; + + const title = isNew + ? isFileHook ? 'Add File Hook' : 'Add Session Hook' + : isFileHook ? 'Edit File Hook' : 'Edit Session Hook'; + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-gray-800 max-h-[85vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-lg mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +

+ {title} +

+ +
+ + {/* Content */} +
+ {/* Pattern (for file hooks) */} + {isFileHook && ( +
+ + setPattern(e.target.value)} + placeholder="e.g., *.ts, src/**/*.tsx" + className={cn( + 'w-full px-3 py-2 bg-gray-900 border rounded-lg text-sm', + 'focus:outline-none focus:ring-2 focus:ring-primary-500', + errors.pattern ? 'border-red-500' : 'border-gray-700' + )} + /> + {errors.pattern && ( +

{errors.pattern}

+ )} +

+ Use glob patterns to match files (e.g., *.ts, **/*.json) +

+
+ )} + + {/* Commands */} +
+
+ + {isFileHook && ( + + )} +
+ +
+ {commandStates.map((state, cmdIndex) => ( +
+ {/* Command args */} +
+
+ + Command {commandStates.length > 1 ? cmdIndex + 1 : ''} + +
+ + {commandStates.length > 1 && ( + + )} +
+
+ +
+ {state.command.map((arg, argIndex) => ( +
+ updateCommandArg(cmdIndex, argIndex, e.target.value)} + placeholder={argIndex === 0 ? 'command' : 'arg'} + className={cn( + 'px-2 py-1 bg-gray-800 border border-gray-600 rounded text-sm font-mono', + 'focus:outline-none focus:ring-1 focus:ring-primary-500', + argIndex === 0 ? 'min-w-[100px]' : 'min-w-[80px]' + )} + /> + {state.command.length > 1 && ( + + )} +
+ ))} + +
+ + {errors.commands?.[cmdIndex] && ( +

{errors.commands[cmdIndex]}

+ )} +
+ + {/* Advanced options toggle */} + + + {/* Advanced options */} + {state.showAdvanced && ( +
+ {/* Timeout */} +
+ + updateCommand(cmdIndex, { + timeout: e.target.value ? parseInt(e.target.value) : undefined + })} + placeholder="30000" + className="w-32 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary-500" + /> +
+ + {/* Working Directory */} +
+ + updateCommand(cmdIndex, { cwd: e.target.value || undefined })} + placeholder="(project root)" + className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary-500" + /> +
+ + {/* Environment Variables */} +
+
+ + +
+
+ {Object.entries(state.environment).map(([key, value]) => ( +
+ updateEnvVar(cmdIndex, key, e.target.value, value)} + placeholder="KEY" + className="w-28 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary-500" + /> + = + updateEnvVar(cmdIndex, key, key, e.target.value)} + placeholder="value" + className="flex-1 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary-500" + /> + +
+ ))} +
+
+
+ )} +
+ ))} +
+
+
+ + {/* Footer */} +
+ + +
+ + + + ); +} diff --git a/packages/ui/src/components/HooksPanel.tsx b/packages/ui/src/components/HooksPanel.tsx new file mode 100644 index 0000000..b7c908a --- /dev/null +++ b/packages/ui/src/components/HooksPanel.tsx @@ -0,0 +1,616 @@ +/** + * HooksPanel Component + * + * Hooks 钩子配置管理面板:显示和编辑所有钩子类型 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + X, + RefreshCw, + Zap, + Plus, + ChevronDown, + ChevronRight, + FileEdit, + FilePlus, + FileX, + CheckCircle, + Trash2, + Play, + Edit2, + AlertCircle, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Skeleton } from './Skeleton'; +import { HookEditor } from './HookEditor'; +import { + getHooksConfig, + updateHooksConfig, + testHookCommand, + type HookConfig, + type FileHookConfig, + type ShellCommandConfig, +} from '../api/client.js'; + +interface HooksPanelProps { + onClose: () => void; + /** 是否启用响应式布局 */ + responsive?: boolean; +} + +type HookType = 'file_edited' | 'file_created' | 'file_deleted' | 'session_completed'; + +interface EditingHook { + type: HookType; + pattern?: string; // 仅用于文件钩子 + command?: ShellCommandConfig; + isNew: boolean; +} + +const HOOK_TYPES: { type: HookType; label: string; icon: React.ReactNode; description: string }[] = [ + { type: 'file_edited', label: 'File Edited', icon: , description: 'Triggered after a file is edited' }, + { type: 'file_created', label: 'File Created', icon: , description: 'Triggered after a file is created' }, + { type: 'file_deleted', label: 'File Deleted', icon: , description: 'Triggered after a file is deleted' }, + { type: 'session_completed', label: 'Session Completed', icon: , description: 'Triggered when a session ends' }, +]; + +export function HooksPanel({ onClose, responsive = false }: HooksPanelProps) { + // 数据状态 + const [config, setConfig] = useState({}); + const [expandedTypes, setExpandedTypes] = useState>(new Set(['file_edited'])); + + // UI 状态 + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [saving, setSaving] = useState(false); + const [testingCommand, setTestingCommand] = useState(null); + + // 编辑状态 + const [editingHook, setEditingHook] = useState(null); + + // 加载配置 + const loadConfig = useCallback(async (showToast = false) => { + try { + const result = await getHooksConfig(); + if (result.success) { + setConfig(result.data); + if (showToast) { + toast.success('Configuration refreshed'); + } + } else { + toast.error(result.error || 'Failed to load configuration'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load configuration'); + } + }, []); + + // 初始加载 + useEffect(() => { + setLoading(true); + loadConfig().finally(() => setLoading(false)); + }, [loadConfig]); + + // 刷新 + const handleRefresh = async () => { + setRefreshing(true); + await loadConfig(true); + setRefreshing(false); + }; + + // 保存配置 + const saveConfig = async (newConfig: HookConfig) => { + setSaving(true); + try { + const result = await updateHooksConfig(newConfig); + if (result.success) { + setConfig(result.data); + toast.success('Configuration saved'); + return true; + } else { + toast.error(result.error || 'Failed to save configuration'); + return false; + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save configuration'); + return false; + } finally { + setSaving(false); + } + }; + + // 切换展开 + const toggleExpanded = (type: HookType) => { + const newExpanded = new Set(expandedTypes); + if (newExpanded.has(type)) { + newExpanded.delete(type); + } else { + newExpanded.add(type); + } + setExpandedTypes(newExpanded); + }; + + // 测试命令 + const handleTestCommand = async (cmd: ShellCommandConfig, id: string) => { + setTestingCommand(id); + try { + const result = await testHookCommand(cmd); + if (result.success && result.data) { + if (result.data.success) { + toast.success( +
+
Command succeeded
+
+ Exit code: {result.data.exitCode} ({result.data.duration}ms) +
+ {result.data.stdout && ( +
+                  {result.data.stdout.slice(0, 500)}
+                
+ )} +
+ ); + } else { + toast.error( +
+
Command failed
+
+ Exit code: {result.data.exitCode} ({result.data.duration}ms) +
+ {result.data.stderr && ( +
+                  {result.data.stderr.slice(0, 500)}
+                
+ )} +
+ ); + } + } else { + toast.error(result.error || 'Test failed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Test failed'); + } finally { + setTestingCommand(null); + } + }; + + // 删除文件钩子的某个 pattern + const handleDeleteFileHook = async (type: HookType, pattern: string) => { + if (type === 'session_completed') return; + + const fileHooks = config[type] as FileHookConfig | undefined; + if (!fileHooks) return; + + const newFileHooks = { ...fileHooks }; + delete newFileHooks[pattern]; + + const newConfig = { ...config, [type]: newFileHooks }; + await saveConfig(newConfig); + }; + + // 删除 session_completed 的某个命令 + const handleDeleteSessionHook = async (index: number) => { + const commands = config.session_completed || []; + const newCommands = commands.filter((_, i) => i !== index); + const newConfig = { ...config, session_completed: newCommands }; + await saveConfig(newConfig); + }; + + // 打开编辑器 - 文件钩子 + const openFileHookEditor = (type: HookType, pattern?: string, commands?: ShellCommandConfig[]) => { + setEditingHook({ + type, + pattern, + command: commands?.[0], + isNew: !pattern, + }); + }; + + // 打开编辑器 - Session 钩子 + const openSessionHookEditor = (command?: ShellCommandConfig, index?: number) => { + setEditingHook({ + type: 'session_completed', + pattern: index !== undefined ? String(index) : undefined, + command, + isNew: index === undefined, + }); + }; + + // 保存编辑的钩子 + const handleSaveHook = async (type: HookType, pattern: string, commands: ShellCommandConfig[]) => { + let newConfig: HookConfig; + + if (type === 'session_completed') { + // Session hook + const existingCommands = [...(config.session_completed || [])]; + const index = editingHook?.pattern !== undefined ? parseInt(editingHook.pattern) : -1; + + if (index >= 0 && index < existingCommands.length) { + // 更新现有命令 + existingCommands[index] = commands[0]; + } else { + // 添加新命令 + existingCommands.push(commands[0]); + } + + newConfig = { ...config, session_completed: existingCommands }; + } else { + // File hook + const fileHooks = { ...(config[type] as FileHookConfig || {}) }; + + // 如果是重命名 pattern + if (editingHook?.pattern && editingHook.pattern !== pattern) { + delete fileHooks[editingHook.pattern]; + } + + fileHooks[pattern] = commands; + newConfig = { ...config, [type]: fileHooks }; + } + + const success = await saveConfig(newConfig); + if (success) { + setEditingHook(null); + } + }; + + // 统计 + const totalHooks = Object.values(config).reduce((sum, hooks) => { + if (Array.isArray(hooks)) { + return sum + hooks.length; + } + if (hooks && typeof hooks === 'object') { + return sum + Object.keys(hooks).length; + } + return sum; + }, 0); + + // Loading 骨架屏 + const LoadingSkeleton = () => ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+ + + +
+
+ ))} +
+ ); + + // 渲染文件钩子内容 + const renderFileHooks = (type: HookType) => { + const hooks = config[type] as FileHookConfig | undefined; + const patterns = Object.keys(hooks || {}); + + if (patterns.length === 0) { + return ( +
+ No hooks configured +
+ ); + } + + return ( +
+ {patterns.map((pattern) => { + const commands = hooks![pattern]; + const cmdId = `${type}-${pattern}`; + + return ( +
+
+ {pattern} +
+ + +
+
+
+ {commands.map((cmd, idx) => ( +
+ + {cmd.command.join(' ')} + + +
+ ))} +
+
+ ); + })} +
+ ); + }; + + // 渲染 session hooks + const renderSessionHooks = () => { + const commands = config.session_completed || []; + + if (commands.length === 0) { + return ( +
+ No hooks configured +
+ ); + } + + return ( +
+ {commands.map((cmd, idx) => { + const cmdId = `session-${idx}`; + return ( +
+ + {cmd.command.join(' ')} + +
+ + + +
+
+ ); + })} +
+ ); + }; + + return ( + <> + + + e.stopPropagation()} + className={cn( + 'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-2xl mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Hooks Configuration +

+

+ {totalHooks} hooks configured +

+
+
+ + +
+
+ + {/* Content */} +
+ {loading ? ( + + ) : ( + + {HOOK_TYPES.map(({ type, label, icon, description }) => { + const isExpanded = expandedTypes.has(type); + const isFileHook = type !== 'session_completed'; + const hookCount = isFileHook + ? Object.keys((config[type] as FileHookConfig) || {}).length + : (config.session_completed || []).length; + + return ( + + {/* Type Header */} +
toggleExpanded(type)} + > + + +
{icon}
+ +
+
+ {label} + {hookCount > 0 && ( + + {hookCount} + + )} +
+
{description}
+
+ + +
+ + {/* Expanded Content */} + + {isExpanded && ( + +
+ {isFileHook ? renderFileHooks(type) : renderSessionHooks()} +
+
+ )} +
+
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+ + + Commands are executed in your project directory. Use caution with destructive operations. + +
+ + + + + {/* Hook Editor Modal */} + {editingHook && ( + setEditingHook(null)} + onTest={handleTestCommand} + saving={saving} + responsive={responsive} + /> + )} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 15ee077..5e2d6f8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -42,6 +42,18 @@ export { listMCPTools, getMCPTool, getMCPConfig, + // Hooks API + getHooksConfig, + updateHooksConfig, + getFileEditedHooks, + updateFileEditedHooks, + getFileCreatedHooks, + updateFileCreatedHooks, + getFileDeletedHooks, + updateFileDeletedHooks, + getSessionCompletedHooks, + updateSessionCompletedHooks, + testHookCommand, } from './api/client.js'; // Types @@ -70,6 +82,11 @@ export type { MCPToolInfo, MCPConfig, MCPServerConfigInfo, + // Hooks types + HookConfig, + FileHookConfig, + ShellCommandConfig, + HookTestResult, } from './api/client.js'; // Primitives (shadcn/ui style) @@ -86,6 +103,8 @@ export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js'; export { CommandPanel } from './components/CommandPanel.js'; export { CommandEditor } from './components/CommandEditor.js'; export { MCPPanel } from './components/MCPPanel.js'; +export { HooksPanel } from './components/HooksPanel.js'; +export { HookEditor } from './components/HookEditor.js'; export { Sidebar } from './components/Sidebar.js'; export { FileBrowser } from './components/FileBrowser.js'; export { ConfigPanel } from './components/ConfigPanel.js'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 710ac6d..8bc9364 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -11,6 +11,7 @@ import { ConfigPanel, CommandPanel, MCPPanel, + HooksPanel, Toaster, listSessions, createSession, @@ -25,6 +26,7 @@ export function App() { const [showConfig, setShowConfig] = useState(false); const [showCommands, setShowCommands] = useState(false); const [showMCP, setShowMCP] = useState(false); + const [showHooks, setShowHooks] = useState(false); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); // 初始化:加载或创建会话 @@ -111,6 +113,7 @@ export function App() { onOpenConfig={() => setShowConfig(true)} onOpenCommands={() => setShowCommands(true)} onOpenMCP={() => setShowMCP(true)} + onOpenHooks={() => setShowHooks(true)} /> ) : (
@@ -165,6 +168,9 @@ export function App() { {/* MCP 面板 */} {showMCP && setShowMCP(false)} responsive />} + {/* Hooks 面板 */} + {showHooks && setShowHooks(false)} responsive />} + {/* 移动端底部文件按钮 */}