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 ( +
正在读取 AI 配置…
; + if (loadError) return