From cd319ce7bdcf67e27903cf349248e680ff89984e Mon Sep 17 00:00:00 2001 From: logan Date: Thu, 15 Oct 2026 11:51:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E9=98=B6=E6=AE=B5=202=20Ask|Agent?= =?UTF-8?q?=20=E5=85=B1=E7=94=A8=20aitools=20Runtime=20=E7=9C=9F=E6=9F=A5?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏 Ask|Agent 分段;Ask 仍走 /api/ai/chat,Agent 走 /api/ai/agent - RunAgent 多轮 tool call → 进程内 aitools(与 MCP 同源只读/白名单/审计) - 回复下步骤时间线;openai-chat tools(DeepSeek);agent-accept 冒烟 --- docs/15-AI-MCP-Agent.md | 18 +- server/cmd/opsxdb-agent-accept/main.go | 63 +++++ server/cmd/opsxdb-mcp/main.go | 63 +---- server/internal/ai/agent.go | 252 ++++++++++++++++++++ server/internal/ai/agent_test.go | 209 +++++++++++++++++ server/internal/ai/provider.go | 35 ++- server/internal/ai/remote_tools.go | 143 ++++++++++++ server/internal/aitools/schema.go | 115 ++++++++++ server/internal/docs/content/10-ai.md | 20 +- server/internal/server/router.go | 25 ++ src/AiPanel.tsx | 303 ++++++++++++++++--------- src/api.ts | 27 +++ src/store.ts | 22 +- src/styles.css | 157 ++++++++++++- 14 files changed, 1270 insertions(+), 182 deletions(-) create mode 100644 server/cmd/opsxdb-agent-accept/main.go create mode 100644 server/internal/ai/agent.go create mode 100644 server/internal/ai/agent_test.go create mode 100644 server/internal/ai/remote_tools.go create mode 100644 server/internal/aitools/schema.go diff --git a/docs/15-AI-MCP-Agent.md b/docs/15-AI-MCP-Agent.md index b5bda9b..d4f85a1 100644 --- a/docs/15-AI-MCP-Agent.md +++ b/docs/15-AI-MCP-Agent.md @@ -14,7 +14,7 @@ | 写操作 | **P0 纯只读**;「确认后才可写」复用现有 dangerConfirm,进 **P1**,MCP 首版不可写 | | 代码边界 | 只动根级 `src` + `server`;禁止 `packages/ui`(死树) | | Redis | 对齐现有精确 TYPE / 集群 SCAN,不另起搜键 | -| UI | P0 无新重界面;Ask\|Agent Seg / 步骤时间线 / MCP 状态点属阶段 2–4(见文末设计备忘) | +| UI | P0 无新重界面;Ask\|Agent Seg / 步骤时间线已落地阶段 2;MCP 状态点属后续 | | 冲突 | 与未推「补偿包」冲突先停报,勿 silently 覆盖 | | 问题 | 结论 | @@ -103,7 +103,7 @@ - [x] 只读模式下 `DELETE`/`DROP`/写 SQL 被拒;审计写 `mcp-audit.jsonl` - [x] 通路代码就绪;单测用 sqlite 覆盖 list/describe/exec;MySQL/PG 真库待桌面验收 - [x] Redis 读白名单 + FLUSH*/KEYS/写拒(单测);真库待验 -- [ ] 侧栏 Agent 调同一 runtime(**P1 / 设计阶段 2,本轮不做 UI**) +- [x] 侧栏 Agent 调同一 runtime(阶段 2:Ask|Agent + `/api/ai/agent` + 步骤时间线) - [x] 文档:`docs/mcp-cursor.example.json` + 本文件 ## 参考路径(本机) @@ -193,6 +193,20 @@ go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp 落地次序:MCP 开关+白名单 → Ask|Agent Seg → 时间线与插入 → MCP 状态点。 +### 阶段 2(内部 Ask | Agent)2026-10-15 + +| 项 | 说明 | +|----|------| +| Ask | 侧栏分段 **Ask**:走既有 `/api/ai/chat`,不调工具(生成/解释/优化) | +| Agent | 分段 **Agent**:`POST /api/ai/agent` → `ai.RunAgent` → 进程内 `aitools.Runtime.Call`(与 MCP 同源) | +| 协议 | Agent tool calling 目前仅 **openai-chat**(DeepSeek / OpenAI 兼容) | +| 安全 | P0 只读;ConfirmDanger 永不 true;白名单 / 审计同 MCP | +| UI | 输入框上 Ask\|Agent;回复下可折叠步骤时间线(默认最近 3 步) | +| 单测 | `go test ./internal/ai/ ./internal/aitools/`(含 mock tool loop) | +| 真库冒烟 | `cd server && go run ./cmd/opsxdb-agent-accept/`(需 `~/.opsxdb/ai.json` 真 key) | + +手动测:打开侧栏 → Agent →「列出 demo 有哪些表并查 users 几行」→ 步骤应出现 `list_tables` / `execute_query`。 + ### 真库验收 2026-10-15 MySQL 3306 demo + Redis 6379:`go run ./cmd/opsxdb-mcp-accept/` 全过。实践文档:`~/Downloads/AI-Agent接入GUI工具开发实践.md`。 diff --git a/server/cmd/opsxdb-agent-accept/main.go b/server/cmd/opsxdb-agent-accept/main.go new file mode 100644 index 0000000..150bbbe --- /dev/null +++ b/server/cmd/opsxdb-agent-accept/main.go @@ -0,0 +1,63 @@ +// opsxdb-agent-accept:阶段 2 冒烟 — 真 LLM(~/.opsxdb/ai.json)+ 真库 aitools。 +// 用法:cd server && go run ./cmd/opsxdb-agent-accept/ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "opsxdb/server/internal/ai" + "opsxdb/server/internal/aitools" +) + +func main() { + llm, err := ai.DefaultToolChat() + if err != nil { + fmt.Println("SKIP: no ToolChat LLM configured:", err) + fmt.Println("提示:Ask|Agent 单测已覆盖 tool loop;配置 DeepSeek 后重跑本命令做真库冒烟。") + os.Exit(0) + } + rt, err := aitools.DefaultRuntime() + if err != nil { + fmt.Println("FAIL: runtime", err) + os.Exit(1) + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + req := ai.AgentReq{ + Prompt: "列出 demo 库有哪些表,再用 SELECT 查 users 表最多 3 行,简要中文回答。", + ConnectionID: "accept-mysql-demo", + Driver: "mysql", + Database: "demo", + MaxRounds: 8, + } + fmt.Println("RUN Agent:", req.Prompt) + res, err := ai.RunAgent(ctx, llm, rt, req) + b, _ := json.MarshalIndent(res, "", " ") + fmt.Println(string(b)) + if err != nil { + fmt.Println("FAIL:", err) + os.Exit(1) + } + okTool := false + for _, s := range res.Steps { + fmt.Printf("STEP ok=%v tool=%s summary=%s ms=%d\n", s.OK, s.Tool, s.Summary, s.Ms) + if s.OK && (s.Tool == aitools.ToolListTables || s.Tool == aitools.ToolExecuteQuery || s.Tool == aitools.ToolGetSchemaContext) { + okTool = true + } + } + if !okTool { + fmt.Println("FAIL: Agent 未成功调用 list_tables / execute_query / get_schema_context") + os.Exit(1) + } + if strings.TrimSpace(res.Content) == "" { + fmt.Println("FAIL: empty content") + os.Exit(1) + } + fmt.Println("OK: agent smoke passed, rounds=", res.Rounds) +} diff --git a/server/cmd/opsxdb-mcp/main.go b/server/cmd/opsxdb-mcp/main.go index 895e7c4..d94d820 100644 --- a/server/cmd/opsxdb-mcp/main.go +++ b/server/cmd/opsxdb-mcp/main.go @@ -33,17 +33,10 @@ func main() { server.WithToolCapabilities(true), ) - register := func(name, desc string, props map[string]any, required []string) { - schema := map[string]any{ - "type": "object", - "properties": props, - } - if len(required) > 0 { - schema["required"] = required - } - raw, _ := json.Marshal(schema) - t := mcp.NewToolWithRawSchema(name, desc, raw) - toolName := name + for _, spec := range aitools.ToolSpecs() { + raw, _ := json.Marshal(spec.InputSchema()) + t := mcp.NewToolWithRawSchema(spec.Name, spec.Description, raw) + toolName := spec.Name s.AddTool(t, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() out, err := rt.Call(ctx, toolName, args) @@ -54,54 +47,6 @@ func main() { }) } - connID := map[string]any{"type": "string", "description": "Saved connection id from opsxdb_list_connections"} - database := map[string]any{"type": "string", "description": "Database / schema / Redis DB index"} - - register(aitools.ToolListConnections, - "List whitelisted OpsxDB connections (id/name/driver/host/port/database; never passwords).", - map[string]any{}, nil) - - register(aitools.ToolListDatabases, - "List databases for a connection.", - map[string]any{"connection_id": connID}, []string{"connection_id"}) - - register(aitools.ToolListTables, - "List tables/views (or engine equivalents) in a database.", - map[string]any{"connection_id": connID, "database": database}, []string{"connection_id"}) - - register(aitools.ToolDescribeTable, - "Describe columns and metadata for a table.", - map[string]any{ - "connection_id": connID, - "database": database, - "table": map[string]any{"type": "string", "description": "Table name"}, - }, []string{"connection_id", "table"}) - - register(aitools.ToolGetSchemaContext, - "Compact schema text for LLM context (capped ~4k chars).", - map[string]any{ - "connection_id": connID, - "database": database, - "max_tables": map[string]any{"type": "integer", "description": "Max tables to include (default 40)"}, - }, []string{"connection_id"}) - - register(aitools.ToolExecuteQuery, - "Execute a READ-ONLY SQL query (SELECT/SHOW/EXPLAIN/WITH…SELECT). Writes and DDL are rejected in P0. Default limit 100, max 500.", - map[string]any{ - "connection_id": connID, - "sql": map[string]any{"type": "string", "description": "Read-only SQL"}, - "database": database, - "limit": map[string]any{"type": "integer", "description": "Row limit (default 100, max 500)"}, - }, []string{"connection_id", "sql"}) - - register(aitools.ToolExecuteRedisCommand, - "Execute a READ-ONLY Redis command (GET/SCAN/TYPE/TTL/…). FLUSH*/KEYS and writes are rejected in P0.", - map[string]any{ - "connection_id": connID, - "command": map[string]any{"type": "string", "description": "Single Redis command line"}, - "database": database, - }, []string{"connection_id", "command"}) - if err := server.ServeStdio(s); err != nil { fmt.Fprintf(os.Stderr, "opsxdb-mcp: %v\n", err) os.Exit(1) diff --git a/server/internal/ai/agent.go b/server/internal/ai/agent.go new file mode 100644 index 0000000..b3f5fdf --- /dev/null +++ b/server/internal/ai/agent.go @@ -0,0 +1,252 @@ +package ai + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "opsxdb/server/internal/aitools" +) + +const ( + defaultAgentMaxRounds = 8 + maxToolResultFeedChars = 12000 + maxStepArgsChars = 200 +) + +// AgentReq 侧栏 Agent 请求(与 Ask 的 /api/ai/chat 分路径)。 +type AgentReq struct { + Prompt string `json:"prompt"` + ConnectionID string `json:"connectionId,omitempty"` + Driver string `json:"driver,omitempty"` + Database string `json:"database,omitempty"` + Model string `json:"model,omitempty"` + MaxRounds int `json:"maxRounds,omitempty"` +} + +// AgentStep 挂在回复下的步骤时间线一项。 +type AgentStep struct { + Tool string `json:"tool"` + Args string `json:"args,omitempty"` + OK bool `json:"ok"` + Summary string `json:"summary"` + Error string `json:"error,omitempty"` + Ms int64 `json:"ms"` +} + +// AgentRes Agent 最终回答 + 步骤。 +type AgentRes struct { + Content string `json:"content"` + SQL string `json:"sql,omitempty"` + Steps []AgentStep `json:"steps"` + Rounds int `json:"rounds"` +} + +// RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌,直到最终文本或轮次耗尽。 +// ConfirmDanger 永不自动 true(由 Runtime 保证)。Ask 模式不要走这里。 +func RunAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentReq) (AgentRes, error) { + if rt == nil { + return AgentRes{}, fmt.Errorf("aitools runtime required") + } + if llm == nil { + return AgentRes{}, fmt.Errorf("tool chat LLM required") + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return AgentRes{}, fmt.Errorf("prompt required") + } + maxRounds := req.MaxRounds + if maxRounds <= 0 { + maxRounds = defaultAgentMaxRounds + } + if maxRounds > 16 { + maxRounds = 16 + } + + tools := aitools.OpenAITools() + msgs := []Message{ + {Role: "system", Content: buildAgentSystem(req)}, + {Role: "user", Content: prompt}, + } + + var steps []AgentStep + rounds := 0 + for round := 0; round < maxRounds; round++ { + rounds = round + 1 + turn, err := llm.ChatWithTools(ctx, req.Model, msgs, tools) + if err != nil { + return AgentRes{Steps: steps, Rounds: rounds}, err + } + if len(turn.ToolCalls) == 0 { + content := turn.Content + if content == "" { + content = "(模型未返回文本,也未调用工具)" + } + return AgentRes{ + Content: content, + SQL: extractSQL(content), + Steps: steps, + Rounds: rounds, + }, nil + } + + // assistant 消息带 tool_calls + msgs = append(msgs, Message{ + Role: "assistant", + Content: turn.Content, + ToolCalls: turn.ToolCalls, + }) + + for _, tc := range turn.ToolCalls { + step, toolMsg := execOneTool(ctx, rt, tc) + steps = append(steps, step) + msgs = append(msgs, toolMsg) + } + } + + return AgentRes{ + Content: fmt.Sprintf("已达最大工具轮次(%d)。请根据已执行步骤自行判断,或缩小问题后重试。", maxRounds), + Steps: steps, + Rounds: rounds, + }, nil +} + +func buildAgentSystem(req AgentReq) string { + var b strings.Builder + b.WriteString("你是 OpsxDB 数据库 Agent。你可以通过工具只读查询用户已白名单的连接。\n") + b.WriteString("规则:\n") + b.WriteString("1. 需要查库时必须调用工具;不要假装已执行 SQL。\n") + b.WriteString("2. 仅只读:execute_query 只允许 SELECT/SHOW/EXPLAIN/DESCRIBE/WITH…SELECT;Redis 仅读命令。\n") + b.WriteString("3. 先确认 connection_id(可用 opsxdb_list_connections);用户给出偏好连接时优先用它。\n") + b.WriteString("4. 用中文简明回答;可执行 SQL 放在 ```sql 代码块中。\n") + b.WriteString("5. 工具结果可能截断,注意说明。\n") + if req.ConnectionID != "" { + b.WriteString(fmt.Sprintf("\n用户当前偏好连接 id: %s", req.ConnectionID)) + if req.Driver != "" { + b.WriteString(fmt.Sprintf("(%s)", req.Driver)) + } + if req.Database != "" { + b.WriteString(fmt.Sprintf(",数据库: %s", req.Database)) + } + b.WriteString("。\n") + } + return b.String() +} + +func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentStep, Message) { + name := strings.TrimSpace(tc.Function.Name) + argsRaw := strings.TrimSpace(tc.Function.Arguments) + if argsRaw == "" { + argsRaw = "{}" + } + step := AgentStep{ + Tool: name, + Args: truncateRunes(argsRaw, maxStepArgsChars), + } + start := time.Now() + + var args map[string]any + if err := json.Unmarshal([]byte(argsRaw), &args); err != nil { + step.OK = false + step.Error = "invalid tool arguments JSON: " + err.Error() + step.Summary = step.Error + step.Ms = time.Since(start).Milliseconds() + return step, Message{ + Role: "tool", + ToolCallID: tc.ID, + Name: name, + Content: step.Error, + } + } + + out, err := rt.Call(ctx, name, args) + step.Ms = time.Since(start).Milliseconds() + if err != nil { + step.OK = false + step.Error = err.Error() + step.Summary = err.Error() + return step, Message{ + Role: "tool", + ToolCallID: tc.ID, + Name: name, + Content: err.Error(), + } + } + step.OK = true + step.Summary = summarizeToolResult(name, out) + feed := aitools.ResultJSON(out) + if len(feed) > maxToolResultFeedChars { + feed = feed[:maxToolResultFeedChars] + "\n…(truncated)" + } + return step, Message{ + Role: "tool", + ToolCallID: tc.ID, + Name: name, + Content: feed, + } +} + +func summarizeToolResult(tool string, out any) string { + m, ok := out.(map[string]any) + if !ok { + return truncateRunes(fmt.Sprint(out), 120) + } + lenAny := func(key string) (int, bool) { + switch v := m[key].(type) { + case []any: + return len(v), true + case []string: + return len(v), true + default: + return 0, false + } + } + switch tool { + case aitools.ToolListConnections: + if n, ok := lenAny("connections"); ok { + return fmt.Sprintf("%d connections", n) + } + case aitools.ToolListDatabases: + if n, ok := lenAny("databases"); ok { + return fmt.Sprintf("%d databases", n) + } + case aitools.ToolListTables: + if n, ok := lenAny("tables"); ok { + return fmt.Sprintf("%d tables", n) + } + case aitools.ToolDescribeTable: + if t, _ := m["table"].(string); t != "" { + if n, ok := lenAny("columns"); ok { + return fmt.Sprintf("%s (%d cols)", t, n) + } + return t + } + case aitools.ToolGetSchemaContext: + if s, ok := m["schema"].(string); ok { + return fmt.Sprintf("schema %d chars", len(s)) + } + case aitools.ToolExecuteQuery, aitools.ToolExecuteRedisCommand: + rc := 0 + switch v := m["rowCount"].(type) { + case int: + rc = v + case int64: + rc = int(v) + case float64: + rc = int(v) + } + return fmt.Sprintf("rows=%d", rc) + } + return truncateRunes(aitools.ResultJSON(out), 120) +} + +func truncateRunes(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/server/internal/ai/agent_test.go b/server/internal/ai/agent_test.go new file mode 100644 index 0000000..6bb7290 --- /dev/null +++ b/server/internal/ai/agent_test.go @@ -0,0 +1,209 @@ +package ai + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "opsxdb/server/internal/aitools" + "opsxdb/server/internal/db" +) + +// scriptedLLM 按调用次序返回预设 ToolsTurn,用于证明 Agent tool loop 不依赖真 LLM。 +type scriptedLLM struct { + turns []ToolsTurn + i int + seen [][]Message +} + +func (s *scriptedLLM) ChatWithTools(_ context.Context, _ string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) { + cp := append([]Message(nil), messages...) + s.seen = append(s.seen, cp) + if len(tools) < 7 { + return ToolsTurn{}, nil // 测试里仍继续;下面断言 + } + if s.i >= len(s.turns) { + return ToolsTurn{Content: "fallback"}, nil + } + t := s.turns[s.i] + s.i++ + return t, nil +} + +func TestOpenAIToolsMatchRuntime(t *testing.T) { + tools := aitools.OpenAITools() + names := aitools.ToolNames() + if len(tools) != len(names) { + t.Fatalf("tools %d vs names %d", len(tools), len(names)) + } + for i, n := range names { + if tools[i].Function.Name != n { + t.Fatalf("tool[%d]=%s want %s", i, tools[i].Function.Name, n) + } + if tools[i].Type != "function" { + t.Fatalf("type want function") + } + } +} + +func TestRunAgentToolLoop(t *testing.T) { + rt := aitools.NewRuntime(aitools.Policy{ + Mode: aitools.ModeReadOnly, + AllowedConnectionIDs: []string{"c1"}, + DefaultLimit: 100, + MaxLimit: 500, + }) + rt.FindConn = func(id string) (*aitools.SavedConnection, error) { + return &aitools.SavedConnection{ID: id, Name: "demo", Driver: "mysql", Host: "127.0.0.1", Port: 3306, Database: "demo"}, nil + } + rt.ListTbl = func(_ context.Context, c db.ConnReq) ([]string, error) { + if c.Database != "demo" && c.Database != "" { + return nil, nil + } + return []string{"users", "orders"}, nil + } + rt.ExecSQL = func(_ context.Context, c db.ConnReq) db.ExecResult { + if !strings.Contains(strings.ToUpper(c.SQL), "SELECT") { + return db.ExecResult{Error: "unexpected"} + } + return db.ExecResult{ + Columns: []string{"id", "name"}, + Rows: []map[string]interface{}{{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}}, + RowCount: 2, + TimeMs: 1, + } + } + rt.Audit = func(aitools.AuditEntry) {} + + argsList, _ := json.Marshal(map[string]any{"connection_id": "c1", "database": "demo"}) + argsQuery, _ := json.Marshal(map[string]any{ + "connection_id": "c1", + "database": "demo", + "sql": "SELECT id, name FROM users LIMIT 5", + }) + + llm := &scriptedLLM{turns: []ToolsTurn{ + { + ToolCalls: []ToolCall{{ + ID: "call_1", Type: "function", + Function: ToolCallFunc{Name: aitools.ToolListTables, Arguments: string(argsList)}, + }}, + }, + { + ToolCalls: []ToolCall{{ + ID: "call_2", Type: "function", + Function: ToolCallFunc{Name: aitools.ToolExecuteQuery, Arguments: string(argsQuery)}, + }}, + }, + {Content: "demo 库有 users、orders。users 样例两行。\n\n```sql\nSELECT id, name FROM users LIMIT 5;\n```"}, + }} + + res, err := RunAgent(context.Background(), llm, rt, AgentReq{ + Prompt: "列出 demo 库有哪些表,并查 users 几行", + ConnectionID: "c1", + Driver: "mysql", + Database: "demo", + }) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if len(res.Steps) != 2 { + t.Fatalf("want 2 steps, got %d: %+v", len(res.Steps), res.Steps) + } + if !res.Steps[0].OK || res.Steps[0].Tool != aitools.ToolListTables { + t.Fatalf("step0: %+v", res.Steps[0]) + } + if !strings.Contains(res.Steps[0].Summary, "2 tables") { + t.Fatalf("list_tables summary: %s", res.Steps[0].Summary) + } + if !res.Steps[1].OK || res.Steps[1].Tool != aitools.ToolExecuteQuery { + t.Fatalf("step1: %+v", res.Steps[1]) + } + if !strings.Contains(res.Steps[1].Summary, "rows=2") { + t.Fatalf("execute_query summary: %s", res.Steps[1].Summary) + } + if !strings.Contains(res.Content, "users") { + t.Fatalf("final content: %s", res.Content) + } + if res.SQL == "" { + t.Fatal("expected extracted SQL") + } + if res.Rounds != 3 { + t.Fatalf("rounds=%d", res.Rounds) + } + // 第二轮 messages 应已含 tool 角色回灌 + if len(llm.seen) < 2 { + t.Fatal("expected multiple LLM calls") + } + foundTool := false + for _, m := range llm.seen[1] { + if m.Role == "tool" && m.ToolCallID == "call_1" { + foundTool = true + if !strings.Contains(m.Content, "users") { + t.Fatalf("tool result missing tables: %s", m.Content) + } + } + } + if !foundTool { + t.Fatal("second LLM call should include tool result for call_1") + } +} + +func TestRunAgentRejectsWriteViaRuntime(t *testing.T) { + rt := aitools.NewRuntime(aitools.Policy{ + Mode: aitools.ModeReadOnly, + AllowedConnectionIDs: []string{"c1"}, + DefaultLimit: 100, + MaxLimit: 500, + }) + rt.FindConn = func(id string) (*aitools.SavedConnection, error) { + return &aitools.SavedConnection{ID: id, Name: "demo", Driver: "mysql"}, nil + } + rt.Audit = func(aitools.AuditEntry) {} + + args, _ := json.Marshal(map[string]any{"connection_id": "c1", "sql": "DROP TABLE users"}) + llm := &scriptedLLM{turns: []ToolsTurn{ + {ToolCalls: []ToolCall{{ + ID: "x", Type: "function", + Function: ToolCallFunc{Name: aitools.ToolExecuteQuery, Arguments: string(args)}, + }}}, + {Content: "无法删除表:只读策略拒绝。"}, + }} + res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "删掉 users"}) + if err != nil { + t.Fatal(err) + } + if res.Steps[0].OK { + t.Fatal("DROP should fail") + } + errMsg := strings.ToLower(res.Steps[0].Error + " " + res.Steps[0].Summary) + if !strings.Contains(errMsg, "read-only") && !strings.Contains(errMsg, "not a read-only") { + t.Fatalf("expected read-only deny, got %+v", res.Steps[0]) + } +} + +func TestParseToolsTurn(t *testing.T) { + content := "hi" + raw, _ := json.Marshal(map[string]any{ + "choices": []map[string]any{{ + "finish_reason": "tool_calls", + "message": map[string]any{ + "role": "assistant", + "content": content, + "tool_calls": []map[string]any{{ + "id": "c1", + "type": "function", + "function": map[string]string{ + "name": "opsxdb_list_tables", + "arguments": `{"connection_id":"x"}`, + }, + }}, + }, + }}, + }) + turn, err := parseToolsTurn(raw, 200) + if err != nil || len(turn.ToolCalls) != 1 || turn.ToolCalls[0].Function.Name != "opsxdb_list_tables" { + t.Fatalf("parse: %+v %v", turn, err) + } +} diff --git a/server/internal/ai/provider.go b/server/internal/ai/provider.go index 075874d..e2a7ada 100644 --- a/server/internal/ai/provider.go +++ b/server/internal/ai/provider.go @@ -2,10 +2,25 @@ package ai import "context" -// Message 对话消息(OpenAI 兼容协议)。 +// Message 对话消息(OpenAI 兼容协议)。Agent 多轮可带 tool_calls / tool 角色。 type Message struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// ToolCall OpenAI function tool_call。 +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolCallFunc `json:"function"` +} + +type ToolCallFunc struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } // ChatReq 在 stub 时代字段上演进,前端契约不变;新增 schema/model 可选字段。 @@ -32,14 +47,14 @@ type Provider interface { Complete(ctx context.Context, req ChatReq) (ChatRes, error) } -// Default 按当前配置选择 Provider;未配置 API Key 时回落 stub(离线占位)。 +// Default 按当前配置选择 Provider;stub 时或远端配置缺 key(免鉴权除外)回落 stub(离线占位)。 func Default() Provider { cfg := LoadConfig() - switch cfg.Provider { - case "openai": - if cfg.APIKey != "" { - return NewOpenAIProvider(cfg) - } + if cfg.Provider == "" || cfg.Provider == "stub" { + return stubProvider{} } - return stubProvider{} + if cfg.APIKey == "" && cfg.AuthStyle != AuthNone { + return stubProvider{} + } + return NewRemoteProvider(cfg) } diff --git a/server/internal/ai/remote_tools.go b/server/internal/ai/remote_tools.go new file mode 100644 index 0000000..aa6d170 --- /dev/null +++ b/server/internal/ai/remote_tools.go @@ -0,0 +1,143 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "opsxdb/server/internal/aitools" +) + +// ToolsTurn 一轮带 tools 的模型回复:要么最终文本,要么一组 tool_calls。 +type ToolsTurn struct { + Content string + ToolCalls []ToolCall + RawFinish string +} + +// ToolChat 可挂载 tools 的 LLM(Agent 循环依赖此接口,便于单测注入)。 +type ToolChat interface { + ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) +} + +// ChatWithTools openai-chat 兼容协议的 tools 调用(DeepSeek / OpenAI / 多数兼容网关)。 +// anthropic / responses 本轮不支持 Agent tool calling,返回明确错误。 +func (p *RemoteProvider) ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) { + if err := validateEndpoint(p.baseURL, p.allowHTTP); err != nil { + return ToolsTurn{}, err + } + if p.protocol != "" && p.protocol != ProtoChat { + return ToolsTurn{}, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s);请改用 DeepSeek / OpenAI 兼容端点", p.protocol) + } + m := strings.TrimSpace(model) + if m == "" { + m = p.model + } + urlStr := endpoint(p.baseURL, "chat/completions") + payload := map[string]any{ + "model": m, + "messages": messages, + "tools": tools, + "tool_choice": "auto", + "max_tokens": remoteMaxTokens, + "temperature": 0.2, + } + body, err := json.Marshal(payload) + if err != nil { + return ToolsTurn{}, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body)) + if err != nil { + return ToolsTurn{}, err + } + httpReq.Header.Set("Content-Type", "application/json") + switch p.authStyle { + case AuthXAPIKey: + httpReq.Header.Set("x-api-key", p.apiKey) + httpReq.Header.Set("anthropic-version", "2023-06-01") + case AuthNone: + default: + httpReq.Header.Set("Authorization", "Bearer "+p.apiKey) + } + resp, err := p.hc.Do(httpReq) + if err != nil { + return ToolsTurn{}, fmt.Errorf("AI 请求失败: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return ToolsTurn{}, fmt.Errorf("读取 AI 响应失败: %w", err) + } + return parseToolsTurn(raw, resp.StatusCode) +} + +func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) { + var errBody struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + _ = json.Unmarshal(raw, &errBody) + if status < 200 || status >= 300 { + msg := "未知错误" + if errBody.Error != nil && errBody.Error.Message != "" { + msg = errBody.Error.Message + } else if errBody.Message != "" { + msg = errBody.Message + } + return ToolsTurn{}, fmt.Errorf("AI 服务 HTTP %d: %s", status, msg) + } + var out struct { + Choices []struct { + FinishReason string `json:"finish_reason"` + Message struct { + Role string `json:"role"` + Content *string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return ToolsTurn{}, fmt.Errorf("解析 AI 响应失败(HTTP %d)", status) + } + if out.Error != nil { + return ToolsTurn{}, fmt.Errorf("AI 服务返回错误: %s", out.Error.Message) + } + if len(out.Choices) == 0 { + return ToolsTurn{}, fmt.Errorf("AI 服务未返回候选结果") + } + ch := out.Choices[0] + content := "" + if ch.Message.Content != nil { + content = strings.TrimSpace(*ch.Message.Content) + } + return ToolsTurn{ + Content: content, + ToolCalls: ch.Message.ToolCalls, + RawFinish: ch.FinishReason, + }, nil +} + +// DefaultToolChat 返回可跑 Agent 的远端 Provider;stub / 缺 key 时返回错误说明。 +func DefaultToolChat() (ToolChat, error) { + cfg := LoadConfig() + if cfg.Provider == "" || cfg.Provider == "stub" { + return nil, fmt.Errorf("Agent 需要真实 LLM:请在设置中配置 DeepSeek / OpenAI 等(stub 仅支持 Ask 生成)") + } + if cfg.APIKey == "" && cfg.AuthStyle != AuthNone { + return nil, fmt.Errorf("Agent 需要 API Key:请在设置中填写后重试") + } + p := NewRemoteProvider(cfg) + if p.protocol != "" && p.protocol != ProtoChat { + return nil, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s)", p.protocol) + } + return p, nil +} diff --git a/server/internal/aitools/schema.go b/server/internal/aitools/schema.go new file mode 100644 index 0000000..b3ebcdb --- /dev/null +++ b/server/internal/aitools/schema.go @@ -0,0 +1,115 @@ +package aitools + +// ToolSpec 工具元数据:MCP stdio 与侧栏 Agent(OpenAI tools)共用同一份契约。 // Logan.Li +type ToolSpec struct { + Name string + Description string + Properties map[string]any + Required []string +} + +// ToolSpecs P0 七工具 schema(顺序与 ToolNames 一致)。 +func ToolSpecs() []ToolSpec { + connID := map[string]any{"type": "string", "description": "Saved connection id from opsxdb_list_connections"} + database := map[string]any{"type": "string", "description": "Database / schema / Redis DB index"} + return []ToolSpec{ + { + Name: ToolListConnections, + Description: "List whitelisted OpsxDB connections (id/name/driver/host/port/database; never passwords).", + Properties: map[string]any{}, + }, + { + Name: ToolListDatabases, + Description: "List databases for a connection.", + Properties: map[string]any{"connection_id": connID}, + Required: []string{"connection_id"}, + }, + { + Name: ToolListTables, + Description: "List tables/views (or engine equivalents) in a database.", + Properties: map[string]any{"connection_id": connID, "database": database}, + Required: []string{"connection_id"}, + }, + { + Name: ToolDescribeTable, + Description: "Describe columns and metadata for a table.", + Properties: map[string]any{ + "connection_id": connID, + "database": database, + "table": map[string]any{"type": "string", "description": "Table name"}, + }, + Required: []string{"connection_id", "table"}, + }, + { + Name: ToolGetSchemaContext, + Description: "Compact schema text for LLM context (capped ~4k chars).", + Properties: map[string]any{ + "connection_id": connID, + "database": database, + "max_tables": map[string]any{"type": "integer", "description": "Max tables to include (default 40)"}, + }, + Required: []string{"connection_id"}, + }, + { + Name: ToolExecuteQuery, + Description: "Execute a READ-ONLY SQL query (SELECT/SHOW/EXPLAIN/WITH…SELECT). Writes and DDL are rejected in P0. Default limit 100, max 500.", + Properties: map[string]any{ + "connection_id": connID, + "sql": map[string]any{"type": "string", "description": "Read-only SQL"}, + "database": database, + "limit": map[string]any{"type": "integer", "description": "Row limit (default 100, max 500)"}, + }, + Required: []string{"connection_id", "sql"}, + }, + { + Name: ToolExecuteRedisCommand, + Description: "Execute a READ-ONLY Redis command (GET/SCAN/TYPE/TTL/…). FLUSH*/KEYS and writes are rejected in P0.", + Properties: map[string]any{ + "connection_id": connID, + "command": map[string]any{"type": "string", "description": "Single Redis command line"}, + "database": database, + }, + Required: []string{"connection_id", "command"}, + }, + } +} + +// InputSchema 返回 JSON Schema object(MCP raw schema / OpenAI parameters)。 +func (t ToolSpec) InputSchema() map[string]any { + schema := map[string]any{ + "type": "object", + "properties": t.Properties, + } + if len(t.Required) > 0 { + schema["required"] = t.Required + } + if t.Properties == nil { + schema["properties"] = map[string]any{} + } + return schema +} + +// OpenAITool OpenAI chat.completions tools[] 一项。 +type OpenAITool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` + } `json:"function"` +} + +// OpenAITools 供 Agent / DeepSeek 等 openai-chat 兼容协议挂载。 +func OpenAITools() []OpenAITool { + specs := ToolSpecs() + out := make([]OpenAITool, 0, len(specs)) + for _, s := range specs { + var t OpenAITool + t.Type = "function" + t.Function.Name = s.Name + t.Function.Description = s.Description + t.Function.Parameters = s.InputSchema() + out = append(out, t) + } + return out +} diff --git a/server/internal/docs/content/10-ai.md b/server/internal/docs/content/10-ai.md index 5cfdd55..289846a 100644 --- a/server/internal/docs/content/10-ai.md +++ b/server/internal/docs/content/10-ai.md @@ -11,6 +11,18 @@ AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻 | 方言 | MySQL ↔ PostgreSQL 等跨方言转换 | | 样例 | 按表结构生成示例查询 | + +## Ask | Agent(阶段 2) + +侧栏输入框上方可切换模式: + +| 模式 | 行为 | +| --- | --- | +| **Ask** | 只生成/解释/优化 SQL,**不执行**数据库工具 | +| **Agent** | 多轮调用与 MCP 同源的只读工具(列连接/库表、描述表、只读 SQL / Redis 读命令),步骤时间线挂在回复下 | + +Agent 需要已配置的 OpenAI 兼容模型(如 DeepSeek)。连接须在 `~/.opsxdb/mcp.json` 白名单内;写操作一律拒绝。 + ## 使用方式 1. 连接库后打开右侧 AI 面板; @@ -19,9 +31,11 @@ AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻 > **提示**:生成的 SQL 依旧走危险语句确认流程 —— AI 产出与手写 SQL 同等对待,不放宽防护。 -## 当前状态(v0.1) +## 配置服务 -AI 面板当前为**本地 stub**:六种动作的交互框架与 API 已就位(`POST /api/ai/chat`),模型服务接入版本会明确标注数据流向与开关位置。 +点击桌面右上角的设置按钮,在「AI 助手」中选择服务类型,填写服务地址、模型和 API Key,点击「保存」或「测试」(测试会先保存配置)。也可以从 AI 面板的设置入口配置。 + +支持离线演示和 OpenAI 兼容服务。已保存的 API Key 不回显,留空保存表示保留原密钥。配置加载失败时可重试。 ## 数据流向承诺 @@ -30,3 +44,5 @@ AI 面板当前为**本地 stub**:六种动作的交互框架与 API 已就位 - 默认只发送你明确提交的内容(不含连接凭据); - 可完全关闭 AI 功能; - 使用云端模型时在界面上明确标识。 + +AI 自动跟随 SQL 编辑器当前连接与数据库(Redis 显示 DB 编号,集群固定 DB0),输入框上方可核对目标。直接点选生成 SQL、解释、优化、修复或样例后发送;Shift+Enter 换行。切换标签不会把晚到的生成结果写入新标签,等待期间修改过的 SQL 也不会被自动覆盖。 diff --git a/server/internal/server/router.go b/server/internal/server/router.go index 4e2a9db..ba07e04 100644 --- a/server/internal/server/router.go +++ b/server/internal/server/router.go @@ -474,6 +474,31 @@ func NewRouter(token string) *gin.Engine { c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Connections)}) }) + // 侧栏 Agent:多轮 tool call → 同一 aitools.Runtime(进程内,不绕 stdio)。Ask 仍走 /ai/chat。 + api.POST("/ai/agent", func(c *gin.Context) { + var req ai.AgentReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + llm, err := ai.DefaultToolChat() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + rt, err := aitools.DefaultRuntime() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + res, err := ai.RunAgent(c.Request.Context(), llm, rt, req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error(), "steps": res.Steps, "rounds": res.Rounds}) + return + } + c.JSON(http.StatusOK, res) + }) + api.POST("/ai/generate", func(c *gin.Context) { var req ai.ChatReq if err := c.ShouldBindJSON(&req); err != nil { diff --git a/src/AiPanel.tsx b/src/AiPanel.tsx index 06c2235..2d5019a 100644 --- a/src/AiPanel.tsx +++ b/src/AiPanel.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from "react"; -import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api"; -import { useWorkspaceStore } from "./store"; +import { callAi, callAiAgent, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api"; +import { AI_PRESETS, findAiPreset, type AiAuthStyle, type AiProtocol } from "./aiPresets"; +import { useWorkspaceStore, type AiAgentStepView, type AiChatMode } from "./store"; +import { editorDatabase } from "./redisContext"; const ACTIONS: { id: AiAction; label: string }[] = [ { id: "generate", label: "生成" }, { id: "explain", label: "解释" }, { id: "optimize", label: "优化" }, { id: "fix", label: "修复" }, - { id: "dialect", label: "方言" }, { id: "sample", label: "样例" }, ]; @@ -92,45 +93,120 @@ function MessageContent({ content }: { content: string }) { ); } -/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */ -function AiSettings({ onClose }: { onClose: () => void }) { + +/** 阶段 2:Agent 步骤时间线 — 挂在回复下,默认可折叠,展示最近步骤 */ +function AgentSteps({ steps }: { steps: AiAgentStepView[] }) { + const [open, setOpen] = useState(false); // 默认最近 3 步 + if (!steps.length) return null; + const shown = open || steps.length <= 3 ? steps : steps.slice(-3); + return ( +
+ + +
+ ); +} + +/** C4:AI 设置(预设 Provider / Base URL / API Key / 模型 + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */ +export function AiSettings({ onClose }: { onClose: () => void }) { const notify = useWorkspaceStore((s) => s.notify); const set = useWorkspaceStore((s) => s.set); - const [provider, setProvider] = useState("openai"); + const [provider, setProvider] = useState("stub"); const [baseURL, setBaseURL] = useState(""); const [apiKey, setApiKey] = useState(""); const [model, setModel] = useState(""); + const [protocol, setProtocol] = useState("openai-chat"); + const [authStyle, setAuthStyle] = useState("bearer"); + const [allowInsecureHttp, setAllowInsecureHttp] = useState(false); const [hasKey, setHasKey] = useState(false); const [busy, setBusy] = useState(false); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [reload, setReload] = useState(0); useEffect(() => { let alive = true; + setLoading(true); + setLoadError(""); getAiConfig() .then((cfg) => { if (!alive) return; setProvider(cfg.provider || "stub"); setBaseURL(cfg.baseURL || ""); setModel(cfg.model || ""); + setProtocol((cfg.protocol as AiProtocol) || "openai-chat"); + setAuthStyle((cfg.authStyle as AiAuthStyle) || (cfg.protocol === "anthropic" ? "x-api-key" : "bearer")); + setAllowInsecureHttp(!!cfg.allowInsecureHttp); setHasKey(!!cfg.hasKey); }) - .catch(() => {}); + .catch(() => { if (alive) setLoadError("无法读取 AI 配置,请确认本机服务已启动后重试。"); }) + .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; - }, []); + }, [reload]); + + /** 选预设:自动填 Base URL / 协议 / 鉴权 / 默认模型;已手填的非预设值会被预设覆盖 */ + const applyPreset = (id: string) => { + setProvider(id); + if (id === "stub") return; + const preset = findAiPreset(id); + if (!preset) return; + setBaseURL(preset.baseUrl); + setProtocol(preset.protocol); + setAuthStyle(preset.authStyle); + setAllowInsecureHttp(preset.allowInsecureHttp); + if (preset.defaultModel) setModel(preset.defaultModel); + }; const save = async () => { + if (provider !== "stub" && !baseURL.trim()) { + notify({ kind: "error", title: "AI 配置", message: "请填写 Base URL,或选择一个预设。", mode: "modal" }); + return false; + } setBusy(true); try { await saveAiConfig({ provider, baseURL: baseURL.trim(), - // 留空表示不改动已保存的 key(服务端掩码不回传明文) + // 留空表示不改动已保存的 key(服务端掩码不回传明文,保存时保留旧值) apiKey: apiKey.trim() || undefined, model: model.trim(), + protocol, + authStyle, + allowInsecureHttp, }); if (model.trim()) set("aiModel", model.trim()); - setHasKey(hasKey || !!apiKey.trim()); + setHasKey(hasKey || !!apiKey.trim() || authStyle === "none"); + setApiKey(""); notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 }); return true; } catch (e) { @@ -160,41 +236,68 @@ function AiSettings({ onClose }: { onClose: () => void }) { } }; + if (loading) return

正在读取 AI 配置…

; + if (loadError) return
{loadError}
; + + const presetModels = findAiPreset(provider)?.models ?? []; + const isLocalHttp = baseURL.trim().startsWith("http://"); + return ( -
+
setModel(e.target.value)} aria-label="模型" /> + + {presetModels.map((m) =>
- setBaseURL(e.target.value)} - aria-label="Base URL" - /> - setApiKey(e.target.value)} - aria-label="API Key" - /> + {provider !== "stub" && ( + <> + setBaseURL(e.target.value)} + aria-label="Base URL" + /> + setApiKey(e.target.value)} + aria-label="API Key" + disabled={authStyle === "none"} + /> + {isLocalHttp && ( + + )} + + )}
-
+ ); } @@ -215,6 +318,7 @@ export function AiPanel() { const ask = useWorkspaceStore((s) => s.aiAsk); const model = useWorkspaceStore((s) => s.aiModel); const busy = useWorkspaceStore((s) => s.aiBusy); + const aiMode = useWorkspaceStore((s) => s.aiMode); const connections = useWorkspaceStore((s) => s.connections); const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId); const tabs = useWorkspaceStore((s) => s.tabs); @@ -222,18 +326,14 @@ export function AiPanel() { const result = useWorkspaceStore((s) => s.result); const set = useWorkspaceStore((s) => s.set); const pushAi = useWorkspaceStore((s) => s.pushAi); - const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql); + const updateTabSqlIfUnchanged = useWorkspaceStore((s) => s.updateTabSqlIfUnchanged); const [showSettings, setShowSettings] = useState(false); - /** C4:真实配置的模型清单(来自 /api/ai/config) */ - const [cfgModel, setCfgModel] = useState(""); - useEffect(() => { let alive = true; getAiConfig() .then((cfg) => { if (!alive) return; if (cfg.model) { - setCfgModel(cfg.model); const cur = useWorkspaceStore.getState().aiModel; if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model); } @@ -247,10 +347,14 @@ export function AiPanel() { const conn = connections.find((c) => c.id === activeConnectionId); const tab = tabs.find((t) => t.id === activeTabId); const sql = tab?.sql ?? ""; + const database = editorDatabase(conn, tab); + const [action, setAction] = useState("generate"); - const run = async (action: AiAction, prompt?: string) => { + const setMode = (mode: AiChatMode) => set("aiMode", mode); + + const runAsk = async (action: AiAction, prompt?: string) => { const text = (prompt ?? ask).trim(); - if (action === "ask" && !text) return; + if (busy || !conn || ((action === "ask" || action === "generate") && !text)) return; set("aiBusy", true); if (action === "ask" || action === "generate") { pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action); @@ -258,20 +362,19 @@ export function AiPanel() { pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`); } try { - // C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts) - const schema = schemaContextFor(conn?.id, conn?.database || tab?.database); + const schema = schemaContextFor(conn?.id, database); const res = await callAi({ action, prompt: text, sql, driver: conn?.driver, - database: conn?.database || tab?.database, + database: database, error: result?.error, schema: schema || undefined, model: model && model !== "opsxdb-stub" ? model : undefined, }); pushAi("assistant", res.content); - if (res.sql) updateActiveSql(res.sql); + if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql); if (action === "ask" || action === "generate") set("aiAsk", ""); } catch (e) { pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`); @@ -280,25 +383,39 @@ export function AiPanel() { } }; - const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean))); + const runAgent = async () => { + const text = ask.trim(); + if (busy || !text) return; + set("aiBusy", true); + pushAi("user", text); + try { + const res = await callAiAgent({ + prompt: text, + connectionId: conn?.id || undefined, + driver: conn?.driver, + database: database || undefined, + model: model && model !== "opsxdb-stub" ? model : undefined, + }); + pushAi("assistant", res.content, res.steps || []); + if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql); + set("aiAsk", ""); + } catch (e) { + pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`); + } finally { + set("aiBusy", false); + } + }; + + const send = () => { + if (aiMode === "agent") void runAgent(); + else void runAsk(action); + }; return ( ); diff --git a/src/api.ts b/src/api.ts index 702bda4..46d7aee 100644 --- a/src/api.ts +++ b/src/api.ts @@ -525,6 +525,33 @@ export async function callAi(payload: { return post<{ content: string; sql?: string }>("/api/ai/chat", payload); } +/** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */ +export type AiAgentStep = { + tool: string; + args?: string; + ok: boolean; + summary: string; + error?: string; + ms: number; +}; + +export type AiAgentRes = { + content: string; + sql?: string; + steps: AiAgentStep[]; + rounds: number; +}; + +export async function callAiAgent(payload: { + prompt: string; + connectionId?: string; + driver?: string; + database?: string; + model?: string; +}) { + return post("/api/ai/agent", payload); +} + /** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */ export type AiConfig = { provider: string; diff --git a/src/store.ts b/src/store.ts index 9da253e..a7cd460 100644 --- a/src/store.ts +++ b/src/store.ts @@ -189,13 +189,26 @@ export type ConfirmRequest = { items?: string[]; }; +export type AiAgentStepView = { + tool: string; + args?: string; + ok: boolean; + summary: string; + error?: string; + ms: number; +}; + export type AiMessage = { id: string; role: "user" | "assistant" | "system"; content: string; at: string; + /** Agent 工具步骤时间线(仅 assistant) */ + steps?: AiAgentStepView[]; }; +export type AiChatMode = "ask" | "agent"; + export type ViewMode = "workbench" | "picker"; export type PickerMode = "list" | "drivers"; @@ -236,6 +249,8 @@ type State = { aiAsk: string; aiModel: string; aiBusy: boolean; + /** Ask=不调工具;Agent=多轮 aitools */ + aiMode: AiChatMode; status: string; notifyModal: NotifyPayload | null; notifyToasts: NotifyPayload[]; @@ -305,7 +320,7 @@ type State = { updateActiveSql: (sql: string) => void; /** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */ updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean; - pushAi: (role: AiMessage["role"], content: string) => void; + pushAi: (role: AiMessage["role"], content: string, steps?: AiAgentStepView[]) => void; setSession: (connectionId: string, sessionId: string | null) => void; pushHistory: (item: Omit & { at?: number }) => void; clearHistory: () => void; @@ -465,6 +480,7 @@ export const useWorkspaceStore = create((set, get) => ({ aiAsk: "", aiModel: "opsxdb-stub", aiBusy: false, + aiMode: "ask", status: "就绪", notifyModal: null, notifyToasts: [], @@ -878,11 +894,11 @@ export const useWorkspaceStore = create((set, get) => ({ return true; }, - pushAi: (role, content) => + pushAi: (role, content, steps) => set((s) => ({ aiMessages: [ ...s.aiMessages, - { id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() }, + { id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString(), steps }, ], })), diff --git a/src/styles.css b/src/styles.css index 61deba2..3af20c4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -90,6 +90,7 @@ .ops-shell { background: var(--ops-bg); color: var(--ops-text); + overflow: hidden; } .ops-input { @@ -162,11 +163,37 @@ .ops-btn-ai:disabled { opacity: 0.5; cursor: not-allowed; } .ops-tabbar { - @apply flex shrink-0 items-stretch gap-0.5 overflow-x-auto border-b px-1; - height: var(--ops-tab-h); + @apply flex shrink-0 items-end gap-1 overflow-x-auto border-b px-2; + height: 38px; border-color: var(--ops-border); background: var(--ops-surface-2); } + .ops-worktab { + @apply flex h-[32px] min-w-0 max-w-[210px] flex-none items-center rounded-t-md border border-b-0 text-[12px] transition-colors; + width: clamp(108px, 13vw, 210px); + border-color: transparent; + color: var(--ops-muted); + } + .ops-worktab:hover { background: var(--ops-bg-active); color: var(--ops-text); } + .ops-worktab-active { + border-color: var(--ops-border); + background: var(--ops-surface); + color: var(--ops-text); + box-shadow: inset 0 2px 0 var(--ops-accent); + } + .ops-worktab-label { + @apply flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden pl-2 text-left; + } + .ops-worktab-label svg, .ops-worktab-label img { @apply shrink-0; } + .ops-worktab-unsaved { @apply h-1.5 w-1.5 shrink-0 rounded-full; background: var(--ops-accent); } + .ops-worktab-close { + @apply mx-1 grid h-5 w-5 shrink-0 place-items-center rounded text-[13px] opacity-0 transition-opacity; + color: var(--ops-muted); + } + .ops-worktab:hover .ops-worktab-close, .ops-worktab-active .ops-worktab-close { opacity: 0.75; } + .ops-worktab-close:hover { background: var(--ops-bg-active); color: var(--ops-text); opacity: 1 !important; } + .ops-worktab-add { @apply mb-1 grid h-6 w-6 shrink-0 place-items-center rounded text-[15px]; color: var(--ops-muted); } + .ops-worktab-add:hover { background: var(--ops-bg-active); color: var(--ops-accent); } .ops-empty { @apply flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center; color: var(--ops-muted); @@ -293,9 +320,20 @@ background: var(--ops-bg-active) !important; } .ops-grid th { - position: relative; + position: sticky; + top: 0; + z-index: 2; user-select: none; } + .ops-grid .ops-number { text-align: right; font-variant-numeric: tabular-nums; } + .ops-grid .ops-empty-value { color: var(--ops-muted); font-style: italic; opacity: .7; } + .ops-grid .ops-boolean { color: var(--ops-accent); } + .ops-result-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--ops-border); font-size: 11px; color: var(--ops-muted); background: var(--ops-surface); } + .ops-result-tools input { width: 190px; height: 26px; font-size: 12px; } + .ops-result-location { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ops-text); } + .ops-result-notice { padding: 4px 10px; font-size: 11px; color: var(--ops-warning); border-bottom: 1px solid var(--ops-border-soft); } + .ops-load-more { display: flex; gap: 12px; justify-content: center; align-items: center; padding: 12px; font-size: 12px; color: var(--ops-muted); } + .ops-value-detail { width: 100%; height: min(380px, 55vh); resize: vertical; font-family: monospace; white-space: pre; } .ops-grid th .ops-col-resize { position: absolute; right: 0; @@ -365,6 +403,32 @@ box-shadow: var(--ops-shadow-modal); font-size: var(--ops-text-md); } + .ops-modal-spacious { + display: flex; + flex-direction: column; + width: min(1120px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + } + .ops-modal-spacious .ops-modal-body { min-height: 0; overflow: auto; } + .ops-modal-spacious .ops-modal-footer { flex: none; } + .ops-designer-grid { border-collapse: separate; border-spacing: 0; font-size: 11.5px; } + .ops-designer-grid th { + position: sticky; + top: 0; + z-index: 1; + white-space: nowrap; + background: var(--ops-surface-2); + color: var(--ops-muted); + font-weight: 600; + text-align: left; + } + .ops-designer-grid th, .ops-designer-grid td { + border-bottom: 1px solid var(--ops-border-soft); + padding: 5px 6px; + } + .ops-designer-grid tbody tr:hover { background: var(--ops-bg-active); } + .ops-designer-compact th, .ops-designer-compact td { padding-top: 2px; padding-bottom: 2px; } + .ops-designer-compact .ops-input { height: 24px; } .ops-modal-header { @apply border-b px-3.5 py-2 font-semibold; font-size: var(--ops-text-md); @@ -380,6 +444,11 @@ @apply flex justify-end gap-2 border-t px-3.5 py-2; border-color: var(--ops-border); } + .ops-ctx.ops-action-menu { min-width: 232px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; } + .ops-ctx.ops-action-menu button { display: flex; justify-content: space-between; gap: 20px; width: 100%; } + .ops-action-menu button:focus-visible { outline: 1px solid var(--ops-accent); outline-offset: -2px; background: var(--ops-surface-2); } + .ops-action-menu kbd { color: var(--ops-muted); font-size: 10px; } + .ops-menu-caption { padding: 5px 9px 7px; color: var(--ops-muted); font-size: 11px; border-bottom: 1px solid var(--ops-border-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 危险确认语句清单(前 3 条 + 折叠,修 C2) */ .ops-modal-items { @apply mt-2 space-y-1 rounded border px-2.5 py-2 font-mono whitespace-pre-wrap break-all; @@ -517,6 +586,8 @@ position: fixed; z-index: var(--ops-z-ctx); min-width: 140px; + max-height: calc(100vh - 16px); + overflow-y: auto; padding: var(--ops-space-1); border-radius: var(--ops-radius-md); border: 1px solid var(--ops-border); @@ -540,6 +611,20 @@ opacity: 0.4; cursor: not-allowed; } + .ops-ctx-group summary { + display: flex; + justify-content: space-between; + gap: 20px; + cursor: pointer; + list-style: none; + padding: 6px 10px; + border-radius: var(--ops-radius-sm); + font-size: var(--ops-text-sm); + } + .ops-ctx-group summary::-webkit-details-marker { display: none; } + .ops-ctx-group summary:hover { background: var(--ops-bg-active); } + .ops-ctx-group[open] summary span { transform: rotate(90deg); } + .ops-ctx-group button { padding-left: 22px; } .ops-ctx-sep { height: 1px; margin: 4px 6px; @@ -551,3 +636,69 @@ letter-spacing: 0.04em; color: var(--ops-muted); } + + /* Redis 键详情 · 行内四钮(悬停/选中才显;28×28 / 图标 16px) */ + .ops-redis-row-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + } + tr:hover .ops-redis-row-actions, + tr.ops-redis-row-active .ops-redis-row-actions, + .ops-redis-row-actions:focus-within { + opacity: 1; + pointer-events: auto; + } + .ops-redis-row-btn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ops-muted); + font-size: 16px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + } + .ops-redis-row-btn-copy:hover { + color: var(--ops-accent); + background: color-mix(in srgb, var(--ops-accent) 10%, transparent); + } + .ops-redis-row-btn-cmd { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: -0.04em; + } + .ops-redis-row-btn-del:hover { + color: var(--ops-danger); + background: color-mix(in srgb, var(--ops-danger) 10%, transparent); + } + .ops-redis-row-btn-flash { + color: var(--ops-success, #22c55e); + } + +.ops-tree-meta-note { padding: 4px 8px; font-size: 11px; color: var(--ops-muted); } +.ops-tree-meta-note button { color: var(--ops-accent); margin-left: 6px; } + +.ops-ai-composer { margin: 8px; padding: 10px; border: 1px solid var(--ops-border); border-radius: 10px; background: var(--ops-surface); } +.ops-ai-context { display: flex; gap: 8px; padding-bottom: 10px; font-size: 12px; color: var(--ops-muted); overflow-wrap: anywhere; } +.ops-ai-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } +.ops-ai-actions button { border: 1px solid var(--ops-border); border-radius: 6px; padding: 3px 7px; font-size: 11px; color: var(--ops-muted); } +.ops-ai-actions button:hover, .ops-ai-actions .ops-ai-action-active { color: var(--ops-accent); border-color: var(--ops-accent); background: var(--ops-accent-soft); } + +/* Ask | Agent 分段(阶段 2) */ +.ops-ai-mode { display: flex; gap: 0; margin-bottom: 8px; border: 1px solid var(--ops-border); border-radius: 6px; overflow: hidden; width: fit-content; } +.ops-ai-mode button { border: 0; background: transparent; padding: 3px 12px; font-size: 11px; color: var(--ops-muted); min-width: 52px; } +.ops-ai-mode button:hover { color: var(--ops-text); } +.ops-ai-mode .ops-ai-mode-active { color: var(--ops-accent); background: var(--ops-accent-soft); font-weight: 600; } +.ops-ai-steps { font-variant-numeric: tabular-nums; } +