mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
feat(hooks): name field + beta explainer modal + UI polish
- PG migration 000054 + SQLite v21→v22: add nullable name column - HookConfig.Name in Go struct, PG/SQLite scan/insert, WS handlers - Builtin seed writes spec ID as hook name - UI: name input in create/edit form, displayed in hook list row - Beta card: "Learn more" button opens modal explaining hooks×skills×MCP - Default handler_type changed from http to script - Script editor section: max-h-[50vh] with scroll, border always visible - i18n: all new keys in en/vi/zh
This commit is contained in:
1 parent
c0acfa1bd9
commit
df82fafb28
19 files changed
+204
-70
No files matched your search
@@ -239,6 +239,9 @@ func (m *HookMethods) handleUpdate(ctx context.Context, client *gateway.Client,
|
||||
// columns). Used pre-validation so the Validate() contract runs on the
|
||||
// full, merged config exactly as it will exist after Update commits.
|
||||
func applyHookPatch(cur hooks.HookConfig, p map[string]any) hooks.HookConfig {
|
||||
if v, ok := p["name"].(string); ok {
|
||||
cur.Name = v
|
||||
}
|
||||
if v, ok := p["event"].(string); ok && v != "" {
|
||||
cur.Event = hooks.HookEvent(v)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ func reconcile(
|
||||
cfg := hooks.HookConfig{
|
||||
ID: id,
|
||||
TenantID: hooks.SentinelTenantID,
|
||||
Name: s.ID,
|
||||
Event: hooks.HookEvent(event),
|
||||
HandlerType: hooks.HandlerScript,
|
||||
Scope: hooks.Scope(s.Scope),
|
||||
|
||||
@@ -164,6 +164,7 @@ type HookConfig struct {
|
||||
Event HookEvent `json:"event"`
|
||||
HandlerType HandlerType `json:"handler_type"`
|
||||
Scope Scope `json:"scope"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// Config holds handler-specific options (command path, HTTP URL, prompt template).
|
||||
Config map[string]any `json:"config"`
|
||||
Matcher string `json:"matcher,omitempty"`
|
||||
|
||||
@@ -67,24 +67,27 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU
|
||||
tid = tenantIDForInsert(ctx)
|
||||
}
|
||||
|
||||
var matcher, ifExpr *string
|
||||
var matcher, ifExpr, name *string
|
||||
if cfg.Matcher != "" {
|
||||
matcher = &cfg.Matcher
|
||||
}
|
||||
if cfg.IfExpr != "" {
|
||||
ifExpr = &cfg.IfExpr
|
||||
}
|
||||
if cfg.Name != "" {
|
||||
name = &cfg.Name
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_hooks
|
||||
(id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,1,$14,$15,$16,$17,$17)`,
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,1,$14,$15,$16,$17,$18,$18)`,
|
||||
id, tid, cfg.AgentID, string(cfg.Scope), string(cfg.Event), string(cfg.HandlerType),
|
||||
cfgJSON, matcher, ifExpr, cfg.TimeoutMS, string(cfg.OnTimeout),
|
||||
cfg.Priority, cfg.Enabled, string(cfg.Source), metaJSON, cfg.CreatedBy,
|
||||
cfg.Priority, cfg.Enabled, string(cfg.Source), metaJSON, name, cfg.CreatedBy,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -100,7 +103,7 @@ func (s *PGHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookCon
|
||||
q := `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks WHERE id = $1`
|
||||
args := []any{id}
|
||||
@@ -132,7 +135,7 @@ func (s *PGHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.HookCon
|
||||
func (s *PGHookStore) List(ctx context.Context, filter hooks.ListFilter) ([]hooks.HookConfig, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at FROM agent_hooks WHERE 1=1`
|
||||
var args []any
|
||||
n := 1
|
||||
@@ -342,7 +345,7 @@ func (s *PGHookStore) ResolveForEvent(ctx context.Context, event hooks.Event) ([
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks
|
||||
WHERE enabled = TRUE AND event = $1
|
||||
@@ -449,7 +452,7 @@ func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
|
||||
scope, event string
|
||||
handlerType, onTimeout string
|
||||
source string
|
||||
matcher, ifExpr sql.NullString
|
||||
matcher, ifExpr, name sql.NullString
|
||||
cfgJSON, metaJSON []byte
|
||||
)
|
||||
err := row.Scan(
|
||||
@@ -458,7 +461,7 @@ func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
|
||||
&cfgJSON, &matcher, &ifExpr,
|
||||
&cfg.TimeoutMS, &onTimeout,
|
||||
&cfg.Priority, &cfg.Enabled, &cfg.Version,
|
||||
&source, &metaJSON, &createdBy,
|
||||
&source, &metaJSON, &name, &createdBy,
|
||||
&cfg.CreatedAt, &cfg.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -479,6 +482,9 @@ func scanHookPGRow(row pgRowScanner) (*hooks.HookConfig, error) {
|
||||
if ifExpr.Valid {
|
||||
cfg.IfExpr = ifExpr.String
|
||||
}
|
||||
if name.Valid {
|
||||
cfg.Name = name.String
|
||||
}
|
||||
|
||||
if len(cfgJSON) > 0 {
|
||||
_ = json.Unmarshal(cfgJSON, &cfg.Config)
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *SqliteHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uui
|
||||
tid = tenantIDForInsert(ctx)
|
||||
}
|
||||
|
||||
var agentID, createdBy, matcher, ifExpr *string
|
||||
var agentID, createdBy, matcher, ifExpr, name *string
|
||||
if cfg.AgentID != nil {
|
||||
s := cfg.AgentID.String()
|
||||
agentID = &s
|
||||
@@ -83,20 +83,23 @@ func (s *SqliteHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uui
|
||||
if cfg.IfExpr != "" {
|
||||
ifExpr = &cfg.IfExpr
|
||||
}
|
||||
if cfg.Name != "" {
|
||||
name = &cfg.Name
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_hooks
|
||||
(id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?,?)`,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?,?,?)`,
|
||||
id.String(), tid.String(), agentID,
|
||||
string(cfg.Scope), string(cfg.Event), string(cfg.HandlerType),
|
||||
string(cfgJSON), matcher, ifExpr,
|
||||
cfg.TimeoutMS, string(cfg.OnTimeout),
|
||||
cfg.Priority, cfg.Enabled,
|
||||
string(cfg.Source), string(metaJSON), createdBy,
|
||||
string(cfg.Source), string(metaJSON), name, createdBy,
|
||||
now, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -112,7 +115,7 @@ func (s *SqliteHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.Hoo
|
||||
q := `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks WHERE id = ?`
|
||||
args := []any{id.String()}
|
||||
@@ -143,7 +146,7 @@ func (s *SqliteHookStore) GetByID(ctx context.Context, id uuid.UUID) (*hooks.Hoo
|
||||
func (s *SqliteHookStore) List(ctx context.Context, filter hooks.ListFilter) ([]hooks.HookConfig, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at FROM agent_hooks WHERE 1=1`
|
||||
var args []any
|
||||
|
||||
@@ -343,7 +346,7 @@ func (s *SqliteHookStore) ResolveForEvent(ctx context.Context, event hooks.Event
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, scope, event, handler_type,
|
||||
config, matcher, if_expr, timeout_ms, on_timeout,
|
||||
priority, enabled, version, source, metadata, created_by,
|
||||
priority, enabled, version, source, metadata, name, created_by,
|
||||
created_at, updated_at
|
||||
FROM agent_hooks
|
||||
WHERE enabled = 1 AND event = ?
|
||||
@@ -455,7 +458,7 @@ func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
|
||||
scope, event string
|
||||
handlerType, onTimeout string
|
||||
source string
|
||||
matcher, ifExpr sql.NullString
|
||||
matcher, ifExpr, name sql.NullString
|
||||
cfgStr, metaStr string
|
||||
enabledInt int
|
||||
createdAt, updatedAt sqliteTime
|
||||
@@ -466,7 +469,7 @@ func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
|
||||
&cfgStr, &matcher, &ifExpr,
|
||||
&cfg.TimeoutMS, &onTimeout,
|
||||
&cfg.Priority, &enabledInt, &cfg.Version,
|
||||
&source, &metaStr, &createdByStr,
|
||||
&source, &metaStr, &name, &createdByStr,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -505,6 +508,9 @@ func scanHookSQLiteRow(row sqliteRowScanner) (*hooks.HookConfig, error) {
|
||||
if ifExpr.Valid {
|
||||
cfg.IfExpr = ifExpr.String
|
||||
}
|
||||
if name.Valid {
|
||||
cfg.Name = name.String
|
||||
}
|
||||
|
||||
if cfgStr != "" {
|
||||
_ = json.Unmarshal([]byte(cfgStr), &cfg.Config)
|
||||
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 21
|
||||
const SchemaVersion = 22
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -468,6 +468,9 @@ WHERE context_pruning IS NOT NULL
|
||||
// rebuild runs in rebuildAgentHooksV21 triggered after the v20→21 migration
|
||||
// tx commits (parallel to backfillV16 at EnsureSchema:677).
|
||||
20: `SELECT 1;`,
|
||||
|
||||
// Version 21 → 22: add name column to agent_hooks.
|
||||
21: `ALTER TABLE agent_hooks ADD COLUMN name TEXT;`,
|
||||
}
|
||||
|
||||
// addAgentHooksTables is the SQLite incremental migration for schema v19 → v20.
|
||||
|
||||
@@ -1595,6 +1595,7 @@ CREATE TABLE IF NOT EXISTS agent_hooks (
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'ui' CHECK (source IN ('ui', 'api', 'seed', 'builtin')),
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
name TEXT,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 53
|
||||
const RequiredSchemaVersion uint = 54
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 000054 down — Remove name column from agent_hooks.
|
||||
ALTER TABLE agent_hooks DROP COLUMN IF EXISTS name;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 000054 — Add `name` column to agent_hooks.
|
||||
--
|
||||
-- User-facing label for hooks. Nullable so existing rows don't break.
|
||||
-- Builtin hooks set name from the YAML spec at seed time.
|
||||
|
||||
ALTER TABLE agent_hooks ADD COLUMN IF NOT EXISTS name VARCHAR(255);
|
||||
@@ -9,6 +9,7 @@ export interface HookConfig {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
agent_id?: string | null;
|
||||
name?: string;
|
||||
event: string;
|
||||
handler_type: "command" | "http" | "prompt" | "script";
|
||||
scope: "global" | "tenant" | "agent";
|
||||
|
||||
@@ -13,7 +13,17 @@
|
||||
"howItWorksBody2": "Script runs sandboxed JavaScript. HTTP posts to your webhook. Prompt asks an LLM. Built-in hooks (System badge) ship with GoClaw and only the enabled toggle is editable.",
|
||||
"howItWorksTitle3": "3. Test before enabling",
|
||||
"howItWorksBody3": "Use Run hook with a sample event to preview the decision, the duration, and any input mutations — nothing touches a real session until enabled.",
|
||||
"collapse": "Hide"
|
||||
"collapse": "Hide",
|
||||
"learnMore": "How hooks work with Skills & MCP",
|
||||
"modalTitle": "Hooks + Skills + MCP",
|
||||
"modalIntro": "Hooks integrate with GoClaw's Skill system and MCP (Model Context Protocol) servers to give you fine-grained control over the agent pipeline.",
|
||||
"modalSkillTitle": "Hooks × Skills",
|
||||
"modalSkillBody": "When a skill is invoked, the agent fires pre_tool_use and post_tool_use events. A hook on pre_tool_use can inspect the skill name and arguments, then allow, block, or rewrite them before execution. Post-tool hooks observe results for logging or chaining.",
|
||||
"modalMcpTitle": "Hooks × MCP Servers",
|
||||
"modalMcpBody": "MCP tools registered from external servers appear as regular tools in the agent pipeline. Hooks fire on the same pre_tool_use / post_tool_use events — no special wiring needed. Use a matcher regex to target specific MCP tool names (e.g. mcp__.*).",
|
||||
"modalBuiltinTitle": "Built-in hooks (System)",
|
||||
"modalBuiltinBody": "Built-in hooks like the PII Redactor run as sandboxed scripts with elevated privileges: they can rewrite user input (rawInput) and tool arguments before the agent sees them. User-created scripts cannot — this is enforced at the dispatcher level.",
|
||||
"modalClose": "Got it"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create hook",
|
||||
@@ -42,6 +52,8 @@
|
||||
"form": {
|
||||
"title_create": "Create Hook",
|
||||
"title_edit": "Edit Hook",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Block sensitive tools",
|
||||
"event": "Event",
|
||||
"handlerType": "Handler Type",
|
||||
"scope": "Scope",
|
||||
|
||||
@@ -13,7 +13,17 @@
|
||||
"howItWorksBody2": "Script chạy JavaScript trong sandbox. HTTP gọi webhook của bạn. Prompt hỏi LLM. Hook dựng sẵn (badge Hệ thống) do GoClaw cung cấp — chỉ chỉnh được toggle bật/tắt.",
|
||||
"howItWorksTitle3": "3. Test trước khi bật",
|
||||
"howItWorksBody3": "Dùng Run hook với sự kiện mẫu để xem trước quyết định, thời gian chạy và mutation đầu vào — không động vào phiên thật cho tới khi bật.",
|
||||
"collapse": "Ẩn"
|
||||
"collapse": "Ẩn",
|
||||
"learnMore": "Hooks hoạt động với Skills & MCP như thế nào",
|
||||
"modalTitle": "Hooks + Skills + MCP",
|
||||
"modalIntro": "Hooks tích hợp với hệ thống Skill và MCP (Model Context Protocol) để cho bạn kiểm soát chi tiết pipeline agent.",
|
||||
"modalSkillTitle": "Hooks × Skills",
|
||||
"modalSkillBody": "Khi skill được gọi, agent phát sự kiện pre_tool_use và post_tool_use. Hook trên pre_tool_use có thể kiểm tra tên skill và tham số, rồi cho phép, chặn hoặc viết lại trước khi thực thi. Hook post-tool quan sát kết quả để ghi log hoặc chuỗi xử lý.",
|
||||
"modalMcpTitle": "Hooks × MCP Servers",
|
||||
"modalMcpBody": "MCP tools từ server bên ngoài xuất hiện như tool thường trong pipeline agent. Hooks vẫn phát trên pre_tool_use / post_tool_use — không cần cấu hình thêm. Dùng matcher regex để nhắm MCP tool cụ thể (ví dụ: mcp__.*).",
|
||||
"modalBuiltinTitle": "Hook dựng sẵn (Hệ thống)",
|
||||
"modalBuiltinBody": "Hook dựng sẵn như PII Redactor chạy script sandbox với quyền cao: có thể viết lại đầu vào người dùng (rawInput) và tham số tool trước khi agent nhìn thấy. Script do người dùng tạo không được phép — bảo vệ ở tầng dispatcher.",
|
||||
"modalClose": "Đã hiểu"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Tạo hook",
|
||||
@@ -42,6 +52,8 @@
|
||||
"form": {
|
||||
"title_create": "Tạo Hook",
|
||||
"title_edit": "Sửa Hook",
|
||||
"name": "Tên",
|
||||
"namePlaceholder": "VD: Chặn tool nhạy cảm",
|
||||
"event": "Sự kiện",
|
||||
"handlerType": "Loại xử lý",
|
||||
"scope": "Phạm vi",
|
||||
|
||||
@@ -13,7 +13,17 @@
|
||||
"howItWorksBody2": "Script 在沙箱中运行 JavaScript。HTTP 调用你的 Webhook。Prompt 询问 LLM。内置钩子(系统徽章)由 GoClaw 提供 — 仅可切换启用状态。",
|
||||
"howItWorksTitle3": "3. 启用前先测试",
|
||||
"howItWorksBody3": "使用「运行钩子」配合示例事件预览决策、耗时及输入修改 — 启用前不会触及任何真实会话。",
|
||||
"collapse": "收起"
|
||||
"collapse": "收起",
|
||||
"learnMore": "Hooks 如何与 Skills 和 MCP 配合",
|
||||
"modalTitle": "Hooks + Skills + MCP",
|
||||
"modalIntro": "Hooks 与 GoClaw 的 Skill 系统和 MCP(Model Context Protocol)服务器集成,为你提供对 Agent 管道的精细控制。",
|
||||
"modalSkillTitle": "Hooks × Skills",
|
||||
"modalSkillBody": "当 Skill 被调用时,Agent 触发 pre_tool_use 和 post_tool_use 事件。pre_tool_use 上的 Hook 可以检查 Skill 名称和参数,然后决定放行、阻止或改写。post-tool Hook 用于观察结果以记录日志或链式处理。",
|
||||
"modalMcpTitle": "Hooks × MCP Servers",
|
||||
"modalMcpBody": "来自外部 MCP 服务器的工具在 Agent 管道中作为普通工具出现。Hooks 同样在 pre_tool_use / post_tool_use 上触发 — 无需特殊配置。使用 matcher 正则来匹配特定 MCP 工具名(如 mcp__.*)。",
|
||||
"modalBuiltinTitle": "内置 Hook(系统)",
|
||||
"modalBuiltinBody": "内置 Hook(如 PII Redactor)以沙箱脚本运行并拥有提升的权限:可以在 Agent 看到之前改写用户输入(rawInput)和工具参数。用户创建的脚本无此权限 — 由调度器层面强制执行。",
|
||||
"modalClose": "知道了"
|
||||
},
|
||||
"actions": {
|
||||
"create": "创建钩子",
|
||||
@@ -42,6 +52,8 @@
|
||||
"form": {
|
||||
"title_create": "创建钩子",
|
||||
"title_edit": "编辑钩子",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "例如:阻止敏感工具",
|
||||
"event": "事件",
|
||||
"handlerType": "处理器类型",
|
||||
"scope": "范围",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp, Sparkles } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Info, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
const LS_KEY = "goclaw:hooks:beta-card-collapsed";
|
||||
|
||||
@@ -21,12 +24,10 @@ function saveCollapsed(v: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// BetaInfoCard explains what the Hooks feature does for end users. Renders at
|
||||
// the top of the hooks list page. State persists per-browser via localStorage
|
||||
// so dismissed users don't see it expand on every reload.
|
||||
export function BetaInfoCard() {
|
||||
const { t } = useTranslation("hooks");
|
||||
const [collapsed, setCollapsed] = useState<boolean>(loadCollapsed);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !collapsed;
|
||||
@@ -35,50 +36,96 @@ export function BetaInfoCard() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{t("beta.title")}
|
||||
</span>
|
||||
<span className="rounded-full bg-blue-200/80 px-1.5 py-0.5 text-2xs font-semibold uppercase text-blue-800 dark:bg-blue-800/40 dark:text-blue-200">
|
||||
{t("beta.badge")}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{collapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
{t("beta.title")}
|
||||
</span>
|
||||
<span className="rounded-full bg-blue-200/80 px-1.5 py-0.5 text-2xs font-semibold uppercase text-blue-800 dark:bg-blue-800/40 dark:text-blue-200">
|
||||
{t("beta.badge")}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{collapsed ? (
|
||||
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-3 px-4 pb-4 text-sm text-blue-900/90 dark:text-blue-100/90">
|
||||
<p>{t("beta.description")}</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<BetaPoint title={t("beta.howItWorksTitle1")} body={t("beta.howItWorksBody1")} />
|
||||
<BetaPoint title={t("beta.howItWorksTitle2")} body={t("beta.howItWorksBody2")} />
|
||||
<BetaPoint title={t("beta.howItWorksTitle3")} body={t("beta.howItWorksBody3")} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="h-7 gap-1 text-xs text-blue-700 hover:bg-blue-100 hover:text-blue-900 dark:text-blue-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-100"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
{t("beta.learnMore")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggle}
|
||||
className="h-7 text-xs text-blue-700 hover:bg-blue-100 hover:text-blue-900 dark:text-blue-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-100"
|
||||
>
|
||||
{t("beta.collapse")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-3 px-4 pb-4 text-sm text-blue-900/90 dark:text-blue-100/90">
|
||||
<p>{t("beta.description")}</p>
|
||||
<HooksExplainerModal open={modalOpen} onOpenChange={setModalOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<BetaPoint title={t("beta.howItWorksTitle1")} body={t("beta.howItWorksBody1")} />
|
||||
<BetaPoint title={t("beta.howItWorksTitle2")} body={t("beta.howItWorksBody2")} />
|
||||
<BetaPoint title={t("beta.howItWorksTitle3")} body={t("beta.howItWorksBody3")} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggle}
|
||||
className="h-7 text-xs text-blue-700 hover:bg-blue-100 hover:text-blue-900 dark:text-blue-300 dark:hover:bg-blue-900/30 dark:hover:text-blue-100"
|
||||
>
|
||||
{t("beta.collapse")}
|
||||
</Button>
|
||||
</div>
|
||||
function HooksExplainerModal({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const { t } = useTranslation("hooks");
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto max-sm:inset-0 max-sm:rounded-none sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">{t("beta.modalTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 text-sm text-foreground/90">
|
||||
<p>{t("beta.modalIntro")}</p>
|
||||
<ExplainerSection title={t("beta.modalSkillTitle")} body={t("beta.modalSkillBody")} />
|
||||
<ExplainerSection title={t("beta.modalMcpTitle")} body={t("beta.modalMcpBody")} />
|
||||
<ExplainerSection title={t("beta.modalBuiltinTitle")} body={t("beta.modalBuiltinBody")} />
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" size="sm" onClick={() => onOpenChange(false)}>
|
||||
{t("beta.modalClose")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplainerSection({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-semibold">{title}</p>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
|
||||
} = useForm<HookFormData>({
|
||||
resolver: zodResolver(hookFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
event: "pre_tool_use",
|
||||
handler_type: "http",
|
||||
handler_type: "script",
|
||||
scope: "tenant",
|
||||
timeout_ms: 5000,
|
||||
on_timeout: "block",
|
||||
@@ -73,6 +74,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
|
||||
const handlerType: HookFormData["handler_type"] =
|
||||
initial.handler_type === "command" ? "http" : initial.handler_type;
|
||||
reset({
|
||||
name: initial.name ?? "",
|
||||
event: initial.event as HookFormData["event"],
|
||||
handler_type: handlerType,
|
||||
scope: initial.scope,
|
||||
@@ -116,6 +118,17 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name — optional user-facing label */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("form.name")}</Label>
|
||||
<Input
|
||||
{...register("name")}
|
||||
placeholder={t("form.namePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
disabled={isBuiltin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event + Scope side-by-side on wider viewports */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
@@ -204,7 +217,8 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
|
||||
|
||||
{/* Handler-specific sub-forms */}
|
||||
{handlerType === "script" && (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
<Controller
|
||||
control={control}
|
||||
name="script_source"
|
||||
@@ -218,6 +232,7 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{handlerType === "http" && (
|
||||
|
||||
@@ -44,7 +44,10 @@ export function HookListRow({ hook, onClick, onToggle, onEdit, onDelete, onTest
|
||||
|
||||
{/* Main info */}
|
||||
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{hook.name && (
|
||||
<p className="truncate text-sm font-medium">{hook.name}</p>
|
||||
)}
|
||||
<div className={`flex flex-wrap items-center gap-1.5 ${hook.name ? "mt-0.5" : ""}`}>
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-2xs font-medium ${EVENT_COLORS[hook.event] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
|
||||
@@ -120,6 +120,7 @@ export function HooksPage() {
|
||||
return;
|
||||
}
|
||||
await createMutation.mutateAsync({
|
||||
name: data.name ?? "",
|
||||
event: data.event,
|
||||
handler_type: data.handler_type,
|
||||
scope: data.scope,
|
||||
@@ -145,6 +146,7 @@ export function HooksPage() {
|
||||
await updateMutation.mutateAsync({
|
||||
hookId: editTarget.id,
|
||||
updates: {
|
||||
name: data.name ?? "",
|
||||
event: data.event,
|
||||
handler_type: data.handler_type,
|
||||
scope: data.scope,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const HookScopeEnum = z.enum(["global", "tenant", "agent"]);
|
||||
|
||||
export const hookFormSchema = z
|
||||
.object({
|
||||
name: z.string().max(255).optional(),
|
||||
event: HookEventEnum,
|
||||
handler_type: HookHandlerTypeEnum,
|
||||
scope: HookScopeEnum,
|
||||
|
||||
Reference in new issue
Block a user