diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 5fb40f50..9aeb9fa5 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -29,6 +29,7 @@ import { ArrowLeftRight, FileArchive, DatabaseBackup, + Webhook, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; @@ -108,6 +109,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { )} + diff --git a/ui/web/src/hooks/use-hooks.ts b/ui/web/src/hooks/use-hooks.ts new file mode 100644 index 00000000..8d315ae7 --- /dev/null +++ b/ui/web/src/hooks/use-hooks.ts @@ -0,0 +1,211 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; +import i18next from "i18next"; +import { useWs } from "@/hooks/use-ws"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { toast } from "@/stores/use-toast-store"; + +export interface HookConfig { + id: string; + tenant_id: string; + agent_id?: string | null; + event: string; + handler_type: "command" | "http" | "prompt"; + scope: "global" | "tenant" | "agent"; + config: Record; + matcher?: string; + if_expr?: string; + timeout_ms: number; + on_timeout: "block" | "allow"; + priority: number; + enabled: boolean; + version: number; + source: "ui" | "api" | "seed"; + metadata: Record; + created_by?: string; + created_at: string; + updated_at: string; +} + +export interface HookTestResult { + decision: "allow" | "block" | "error" | "timeout"; + reason?: string; + durationMs: number; + stdout?: string; + stderr?: string; + statusCode?: number; + error?: string; + updatedInput?: Record; +} + +export interface HookExecution { + id: string; + hook_id: string; + session_key?: string; + decision: string; + duration_ms: number; + error?: string; + created_at: string; +} + +const HOOKS_QUERY_KEY = ["hooks"] as const; +const hookDetailKey = (id: string) => ["hooks", id] as const; +const hookHistoryKey = (id: string) => ["hooks", id, "history"] as const; + +export function useHooksList(filters?: { + event?: string; + scope?: string; + agentId?: string; + enabled?: boolean; +}) { + const ws = useWs(); + const connected = useAuthStore((s) => s.connected); + + return useQuery({ + queryKey: [...HOOKS_QUERY_KEY, filters ?? {}], + queryFn: async () => { + const res = await ws.call<{ hooks: HookConfig[] }>("hooks.list", filters ?? {}); + return res.hooks ?? []; + }, + staleTime: 30_000, + enabled: connected, + }); +} + +export function useHook(id: string | undefined) { + const ws = useWs(); + const connected = useAuthStore((s) => s.connected); + + return useQuery({ + queryKey: hookDetailKey(id ?? ""), + queryFn: async () => { + const res = await ws.call<{ hooks: HookConfig[] }>("hooks.list", {}); + return res.hooks?.find((h) => h.id === id) ?? null; + }, + staleTime: 30_000, + enabled: connected && !!id, + }); +} + +export function useHookHistory(hookId: string | undefined) { + const ws = useWs(); + const connected = useAuthStore((s) => s.connected); + + return useQuery({ + queryKey: hookHistoryKey(hookId ?? ""), + queryFn: async () => { + const res = await ws.call<{ executions: HookExecution[]; nextCursor: string; note?: string }>( + "hooks.history", + { hookId }, + ); + return res; + }, + staleTime: 60_000, + enabled: connected && !!hookId, + }); +} + +function useInvalidateHooks() { + const queryClient = useQueryClient(); + return useCallback( + () => queryClient.invalidateQueries({ queryKey: HOOKS_QUERY_KEY }), + [queryClient], + ); +} + +export function useCreateHook() { + const ws = useWs(); + const invalidate = useInvalidateHooks(); + + return useMutation({ + mutationFn: (params: Partial) => + ws.call<{ hookId: string }>("hooks.create", params), + onSuccess: async () => { + await invalidate(); + toast.success(i18next.t("hooks:toast.created")); + }, + onError: (err) => { + toast.error( + i18next.t("hooks:toast.failedCreate"), + err instanceof Error ? err.message : "", + ); + }, + }); +} + +export function useUpdateHook() { + const ws = useWs(); + const invalidate = useInvalidateHooks(); + + return useMutation({ + mutationFn: ({ hookId, updates }: { hookId: string; updates: Record }) => + ws.call<{ hookId: string }>("hooks.update", { hookId, updates }), + onSuccess: async () => { + await invalidate(); + toast.success(i18next.t("hooks:toast.updated")); + }, + onError: (err) => { + toast.error( + i18next.t("hooks:toast.failedUpdate"), + err instanceof Error ? err.message : "", + ); + }, + }); +} + +export function useDeleteHook() { + const ws = useWs(); + const invalidate = useInvalidateHooks(); + + return useMutation({ + mutationFn: (hookId: string) => ws.call<{ hookId: string }>("hooks.delete", { hookId }), + onSuccess: async () => { + await invalidate(); + toast.success(i18next.t("hooks:toast.deleted")); + }, + onError: (err) => { + toast.error( + i18next.t("hooks:toast.failedDelete"), + err instanceof Error ? err.message : "", + ); + }, + }); +} + +export function useToggleHook() { + const ws = useWs(); + const invalidate = useInvalidateHooks(); + + return useMutation({ + mutationFn: ({ hookId, enabled }: { hookId: string; enabled: boolean }) => + ws.call<{ hookId: string; enabled: boolean }>("hooks.toggle", { hookId, enabled }), + onSuccess: async () => { + await invalidate(); + toast.success(i18next.t("hooks:toast.toggled")); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : String(err)); + }, + }); +} + +export function useTestHook() { + const ws = useWs(); + + return useMutation({ + mutationFn: ({ + config, + sampleEvent, + }: { + config: Partial; + sampleEvent: { toolName: string; toolInput: Record; rawInput?: string }; + }) => + ws.call<{ result: HookTestResult }>("hooks.test", { config, sampleEvent }), + onError: (err) => { + toast.error( + i18next.t("hooks:toast.failedTest"), + err instanceof Error ? err.message : "", + ); + }, + }); +} diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index 22061a93..25d6e414 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -40,6 +40,7 @@ import enSystemSettings from "./locales/en/system-settings.json"; import enImportExport from "./locales/en/import-export.json"; import enV3Capabilities from "./locales/en/v3-capabilities.json"; import enBackup from "./locales/en/backup.json"; +import enHooks from "./locales/en/hooks.json"; // --- VI namespaces --- import viCommon from "./locales/vi/common.json"; @@ -80,6 +81,7 @@ import viSystemSettings from "./locales/vi/system-settings.json"; import viImportExport from "./locales/vi/import-export.json"; import viV3Capabilities from "./locales/vi/v3-capabilities.json"; import viBackup from "./locales/vi/backup.json"; +import viHooks from "./locales/vi/hooks.json"; // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; @@ -120,6 +122,7 @@ import zhSystemSettings from "./locales/zh/system-settings.json"; import zhImportExport from "./locales/zh/import-export.json"; import zhV3Capabilities from "./locales/zh/v3-capabilities.json"; import zhBackup from "./locales/zh/backup.json"; +import zhHooks from "./locales/zh/hooks.json"; const STORAGE_KEY = "goclaw:language"; @@ -141,6 +144,7 @@ const ns = [ "cli-credentials", "packages", "tenants", "system-settings", "import-export", "v3-capabilities", "backup", + "hooks", ] as const; i18n.use(initReactI18next).init({ @@ -162,6 +166,7 @@ i18n.use(initReactI18next).init({ "import-export": enImportExport, "v3-capabilities": enV3Capabilities, backup: enBackup, + hooks: enHooks, }, vi: { common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin, @@ -180,6 +185,7 @@ i18n.use(initReactI18next).init({ "import-export": viImportExport, "v3-capabilities": viV3Capabilities, backup: viBackup, + hooks: viHooks, }, zh: { common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin, @@ -198,6 +204,7 @@ i18n.use(initReactI18next).init({ "import-export": zhImportExport, "v3-capabilities": zhV3Capabilities, backup: zhBackup, + hooks: zhHooks, }, }, ns: [...ns], diff --git a/ui/web/src/i18n/locales/en/hooks.json b/ui/web/src/i18n/locales/en/hooks.json new file mode 100644 index 00000000..2c2b870d --- /dev/null +++ b/ui/web/src/i18n/locales/en/hooks.json @@ -0,0 +1,108 @@ +{ + "title": "Agent Hooks", + "subtitle": "Lifecycle hooks: block, observe, inject context across agent events", + "empty": "No hooks configured yet", + "nav": "Hooks", + "actions": { + "create": "Create hook", + "edit": "Edit", + "delete": "Delete", + "test": "Test", + "toggle": "Enable/Disable" + }, + "filters": { + "event": "Event", + "type": "Handler", + "scope": "Scope", + "enabled": "Status", + "all": "All" + }, + "table": { + "name": "Name", + "event": "Event", + "type": "Handler", + "scope": "Scope", + "matcher": "Matcher", + "enabled": "Enabled", + "actions": "Actions" + }, + "form": { + "title_create": "Create Hook", + "title_edit": "Edit Hook", + "event": "Event", + "handlerType": "Handler Type", + "scope": "Scope", + "matcher": "Matcher (regex)", + "matcherHint": "Applied to tool name before firing (optional except for prompt)", + "ifExpr": "Condition (CEL)", + "ifExprHint": "Evaluated against tool_input; cel-go syntax", + "timeout": "Timeout (ms)", + "onTimeout": "On Timeout", + "priority": "Priority", + "enabled": "Enabled", + "command": "Command", + "allowedEnvVars": "Allowed Env Vars", + "cwd": "Working Directory", + "url": "URL", + "method": "HTTP Method", + "headers": "Headers", + "bodyTemplate": "Body Template", + "promptTemplate": "Prompt Template", + "model": "Model", + "maxInvocationsPerTurn": "Max Invocations per Turn", + "save": "Save", + "cancel": "Cancel", + "commandDisabledTooltip": "Command hooks are only available on Lite edition" + }, + "tabs": { + "overview": "Overview", + "config": "Config", + "test": "Test", + "history": "History" + }, + "test": { + "title": "Test hook", + "sampleEvent": "Sample event", + "toolName": "Tool name", + "toolInput": "Tool input (JSON)", + "rawInput": "Raw input", + "fire": "Fire", + "firing": "Firing\u2026", + "decision": "Decision", + "duration": "Duration", + "stdout": "Stdout", + "stderr": "Stderr", + "statusCode": "Status code", + "updatedInput": "Updated input (diff)" + }, + "history": { + "title": "Execution history", + "empty": "No executions yet", + "phaseFourNote": "History pagination lands in phase 4", + "decision": "Decision", + "duration": "Duration", + "error": "Error", + "timestamp": "When" + }, + "toast": { + "created": "Hook created", + "updated": "Hook updated", + "deleted": "Hook deleted", + "toggled": "Hook toggled", + "failedCreate": "Failed to create hook", + "failedUpdate": "Failed to update hook", + "failedDelete": "Failed to delete hook", + "failedTest": "Test failed" + }, + "validation": { + "promptRequiresMatcher": "Prompt hooks require a matcher or if_expr", + "invalidRegex": "Invalid regex", + "promptTemplateRequired": "Prompt template is required" + }, + "decision": { + "allow": "Allow", + "block": "Block", + "error": "Error", + "timeout": "Timeout" + } +} diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index 2eae8876..7fd60df2 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -19,6 +19,7 @@ "contacts": "Contacts", "skills": "Skills", "cron": "Cron", + "hooks": "Hooks", "customTools": "Custom Tools", "builtinTools": "Built-in Tools", "mcpServers": "MCP Servers", diff --git a/ui/web/src/i18n/locales/vi/hooks.json b/ui/web/src/i18n/locales/vi/hooks.json new file mode 100644 index 00000000..0b27993a --- /dev/null +++ b/ui/web/src/i18n/locales/vi/hooks.json @@ -0,0 +1,108 @@ +{ + "title": "Agent Hooks", + "subtitle": "Hook vòng đời: chặn, quan sát, tiêm context qua các sự kiện agent", + "empty": "Chưa có hook nào được cấu hình", + "nav": "Hooks", + "actions": { + "create": "Tạo hook", + "edit": "Sửa", + "delete": "Xóa", + "test": "Kiểm tra", + "toggle": "Bật/Tắt" + }, + "filters": { + "event": "Sự kiện", + "type": "Loại xử lý", + "scope": "Phạm vi", + "enabled": "Trạng thái", + "all": "Tất cả" + }, + "table": { + "name": "Tên", + "event": "Sự kiện", + "type": "Loại xử lý", + "scope": "Phạm vi", + "matcher": "Bộ lọc", + "enabled": "Bật", + "actions": "Thao tác" + }, + "form": { + "title_create": "Tạo Hook", + "title_edit": "Sửa Hook", + "event": "Sự kiện", + "handlerType": "Loại xử lý", + "scope": "Phạm vi", + "matcher": "Bộ lọc (regex)", + "matcherHint": "Áp dụng cho tên công cụ trước khi kích hoạt (tùy chọn, bắt buộc với prompt)", + "ifExpr": "Điều kiện (CEL)", + "ifExprHint": "Đánh giá theo tool_input; cú pháp cel-go", + "timeout": "Thời gian chờ (ms)", + "onTimeout": "Khi hết thời gian", + "priority": "Độ ưu tiên", + "enabled": "Bật", + "command": "Lệnh", + "allowedEnvVars": "Biến môi trường được phép", + "cwd": "Thư mục làm việc", + "url": "URL", + "method": "Phương thức HTTP", + "headers": "Headers", + "bodyTemplate": "Mẫu body", + "promptTemplate": "Mẫu prompt", + "model": "Mô hình", + "maxInvocationsPerTurn": "Số lần gọi tối đa mỗi lượt", + "save": "Lưu", + "cancel": "Hủy", + "commandDisabledTooltip": "Hook command chỉ khả dụng trên phiên bản Lite" + }, + "tabs": { + "overview": "Tổng quan", + "config": "Cấu hình", + "test": "Kiểm tra", + "history": "Lịch sử" + }, + "test": { + "title": "Kiểm tra hook", + "sampleEvent": "Sự kiện mẫu", + "toolName": "Tên công cụ", + "toolInput": "Đầu vào công cụ (JSON)", + "rawInput": "Đầu vào thô", + "fire": "Kích hoạt", + "firing": "Đang kích hoạt\u2026", + "decision": "Quyết định", + "duration": "Thời gian", + "stdout": "Stdout", + "stderr": "Stderr", + "statusCode": "Mã trạng thái", + "updatedInput": "Đầu vào đã cập nhật (diff)" + }, + "history": { + "title": "Lịch sử thực thi", + "empty": "Chưa có lần thực thi nào", + "phaseFourNote": "Phân trang lịch sử sẽ có trong phase 4", + "decision": "Quyết định", + "duration": "Thời gian", + "error": "Lỗi", + "timestamp": "Thời điểm" + }, + "toast": { + "created": "Đã tạo hook", + "updated": "Đã cập nhật hook", + "deleted": "Đã xóa hook", + "toggled": "Đã chuyển đổi hook", + "failedCreate": "Không thể tạo hook", + "failedUpdate": "Không thể cập nhật hook", + "failedDelete": "Không thể xóa hook", + "failedTest": "Kiểm tra thất bại" + }, + "validation": { + "promptRequiresMatcher": "Hook prompt yêu cầu matcher hoặc if_expr", + "invalidRegex": "Regex không hợp lệ", + "promptTemplateRequired": "Mẫu prompt là bắt buộc" + }, + "decision": { + "allow": "Cho phép", + "block": "Chặn", + "error": "Lỗi", + "timeout": "Hết thời gian" + } +} diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index 72680094..7b45538b 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -19,6 +19,7 @@ "contacts": "Liên hệ", "skills": "Skill", "cron": "Tác vụ định kỳ", + "hooks": "Hooks", "customTools": "Công cụ tùy chỉnh", "builtinTools": "Công cụ tích hợp", "mcpServers": "Máy chủ MCP", diff --git a/ui/web/src/i18n/locales/zh/hooks.json b/ui/web/src/i18n/locales/zh/hooks.json new file mode 100644 index 00000000..5b338f88 --- /dev/null +++ b/ui/web/src/i18n/locales/zh/hooks.json @@ -0,0 +1,108 @@ +{ + "title": "Agent Hooks", + "subtitle": "生命周期钩子:在 Agent 事件中拦截、观察、注入上下文", + "empty": "暂无已配置的钩子", + "nav": "Hooks", + "actions": { + "create": "创建钩子", + "edit": "编辑", + "delete": "删除", + "test": "测试", + "toggle": "启用/禁用" + }, + "filters": { + "event": "事件", + "type": "处理器", + "scope": "范围", + "enabled": "状态", + "all": "全部" + }, + "table": { + "name": "名称", + "event": "事件", + "type": "处理器", + "scope": "范围", + "matcher": "匹配器", + "enabled": "已启用", + "actions": "操作" + }, + "form": { + "title_create": "创建钩子", + "title_edit": "编辑钩子", + "event": "事件", + "handlerType": "处理器类型", + "scope": "范围", + "matcher": "匹配器(正则)", + "matcherHint": "触发前应用于工具名称(可选,prompt 类型必填)", + "ifExpr": "条件(CEL)", + "ifExprHint": "对 tool_input 求值;使用 cel-go 语法", + "timeout": "超时(毫秒)", + "onTimeout": "超时处理", + "priority": "优先级", + "enabled": "启用", + "command": "命令", + "allowedEnvVars": "允许的环境变量", + "cwd": "工作目录", + "url": "URL", + "method": "HTTP 方法", + "headers": "请求头", + "bodyTemplate": "请求体模板", + "promptTemplate": "Prompt 模板", + "model": "模型", + "maxInvocationsPerTurn": "每轮最大调用次数", + "save": "保存", + "cancel": "取消", + "commandDisabledTooltip": "命令钩子仅在 Lite 版本中可用" + }, + "tabs": { + "overview": "概览", + "config": "配置", + "test": "测试", + "history": "历史" + }, + "test": { + "title": "测试钩子", + "sampleEvent": "示例事件", + "toolName": "工具名称", + "toolInput": "工具输入(JSON)", + "rawInput": "原始输入", + "fire": "触发", + "firing": "触发中\u2026", + "decision": "决策", + "duration": "耗时", + "stdout": "标准输出", + "stderr": "标准错误", + "statusCode": "状态码", + "updatedInput": "已更新输入(差异)" + }, + "history": { + "title": "执行历史", + "empty": "暂无执行记录", + "phaseFourNote": "历史分页将在第 4 阶段实现", + "decision": "决策", + "duration": "耗时", + "error": "错误", + "timestamp": "时间" + }, + "toast": { + "created": "钩子已创建", + "updated": "钩子已更新", + "deleted": "钩子已删除", + "toggled": "钩子已切换", + "failedCreate": "创建钩子失败", + "failedUpdate": "更新钩子失败", + "failedDelete": "删除钩子失败", + "failedTest": "测试失败" + }, + "validation": { + "promptRequiresMatcher": "Prompt 钩子需要 matcher 或 if_expr", + "invalidRegex": "无效的正则表达式", + "promptTemplateRequired": "Prompt 模板为必填项" + }, + "decision": { + "allow": "允许", + "block": "拦截", + "error": "错误", + "timeout": "超时" + } +} diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index 16047714..15e970e1 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -19,6 +19,7 @@ "chat": "对话", "config": "配置", "cron": "定时任务", + "hooks": "Hooks", "customTools": "自定义工具", "logs": "日志", "mcpServers": "MCP 服务器", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 87b833da..3b3835f8 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -51,6 +51,11 @@ export const queryKeys = { cron: { all: ["cron"] as const, }, + hooks: { + all: ["hooks"] as const, + detail: (id: string) => ["hooks", id] as const, + history: (id: string) => ["hooks", id, "history"] as const, + }, builtinTools: { all: ["builtinTools"] as const, }, diff --git a/ui/web/src/lib/routes.ts b/ui/web/src/lib/routes.ts index 4813bf92..ea070ee0 100644 --- a/ui/web/src/lib/routes.ts +++ b/ui/web/src/lib/routes.ts @@ -45,4 +45,6 @@ export const ROUTES = { SETUP: "/setup", BACKUP_RESTORE: "/backup-restore", SELECT_TENANT: "/select-tenant", + HOOKS: "/hooks", + HOOK_DETAIL: "/hooks/:id", } as const; diff --git a/ui/web/src/pages/hooks/__tests__/hooks-page.test.ts b/ui/web/src/pages/hooks/__tests__/hooks-page.test.ts new file mode 100644 index 00000000..ec8537f1 --- /dev/null +++ b/ui/web/src/pages/hooks/__tests__/hooks-page.test.ts @@ -0,0 +1,169 @@ +/** + * Smoke tests for the hooks page module contracts. + * + * @testing-library/react is not installed — tests cover pure logic, + * schema validation, and i18n key presence (mirrors stt-provider-form.test.tsx). + */ +import { describe, it, expect } from "vitest"; +import { hookFormSchema } from "@/schemas/hooks.schema"; + +// --- Zod schema validation --- + +describe("hookFormSchema — base cases", () => { + const base = { + event: "pre_tool_use" as const, + handler_type: "http" as const, + scope: "tenant" as const, + timeout_ms: 5000, + on_timeout: "block" as const, + priority: 100, + enabled: true, + method: "POST" as const, + url: "https://hooks.example.com/test", + }; + + it("accepts valid http hook", () => { + const result = hookFormSchema.safeParse(base); + expect(result.success).toBe(true); + }); + + it("accepts valid prompt hook with matcher", () => { + const result = hookFormSchema.safeParse({ + ...base, + handler_type: "prompt", + matcher: "^bash$", + prompt_template: "Evaluate the tool call.", + }); + expect(result.success).toBe(true); + }); + + it("rejects prompt hook without matcher or if_expr", () => { + const result = hookFormSchema.safeParse({ + ...base, + handler_type: "prompt", + prompt_template: "Evaluate the tool call.", + matcher: "", + if_expr: "", + }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((e) => e.path.join(".")); + expect(paths).toContain("matcher"); + } + }); + + it("rejects prompt hook without prompt_template", () => { + const result = hookFormSchema.safeParse({ + ...base, + handler_type: "prompt", + matcher: "^bash$", + }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((e) => e.path.join(".")); + expect(paths).toContain("prompt_template"); + } + }); + + it("rejects invalid regex in matcher", () => { + const result = hookFormSchema.safeParse({ + ...base, + matcher: "[invalid(regex", + }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((e) => e.path.join(".")); + expect(paths).toContain("matcher"); + } + }); + + it("accepts valid regex in matcher", () => { + const result = hookFormSchema.safeParse({ + ...base, + matcher: "^(bash|python)$", + }); + expect(result.success).toBe(true); + }); +}); + +// --- i18n key contracts --- + +describe("hooks i18n key contracts", () => { + it("en locale has all top-level required keys", async () => { + const en = await import("@/i18n/locales/en/hooks.json"); + const data = en as unknown as Record; + for (const key of ["title", "subtitle", "empty", "nav", "actions", "filters", "table", "form", "tabs", "test", "history", "toast", "validation", "decision"]) { + expect(data[key], `missing key: ${key}`).toBeDefined(); + } + }); + + it("vi locale has same top-level keys as en", async () => { + const en = await import("@/i18n/locales/en/hooks.json"); + const vi = await import("@/i18n/locales/vi/hooks.json"); + const enKeys = Object.keys(en as unknown as Record).sort(); + const viKeys = Object.keys(vi as unknown as Record).sort(); + expect(viKeys).toEqual(enKeys); + }); + + it("zh locale has same top-level keys as en", async () => { + const en = await import("@/i18n/locales/en/hooks.json"); + const zh = await import("@/i18n/locales/zh/hooks.json"); + const enKeys = Object.keys(en as unknown as Record).sort(); + const zhKeys = Object.keys(zh as unknown as Record).sort(); + expect(zhKeys).toEqual(enKeys); + }); + + it("decision keys exist in all locales", async () => { + for (const locale of ["en", "vi", "zh"]) { + const mod = await import(`@/i18n/locales/${locale}/hooks.json`); + const data = mod as unknown as Record>; + expect(data.decision?.allow).toBeTruthy(); + expect(data.decision?.block).toBeTruthy(); + expect(data.decision?.error).toBeTruthy(); + expect(data.decision?.timeout).toBeTruthy(); + } + }); + + it("toast keys exist in en", async () => { + const en = await import("@/i18n/locales/en/hooks.json"); + const data = en as unknown as Record>; + for (const key of ["created", "updated", "deleted", "toggled", "failedCreate", "failedUpdate", "failedDelete", "failedTest"]) { + expect(data.toast?.[key], `missing toast.${key}`).toBeTruthy(); + } + }); +}); + +// --- buildConfig helper (re-tested inline) --- + +describe("hook config builder logic", () => { + it("prompt type config has prompt_template and model", () => { + const config = { + prompt_template: "Evaluate this.", + model: "haiku", + max_invocations_per_turn: 5, + }; + expect(config.prompt_template).toBeTruthy(); + expect(config.model).toBe("haiku"); + expect(config.max_invocations_per_turn).toBe(5); + }); + + it("allowed event values are exhaustive", () => { + const events = [ + "session_start", "user_prompt_submit", "pre_tool_use", + "post_tool_use", "stop", "subagent_start", "subagent_stop", + ]; + expect(events).toHaveLength(7); + for (const ev of events) { + const result = hookFormSchema.safeParse({ + event: ev, + handler_type: "http", + scope: "tenant", + timeout_ms: 5000, + on_timeout: "block", + priority: 100, + enabled: true, + }); + expect(result.success, `event "${ev}" should be valid`).toBe(true); + } + }); +}); diff --git a/ui/web/src/pages/hooks/components/hook-diff-viewer.tsx b/ui/web/src/pages/hooks/components/hook-diff-viewer.tsx new file mode 100644 index 00000000..ed91462f --- /dev/null +++ b/ui/web/src/pages/hooks/components/hook-diff-viewer.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import hljs from "highlight.js/lib/core"; +import jsonLang from "highlight.js/lib/languages/json"; +import { useTranslation } from "react-i18next"; + +hljs.registerLanguage("json", jsonLang); + +interface HookDiffViewerProps { + before: Record; + after: Record; +} + +function highlight(code: string): string { + try { + return hljs.highlight(code, { language: "json" }).value; + } catch { + return code; + } +} + +function computeDeltaLines( + beforeLines: string[], + afterLines: string[], +): { before: Array<{ text: string; changed: boolean }>; after: Array<{ text: string; changed: boolean }> } { + const maxLen = Math.max(beforeLines.length, afterLines.length); + const beforeOut: Array<{ text: string; changed: boolean }> = []; + const afterOut: Array<{ text: string; changed: boolean }> = []; + + for (let i = 0; i < maxLen; i++) { + const b = beforeLines[i] ?? ""; + const a = afterLines[i] ?? ""; + const changed = b !== a; + beforeOut.push({ text: b, changed }); + afterOut.push({ text: a, changed }); + } + return { before: beforeOut, after: afterOut }; +} + +export function HookDiffViewer({ before, after }: HookDiffViewerProps) { + const { t } = useTranslation("hooks"); + + const beforeJson = useMemo(() => JSON.stringify(before, null, 2), [before]); + const afterJson = useMemo(() => JSON.stringify(after, null, 2), [after]); + + const isIdentical = beforeJson === afterJson; + + const { before: beforeLines, after: afterLines } = useMemo(() => { + const bl = beforeJson.split("\n"); + const al = afterJson.split("\n"); + return computeDeltaLines(bl, al); + }, [beforeJson, afterJson]); + + if (isIdentical) { + return ( +
+ {t("test.updatedInput")} — no changes +
+ ); + } + + return ( +
+

{t("test.updatedInput")}

+
+ {/* Before */} +
+
Before
+
+ {beforeLines.map((line, i) => ( +
+ ))} +
+
+ {/* After */} +
+
After
+
+ {afterLines.map((line, i) => ( +
+ ))} +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx new file mode 100644 index 00000000..cf602b80 --- /dev/null +++ b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx @@ -0,0 +1,333 @@ +import { useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; +import { + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { hookFormSchema, type HookFormData } from "@/schemas/hooks.schema"; +import type { HookConfig } from "@/hooks/use-hooks"; + +// TODO: replace with real edition context once EditionContext is available +const IS_LITE_EDITION = false; + +const HOOK_EVENTS = [ + "session_start", "user_prompt_submit", "pre_tool_use", + "post_tool_use", "stop", "subagent_start", "subagent_stop", +] as const; + +interface HookFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: HookFormData) => Promise; + initial?: HookConfig | null; +} + +export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFormDialogProps) { + const { t } = useTranslation("hooks"); + + const { + register, control, handleSubmit, watch, reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(hookFormSchema), + defaultValues: { + event: "pre_tool_use", + handler_type: "http", + scope: "tenant", + timeout_ms: 5000, + on_timeout: "block", + priority: 100, + enabled: true, + method: "POST", + max_invocations_per_turn: 5, + }, + }); + + const handlerType = watch("handler_type"); + + useEffect(() => { + if (open) { + if (initial) { + const cfg = initial.config as Record; + reset({ + event: initial.event as HookFormData["event"], + handler_type: initial.handler_type, + scope: initial.scope, + matcher: initial.matcher ?? "", + if_expr: initial.if_expr ?? "", + timeout_ms: initial.timeout_ms, + on_timeout: initial.on_timeout, + priority: initial.priority, + enabled: initial.enabled, + command: (cfg.command as string) ?? "", + allowed_env_vars: ((cfg.allowed_env_vars as string[]) ?? []).join(","), + cwd: (cfg.cwd as string) ?? "", + url: (cfg.url as string) ?? "", + method: (cfg.method as HookFormData["method"]) ?? "POST", + headers: cfg.headers ? JSON.stringify(cfg.headers) : "", + body_template: (cfg.body_template as string) ?? "", + prompt_template: (cfg.prompt_template as string) ?? "", + model: (cfg.model as string) ?? "", + max_invocations_per_turn: (cfg.max_invocations_per_turn as number) ?? 5, + }); + } else { + reset(); + } + } + }, [open, initial, reset]); + + const onFormSubmit = async (data: HookFormData) => { + await onSubmit(data); + onOpenChange(false); + }; + + return ( + + + + {initial ? t("form.title_edit") : t("form.title_create")} + + +
+ {/* Event */} +
+ + ( + + )} /> +
+ + {/* Handler type */} +
+ + ( + + {(["command", "http", "prompt"] as const).map((ht) => { + const disabled = ht === "command" && !IS_LITE_EDITION; + const radio = ( +
+ + +
+ ); + if (disabled) { + return ( + + + {radio} + {t("form.commandDisabledTooltip")} + + + ); + } + return radio; + })} +
+ )} /> +
+ + {/* Scope */} +
+ + ( + + )} /> +
+ + {/* Matcher */} +
+ + + {errors.matcher + ?

{errors.matcher.message}

+ :

{t("form.matcherHint")}

+ } +
+ + {/* if_expr */} +
+ + +

{t("form.ifExprHint")}

+
+ + {/* Handler-specific sub-forms */} + {handlerType === "command" && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ )} + + {handlerType === "http" && ( +
+
+ + + {errors.url &&

{errors.url.message}

} +
+
+ + ( + + )} /> +
+
+ +