feat(hooks): 添加 Hooks 配置管理功能

- 新增 Server Hooks API 路由 (CRUD + 测试执行)
- 新增 HooksPanel 组件用于管理所有钩子类型
- 新增 HookEditor 组件用于编辑单个钩子规则
- 支持 file_edited/file_created/file_deleted/session_completed 四种钩子
- 集成到 web 和 desktop 应用
This commit is contained in:
2025-12-12 21:02:06 +08:00
parent 622bd869f9
commit 9365e07df1
12 changed files with 1990 additions and 5 deletions
+2 -1
View File
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { createBunWebSocket } from 'hono/bun';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter } from './routes/index.js';
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter, hooksRouter } from './routes/index.js';
import {
handleWebSocket,
handleWebSocketMessage,
@@ -84,6 +84,7 @@ api.route('/config', configRouter);
api.route('/files', filesRouter);
api.route('/commands', commandsRouter);
api.route('/mcp', mcpRouter);
api.route('/hooks', hooksRouter);
// SSE 事件流
api.get('/sessions/:id/events', handleSSE);
+581
View File
@@ -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
);
}
});
+1
View File
@@ -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';