feat(hooks): 添加 Hooks 配置管理功能
- 新增 Server Hooks API 路由 (CRUD + 测试执行) - 新增 HooksPanel 组件用于管理所有钩子类型 - 新增 HookEditor 组件用于编辑单个钩子规则 - 支持 file_edited/file_created/file_deleted/session_completed 四种钩子 - 集成到 web 和 desktop 应用
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
CommandPanel,
|
CommandPanel,
|
||||||
MCPPanel,
|
MCPPanel,
|
||||||
|
HooksPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -23,6 +24,7 @@ export function App() {
|
|||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const [showMCP, setShowMCP] = useState(false);
|
const [showMCP, setShowMCP] = useState(false);
|
||||||
|
const [showHooks, setShowHooks] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -95,6 +97,7 @@ export function App() {
|
|||||||
onOpenConfig={() => setShowConfig(true)}
|
onOpenConfig={() => setShowConfig(true)}
|
||||||
onOpenCommands={() => setShowCommands(true)}
|
onOpenCommands={() => setShowCommands(true)}
|
||||||
onOpenMCP={() => setShowMCP(true)}
|
onOpenMCP={() => setShowMCP(true)}
|
||||||
|
onOpenHooks={() => setShowHooks(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -124,6 +127,9 @@ export function App() {
|
|||||||
{/* MCP 面板 */}
|
{/* MCP 面板 */}
|
||||||
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />}
|
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />}
|
||||||
|
|
||||||
|
{/* Hooks 面板 */}
|
||||||
|
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} />}
|
||||||
|
|
||||||
{/* Toast 通知 */}
|
{/* Toast 通知 */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -22,6 +22,7 @@ interface ChatPageProps {
|
|||||||
onOpenConfig?: () => void;
|
onOpenConfig?: () => void;
|
||||||
onOpenCommands?: () => void;
|
onOpenCommands?: () => void;
|
||||||
onOpenMCP?: () => void;
|
onOpenMCP?: () => void;
|
||||||
|
onOpenHooks?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -32,6 +33,7 @@ export function ChatPage({
|
|||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenCommands,
|
onOpenCommands,
|
||||||
onOpenMCP,
|
onOpenMCP,
|
||||||
|
onOpenHooks,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -125,8 +127,21 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
|
||||||
{/* 工具栏按钮 */}
|
{/* 工具栏按钮 */}
|
||||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
|
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks) && (
|
||||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
|
{/* Hooks 按钮 */}
|
||||||
|
{onOpenHooks && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={onOpenHooks}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||||
|
title="Hooks"
|
||||||
|
>
|
||||||
|
<Zap size={20} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* MCP 按钮 */}
|
{/* MCP 按钮 */}
|
||||||
{onOpenMCP && (
|
{onOpenMCP && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
|
|||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { createBunWebSocket } from 'hono/bun';
|
import { createBunWebSocket } from 'hono/bun';
|
||||||
|
|
||||||
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter } from './routes/index.js';
|
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter } from './routes/index.js';
|
||||||
import {
|
import {
|
||||||
handleWebSocket,
|
handleWebSocket,
|
||||||
handleWebSocketMessage,
|
handleWebSocketMessage,
|
||||||
@@ -84,6 +84,7 @@ api.route('/config', configRouter);
|
|||||||
api.route('/files', filesRouter);
|
api.route('/files', filesRouter);
|
||||||
api.route('/commands', commandsRouter);
|
api.route('/commands', commandsRouter);
|
||||||
api.route('/mcp', mcpRouter);
|
api.route('/mcp', mcpRouter);
|
||||||
|
api.route('/hooks', hooksRouter);
|
||||||
|
|
||||||
// SSE 事件流
|
// SSE 事件流
|
||||||
api.get('/sessions/:id/events', handleSSE);
|
api.get('/sessions/:id/events', handleSSE);
|
||||||
|
|||||||
@@ -0,0 +1,581 @@
|
|||||||
|
/**
|
||||||
|
* Hooks API Routes
|
||||||
|
*
|
||||||
|
* 提供 Hooks 配置管理的 REST API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { getConfig } from './config.js';
|
||||||
|
|
||||||
|
// Core Hooks 模块类型
|
||||||
|
interface HooksModule {
|
||||||
|
loadProjectConfig: (directory: string) => Promise<ProjectConfig | null>;
|
||||||
|
loadHookConfig: (directory: string) => Promise<HookConfig | null>;
|
||||||
|
getConfigFilePath: (directory: string) => Promise<string | null>;
|
||||||
|
createDefaultConfig: (directory: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShellCommandConfig {
|
||||||
|
command: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
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<HooksModule | null> {
|
||||||
|
if (hooksModule) return hooksModule;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const corePath = '@ai-assistant/core';
|
||||||
|
const core = (await import(corePath)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
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<ProjectConfig | null> {
|
||||||
|
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<void> {
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建配置文件路径
|
||||||
|
*/
|
||||||
|
async function getOrCreateConfigPath(workdir: string): Promise<string> {
|
||||||
|
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<HookConfig>();
|
||||||
|
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<FileHookConfig>();
|
||||||
|
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<FileHookConfig>();
|
||||||
|
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<FileHookConfig>();
|
||||||
|
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<ShellCommandConfig[]>();
|
||||||
|
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<ShellCommandConfig>();
|
||||||
|
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<HookTestResult>((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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,3 +10,4 @@ export { configRouter, getConfig, setConfig } from './config.js';
|
|||||||
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
|
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
|
||||||
export { commandsRouter } from './commands.js';
|
export { commandsRouter } from './commands.js';
|
||||||
export { mcpRouter } from './mcp.js';
|
export { mcpRouter } from './mcp.js';
|
||||||
|
export { hooksRouter } from './hooks.js';
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import type {
|
|||||||
MCPServerStatus,
|
MCPServerStatus,
|
||||||
MCPToolInfo,
|
MCPToolInfo,
|
||||||
MCPConfig,
|
MCPConfig,
|
||||||
|
HookConfig,
|
||||||
|
FileHookConfig,
|
||||||
|
ShellCommandConfig,
|
||||||
|
HookTestResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
@@ -45,6 +49,11 @@ export type {
|
|||||||
MCPToolInfo,
|
MCPToolInfo,
|
||||||
MCPConfig,
|
MCPConfig,
|
||||||
MCPServerConfigInfo,
|
MCPServerConfigInfo,
|
||||||
|
// Hooks types
|
||||||
|
HookConfig,
|
||||||
|
FileHookConfig,
|
||||||
|
ShellCommandConfig,
|
||||||
|
HookTestResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
@@ -344,3 +353,126 @@ export async function getMCPConfig(): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
return request('GET', '/mcp/config');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,3 +236,48 @@ export interface MCPServerConfigInfo {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Hooks 相关 ============
|
||||||
|
|
||||||
|
/** Shell 命令配置 */
|
||||||
|
export interface ShellCommandConfig {
|
||||||
|
/** 命令数组,如 ['npm', 'run', 'lint'] */
|
||||||
|
command: string[];
|
||||||
|
/** 环境变量 */
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
/** 超时时间(毫秒) */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onTest: (command: ShellCommandConfig, id: string) => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
responsive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandEditorState {
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
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<CommandEditorState[]>(() => {
|
||||||
|
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<number | null>(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<CommandEditorState>) => {
|
||||||
|
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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={modalOverlay}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-black/70 flex z-[60]',
|
||||||
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||||
|
)}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={modalContent}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={smoothTransition}
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between border-b border-gray-700',
|
||||||
|
responsive ? 'px-4 py-3' : 'px-6 py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{responsive && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||||
|
)}
|
||||||
|
<h3 className={cn('text-lg font-semibold', responsive && 'mt-2 md:mt-0')}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onCancel}>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Pattern (for file hooks) */}
|
||||||
|
{isFileHook && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Pattern (glob)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">{errors.pattern}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Use glob patterns to match files (e.g., *.ts, **/*.json)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Commands */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium text-gray-300">
|
||||||
|
Commands
|
||||||
|
</label>
|
||||||
|
{isFileHook && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addCommand}
|
||||||
|
className="text-primary-400"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Add Command
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{commandStates.map((state, cmdIndex) => (
|
||||||
|
<div
|
||||||
|
key={cmdIndex}
|
||||||
|
className="bg-gray-900/50 rounded-lg p-3 border border-gray-700"
|
||||||
|
>
|
||||||
|
{/* Command args */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Command {commandStates.length > 1 ? cmdIndex + 1 : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTest(cmdIndex)}
|
||||||
|
disabled={testingIndex === cmdIndex}
|
||||||
|
className="h-6 text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
{testingIndex === cmdIndex ? (
|
||||||
|
<RefreshCw size={12} className="animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<Play size={12} className="mr-1" />
|
||||||
|
)}
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
{commandStates.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCommand(cmdIndex)}
|
||||||
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{state.command.map((arg, argIndex) => (
|
||||||
|
<div key={argIndex} className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={arg}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeCommandArg(cmdIndex, argIndex)}
|
||||||
|
className="text-gray-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addCommandArg(cmdIndex)}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.commands?.[cmdIndex] && (
|
||||||
|
<p className="text-xs text-red-400">{errors.commands[cmdIndex]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced options toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => updateCommand(cmdIndex, { showAdvanced: !state.showAdvanced })}
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 mt-3"
|
||||||
|
>
|
||||||
|
{state.showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
Advanced options
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Advanced options */}
|
||||||
|
{state.showAdvanced && (
|
||||||
|
<div className="mt-3 space-y-3 pl-4 border-l border-gray-700">
|
||||||
|
{/* Timeout */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">
|
||||||
|
Timeout (ms)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={state.timeout || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Working Directory */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">
|
||||||
|
Working Directory
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={state.cwd || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-xs text-gray-400">
|
||||||
|
Environment Variables
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addEnvVar(cmdIndex)}
|
||||||
|
className="h-5 px-1 text-xs"
|
||||||
|
>
|
||||||
|
<Plus size={10} className="mr-0.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(state.environment).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-500">=</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEnvVar(cmdIndex, key)}
|
||||||
|
className="text-gray-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-end gap-2 border-t border-gray-700',
|
||||||
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={14} className="animate-spin mr-1" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: <FileEdit size={16} />, description: 'Triggered after a file is edited' },
|
||||||
|
{ type: 'file_created', label: 'File Created', icon: <FilePlus size={16} />, description: 'Triggered after a file is created' },
|
||||||
|
{ type: 'file_deleted', label: 'File Deleted', icon: <FileX size={16} />, description: 'Triggered after a file is deleted' },
|
||||||
|
{ type: 'session_completed', label: 'Session Completed', icon: <CheckCircle size={16} />, description: 'Triggered when a session ends' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HooksPanel({ onClose, responsive = false }: HooksPanelProps) {
|
||||||
|
// 数据状态
|
||||||
|
const [config, setConfig] = useState<HookConfig>({});
|
||||||
|
const [expandedTypes, setExpandedTypes] = useState<Set<HookType>>(new Set(['file_edited']));
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testingCommand, setTestingCommand] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 编辑状态
|
||||||
|
const [editingHook, setEditingHook] = useState<EditingHook | null>(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(
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Command succeeded</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
Exit code: {result.data.exitCode} ({result.data.duration}ms)
|
||||||
|
</div>
|
||||||
|
{result.data.stdout && (
|
||||||
|
<pre className="text-xs bg-gray-900 p-2 rounded mt-2 max-h-32 overflow-auto">
|
||||||
|
{result.data.stdout.slice(0, 500)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Command failed</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
Exit code: {result.data.exitCode} ({result.data.duration}ms)
|
||||||
|
</div>
|
||||||
|
{result.data.stderr && (
|
||||||
|
<pre className="text-xs bg-gray-900 p-2 rounded mt-2 max-h-32 overflow-auto text-red-400">
|
||||||
|
{result.data.stderr.slice(0, 500)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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 = () => (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="bg-gray-900/50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-4 w-16 ml-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染文件钩子内容
|
||||||
|
const renderFileHooks = (type: HookType) => {
|
||||||
|
const hooks = config[type] as FileHookConfig | undefined;
|
||||||
|
const patterns = Object.keys(hooks || {});
|
||||||
|
|
||||||
|
if (patterns.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-500 py-2 px-3">
|
||||||
|
No hooks configured
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{patterns.map((pattern) => {
|
||||||
|
const commands = hooks![pattern];
|
||||||
|
const cmdId = `${type}-${pattern}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pattern} className="bg-gray-800/50 rounded p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<code className="text-xs font-mono text-blue-400">{pattern}</code>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openFileHookEditor(type, pattern, commands)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteFileHook(type, pattern)}
|
||||||
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{commands.map((cmd, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between text-xs">
|
||||||
|
<code className="font-mono text-gray-400 truncate flex-1">
|
||||||
|
{cmd.command.join(' ')}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTestCommand(cmd, `${cmdId}-${idx}`)}
|
||||||
|
disabled={testingCommand === `${cmdId}-${idx}`}
|
||||||
|
className="h-5 px-1.5 text-green-400 hover:text-green-300"
|
||||||
|
title="Test"
|
||||||
|
>
|
||||||
|
{testingCommand === `${cmdId}-${idx}` ? (
|
||||||
|
<RefreshCw size={10} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play size={10} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染 session hooks
|
||||||
|
const renderSessionHooks = () => {
|
||||||
|
const commands = config.session_completed || [];
|
||||||
|
|
||||||
|
if (commands.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-500 py-2 px-3">
|
||||||
|
No hooks configured
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{commands.map((cmd, idx) => {
|
||||||
|
const cmdId = `session-${idx}`;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="bg-gray-800/50 rounded p-2 flex items-center justify-between">
|
||||||
|
<code className="text-xs font-mono text-gray-400 truncate flex-1">
|
||||||
|
{cmd.command.join(' ')}
|
||||||
|
</code>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTestCommand(cmd, cmdId)}
|
||||||
|
disabled={testingCommand === cmdId}
|
||||||
|
className="h-6 px-1.5 text-green-400 hover:text-green-300"
|
||||||
|
title="Test"
|
||||||
|
>
|
||||||
|
{testingCommand === cmdId ? (
|
||||||
|
<RefreshCw size={10} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play size={10} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openSessionHookEditor(cmd, idx)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteSessionHook(idx)}
|
||||||
|
className="h-6 w-6 p-0 text-red-400 hover:text-red-300"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={modalOverlay}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-black/50 flex z-50',
|
||||||
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={modalContent}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={smoothTransition}
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between border-b border-gray-700',
|
||||||
|
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{responsive && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||||
|
)}
|
||||||
|
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Zap size={20} className="text-yellow-400" />
|
||||||
|
Hooks Configuration
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{totalHooks} hooks configured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Refresh"
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSkeleton />
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<motion.div
|
||||||
|
key={type}
|
||||||
|
layout
|
||||||
|
className="bg-gray-900/50 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Type Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-3',
|
||||||
|
'hover:bg-gray-900/80 transition-colors cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={() => toggleExpanded(type)}
|
||||||
|
>
|
||||||
|
<button className="text-gray-500 hover:text-gray-300">
|
||||||
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-primary-400">{icon}</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-200">{label}</span>
|
||||||
|
{hookCount > 0 && (
|
||||||
|
<span className="text-xs bg-gray-700 px-1.5 py-0.5 rounded">
|
||||||
|
{hookCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{description}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isFileHook) {
|
||||||
|
openFileHookEditor(type);
|
||||||
|
} else {
|
||||||
|
openSessionHookEditor();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-primary-400 hover:text-primary-300"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-3 pt-1 border-t border-gray-700/50">
|
||||||
|
{isFileHook ? renderFileHooks(type) : renderSessionHooks()}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-t border-gray-700 text-xs text-gray-500 px-4 py-3 flex items-start gap-2',
|
||||||
|
responsive && 'safe-area-pb'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircle size={14} className="flex-shrink-0 mt-0.5 text-yellow-500" />
|
||||||
|
<span>
|
||||||
|
Commands are executed in your project directory. Use caution with destructive operations.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Hook Editor Modal */}
|
||||||
|
{editingHook && (
|
||||||
|
<HookEditor
|
||||||
|
type={editingHook.type}
|
||||||
|
pattern={editingHook.pattern}
|
||||||
|
commands={editingHook.command ? [editingHook.command] : []}
|
||||||
|
isNew={editingHook.isNew}
|
||||||
|
onSave={handleSaveHook}
|
||||||
|
onCancel={() => setEditingHook(null)}
|
||||||
|
onTest={handleTestCommand}
|
||||||
|
saving={saving}
|
||||||
|
responsive={responsive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,18 @@ export {
|
|||||||
listMCPTools,
|
listMCPTools,
|
||||||
getMCPTool,
|
getMCPTool,
|
||||||
getMCPConfig,
|
getMCPConfig,
|
||||||
|
// Hooks API
|
||||||
|
getHooksConfig,
|
||||||
|
updateHooksConfig,
|
||||||
|
getFileEditedHooks,
|
||||||
|
updateFileEditedHooks,
|
||||||
|
getFileCreatedHooks,
|
||||||
|
updateFileCreatedHooks,
|
||||||
|
getFileDeletedHooks,
|
||||||
|
updateFileDeletedHooks,
|
||||||
|
getSessionCompletedHooks,
|
||||||
|
updateSessionCompletedHooks,
|
||||||
|
testHookCommand,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -70,6 +82,11 @@ export type {
|
|||||||
MCPToolInfo,
|
MCPToolInfo,
|
||||||
MCPConfig,
|
MCPConfig,
|
||||||
MCPServerConfigInfo,
|
MCPServerConfigInfo,
|
||||||
|
// Hooks types
|
||||||
|
HookConfig,
|
||||||
|
FileHookConfig,
|
||||||
|
ShellCommandConfig,
|
||||||
|
HookTestResult,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Primitives (shadcn/ui style)
|
// Primitives (shadcn/ui style)
|
||||||
@@ -86,6 +103,8 @@ export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
|||||||
export { CommandPanel } from './components/CommandPanel.js';
|
export { CommandPanel } from './components/CommandPanel.js';
|
||||||
export { CommandEditor } from './components/CommandEditor.js';
|
export { CommandEditor } from './components/CommandEditor.js';
|
||||||
export { MCPPanel } from './components/MCPPanel.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 { Sidebar } from './components/Sidebar.js';
|
||||||
export { FileBrowser } from './components/FileBrowser.js';
|
export { FileBrowser } from './components/FileBrowser.js';
|
||||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
CommandPanel,
|
CommandPanel,
|
||||||
MCPPanel,
|
MCPPanel,
|
||||||
|
HooksPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -25,6 +26,7 @@ export function App() {
|
|||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const [showMCP, setShowMCP] = useState(false);
|
const [showMCP, setShowMCP] = useState(false);
|
||||||
|
const [showHooks, setShowHooks] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -111,6 +113,7 @@ export function App() {
|
|||||||
onOpenConfig={() => setShowConfig(true)}
|
onOpenConfig={() => setShowConfig(true)}
|
||||||
onOpenCommands={() => setShowCommands(true)}
|
onOpenCommands={() => setShowCommands(true)}
|
||||||
onOpenMCP={() => setShowMCP(true)}
|
onOpenMCP={() => setShowMCP(true)}
|
||||||
|
onOpenHooks={() => setShowHooks(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -165,6 +168,9 @@ export function App() {
|
|||||||
{/* MCP 面板 */}
|
{/* MCP 面板 */}
|
||||||
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
|
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
|
||||||
|
|
||||||
|
{/* Hooks 面板 */}
|
||||||
|
{showHooks && <HooksPanel onClose={() => setShowHooks(false)} responsive />}
|
||||||
|
|
||||||
{/* 移动端底部文件按钮 */}
|
{/* 移动端底部文件按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFileBrowser(true)}
|
onClick={() => setShowFileBrowser(true)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -24,6 +24,7 @@ interface ChatPageProps {
|
|||||||
onOpenConfig?: () => void;
|
onOpenConfig?: () => void;
|
||||||
onOpenCommands?: () => void;
|
onOpenCommands?: () => void;
|
||||||
onOpenMCP?: () => void;
|
onOpenMCP?: () => void;
|
||||||
|
onOpenHooks?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -36,6 +37,7 @@ export function ChatPage({
|
|||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenCommands,
|
onOpenCommands,
|
||||||
onOpenMCP,
|
onOpenMCP,
|
||||||
|
onOpenHooks,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -130,8 +132,21 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
|
||||||
{/* 工具栏按钮 */}
|
{/* 工具栏按钮 */}
|
||||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
|
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks) && (
|
||||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
|
{/* Hooks 按钮 */}
|
||||||
|
{onOpenHooks && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={onOpenHooks}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||||
|
title="Hooks"
|
||||||
|
>
|
||||||
|
<Zap size={20} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* MCP 按钮 */}
|
{/* MCP 按钮 */}
|
||||||
{onOpenMCP && (
|
{onOpenMCP && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
Reference in New Issue
Block a user