diff --git a/ui/web/index.html b/ui/web/index.html index a9c03efd..1326be00 100644 --- a/ui/web/index.html +++ b/ui/web/index.html @@ -4,6 +4,20 @@ + + diff --git a/ui/web/package.json b/ui/web/package.json index d742e19a..3100368c 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -37,6 +37,7 @@ "jszip": "^3.10.1", "lucide-react": "^1.7.0", "mermaid": "^11.14.0", + "prismjs": "^1.30.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -44,6 +45,7 @@ "react-i18next": "^17.0.1", "react-markdown": "^10.1.0", "react-router": "^7.13.2", + "react-simple-code-editor": "^0.14.1", "recharts": "^3.8.1", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", @@ -59,6 +61,7 @@ "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.2", "@types/mdast": "^4.0.4", + "@types/prismjs": "^1.26.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", @@ -77,5 +80,10 @@ "vitest": "^4.1.4", "ws": "^8.20.0" }, - "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a" + "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a", + "pnpm": { + "overrides": { + "prismjs": "^1.30.0" + } + } } diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 4c9cced6..6738c83e 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: mermaid: specifier: ^11.14.0 version: 11.14.0 + prismjs: + specifier: ^1.30.0 + version: 1.30.0 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -98,6 +101,9 @@ importers: react-router: specifier: ^7.13.2 version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-simple-code-editor: + specifier: ^0.14.1 + version: 0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) @@ -138,6 +144,9 @@ importers: '@types/mdast': specifier: ^4.0.4 version: 4.0.4 + '@types/prismjs': + specifier: ^1.26.6 + version: 1.26.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -1553,6 +1562,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2798,6 +2810,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -2899,6 +2915,12 @@ packages: react-dom: optional: true + react-simple-code-editor@0.14.1: + resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4808,6 +4830,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/prismjs@1.26.6': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -6354,6 +6378,8 @@ snapshots: prelude-ls@1.2.1: {} + prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} property-information@7.1.0: {} @@ -6499,6 +6525,11 @@ snapshots: optionalDependencies: react-dom: 19.2.4(react@19.2.4) + react-simple-code-editor@0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: get-nonce: 1.0.1 diff --git a/ui/web/src/hooks/use-hooks.ts b/ui/web/src/hooks/use-hooks.ts index 8d315ae7..243427d9 100644 --- a/ui/web/src/hooks/use-hooks.ts +++ b/ui/web/src/hooks/use-hooks.ts @@ -10,7 +10,7 @@ export interface HookConfig { tenant_id: string; agent_id?: string | null; event: string; - handler_type: "command" | "http" | "prompt"; + handler_type: "command" | "http" | "prompt" | "script"; scope: "global" | "tenant" | "agent"; config: Record; matcher?: string; @@ -20,7 +20,7 @@ export interface HookConfig { priority: number; enabled: boolean; version: number; - source: "ui" | "api" | "seed"; + source: "ui" | "api" | "seed" | "builtin"; metadata: Record; created_by?: string; created_at: string; diff --git a/ui/web/src/i18n/locales/en/hooks.json b/ui/web/src/i18n/locales/en/hooks.json index 552c7eff..2e41bc48 100644 --- a/ui/web/src/i18n/locales/en/hooks.json +++ b/ui/web/src/i18n/locales/en/hooks.json @@ -24,7 +24,8 @@ "scope": "Scope", "matcher": "Matcher", "enabled": "Enabled", - "actions": "Actions" + "actions": "Actions", + "systemBadge": "System" }, "form": { "title_create": "Create Hook", @@ -47,6 +48,11 @@ "promptTemplate": "Prompt Template", "model": "Model", "maxInvocationsPerTurn": "Max Invocations per Turn", + "scriptSource": "Script source (ES5.1 JavaScript)", + "scriptSchemaHelp": "Export function handle(event) returning {decision: \"allow\"|\"block\", reason?: string, updatedInput?: object}. Limit 32 KiB.", + "compileError": "Syntax error at line {{line}}, column {{col}}", + "builtinReadonly": "Built-in hook — only the enabled toggle can be changed.", + "saveToggle": "Save toggle", "save": "Save", "cancel": "Cancel" }, @@ -66,6 +72,7 @@ "firing": "Firing\u2026", "decision": "Decision", "duration": "Duration", + "reason": "Reason", "stdout": "Stdout", "stderr": "Stderr", "statusCode": "Status code", @@ -93,7 +100,8 @@ "validation": { "promptRequiresMatcher": "Prompt hooks require a matcher or if_expr", "invalidRegex": "Invalid regex", - "promptTemplateRequired": "Prompt template is required" + "promptTemplateRequired": "Prompt template is required", + "scriptSourceRequired": "Script source is required" }, "decision": { "allow": "Allow", diff --git a/ui/web/src/i18n/locales/vi/hooks.json b/ui/web/src/i18n/locales/vi/hooks.json index 795b7d86..8d3be58f 100644 --- a/ui/web/src/i18n/locales/vi/hooks.json +++ b/ui/web/src/i18n/locales/vi/hooks.json @@ -24,7 +24,8 @@ "scope": "Phạm vi", "matcher": "Bộ lọc", "enabled": "Bật", - "actions": "Thao tác" + "actions": "Thao tác", + "systemBadge": "Hệ thống" }, "form": { "title_create": "Tạo Hook", @@ -47,6 +48,11 @@ "promptTemplate": "Mẫu prompt", "model": "Mô hình", "maxInvocationsPerTurn": "Số lần gọi tối đa mỗi lượt", + "scriptSource": "Mã nguồn script (ES5.1 JavaScript)", + "scriptSchemaHelp": "Hàm handle(event) trả về {decision: \"allow\"|\"block\", reason?: string, updatedInput?: object}. Giới hạn 32 KiB.", + "compileError": "Lỗi cú pháp tại dòng {{line}}, cột {{col}}", + "builtinReadonly": "Hook dựng sẵn — chỉ có thể bật/tắt, không chỉnh sửa nội dung.", + "saveToggle": "Lưu bật/tắt", "save": "Lưu", "cancel": "Hủy" }, @@ -66,6 +72,7 @@ "firing": "Đang kích hoạt\u2026", "decision": "Quyết định", "duration": "Thời gian", + "reason": "Lý do", "stdout": "Stdout", "stderr": "Stderr", "statusCode": "Mã trạng thái", @@ -93,7 +100,8 @@ "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" + "promptTemplateRequired": "Mẫu prompt là bắt buộc", + "scriptSourceRequired": "Mã nguồn script là bắt buộc" }, "decision": { "allow": "Cho phép", diff --git a/ui/web/src/i18n/locales/zh/hooks.json b/ui/web/src/i18n/locales/zh/hooks.json index d1615957..928fd681 100644 --- a/ui/web/src/i18n/locales/zh/hooks.json +++ b/ui/web/src/i18n/locales/zh/hooks.json @@ -24,7 +24,8 @@ "scope": "范围", "matcher": "匹配器", "enabled": "已启用", - "actions": "操作" + "actions": "操作", + "systemBadge": "系统" }, "form": { "title_create": "创建钩子", @@ -47,6 +48,11 @@ "promptTemplate": "Prompt 模板", "model": "模型", "maxInvocationsPerTurn": "每轮最大调用次数", + "scriptSource": "脚本源码(ES5.1 JavaScript)", + "scriptSchemaHelp": "函数 handle(event) 返回 {decision: \"allow\"|\"block\", reason?: string, updatedInput?: object}。限制 32 KiB。", + "compileError": "语法错误,位于第 {{line}} 行第 {{col}} 列", + "builtinReadonly": "内置钩子 — 仅可切换启用状态,不能修改内容。", + "saveToggle": "保存开关", "save": "保存", "cancel": "取消" }, @@ -66,6 +72,7 @@ "firing": "触发中\u2026", "decision": "决策", "duration": "耗时", + "reason": "原因", "stdout": "标准输出", "stderr": "标准错误", "statusCode": "状态码", @@ -93,7 +100,8 @@ "validation": { "promptRequiresMatcher": "Prompt 钩子需要 matcher 或 if_expr", "invalidRegex": "无效的正则表达式", - "promptTemplateRequired": "Prompt 模板为必填项" + "promptTemplateRequired": "Prompt 模板为必填项", + "scriptSourceRequired": "脚本源码为必填项" }, "decision": { "allow": "允许", diff --git a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx index 6fc50321..361153ae 100644 --- a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx +++ b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx @@ -15,6 +15,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { hookFormSchema, type HookFormData } from "@/schemas/hooks.schema"; import type { HookConfig } from "@/hooks/use-hooks"; import { useAuthStore } from "@/stores/use-auth-store"; +import { ScriptEditor } from "./script-editor"; const HOOK_EVENTS = [ "session_start", "user_prompt_submit", "pre_tool_use", @@ -55,6 +56,10 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo }); const handlerType = watch("handler_type"); + // Builtin rows (Phase 04/05) ship with source='builtin'. UI + backend agree: + // only `enabled` is mutable. All other inputs render as read-only, and the + // Save button label changes to reflect the narrowed scope. + const isBuiltin = initial?.source === "builtin"; useEffect(() => { if (open) { @@ -81,6 +86,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo prompt_template: (cfg.prompt_template as string) ?? "", model: (cfg.model as string) ?? "", max_invocations_per_turn: (cfg.max_invocations_per_turn as number) ?? 5, + script_source: (cfg.source as string) ?? "", }); } else { reset(); @@ -101,11 +107,17 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
+ {isBuiltin && ( +
+ {t("form.builtinReadonly")} +
+ )} + {/* Event */}
( - @@ -119,12 +131,18 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
{/* Handler type — `command` intentionally absent post-Wave-1. Lite users keep - existing rows but cannot create new ones via UI (DB/CLI path only). */} + existing rows but cannot create new ones via UI (DB/CLI path only). + `script` added in Phase 06 (goja sandbox; builtin PII redactor = Phase 05). */}
( - - {(["http", "prompt"] as const).map((ht) => ( + + {(["script", "http", "prompt"] as const).map((ht) => (