同步完整源码至远程:扁平化重构现状 + 未入库源文件

- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、
  packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件
- 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源)
- 构建产物(bin/target/dist)与签名私钥仍按规则排除
This commit is contained in:
logan committed 2026-10-16 03:47:33 +08:00
1 parent 5c0be49217
commit 2c0bfafd8d
269 files changed
+10372 -16435

No files matched your search

+28 -20
View File
@@ -34,17 +34,17 @@ type AgentReq struct {
// 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"`
NeedConfirm bool `json:"needConfirm,omitempty"`
ConfirmSQL string `json:"confirmSql,omitempty"`
ConfirmBind string `json:"confirmBind,omitempty"`
Pending bool `json:"pending,omitempty"`
Result *AgentQueryResult `json:"result,omitempty"`
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"`
NeedConfirm bool `json:"needConfirm,omitempty"`
ConfirmSQL string `json:"confirmSql,omitempty"`
ConfirmBind string `json:"confirmBind,omitempty"`
Pending bool `json:"pending,omitempty"`
Result *AgentQueryResult `json:"result,omitempty"`
}
// AgentRes Agent 最终回答 + 步骤 + 运行结果契约。
@@ -60,7 +60,7 @@ type AgentRes struct {
Resumable bool `json:"resumable,omitempty"`
StepsBudget int `json:"stepsBudget,omitempty"`
// Trajectory 含 system/user/assistant.tool_calls/tool 的完整对话(供落盘续跑;JSON 默认省略大字段可选)
Trajectory []Message `json:"trajectory,omitempty"`
Trajectory []Message `json:"trajectory,omitempty"`
}
// RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌。
@@ -98,6 +98,10 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
req.SessionID = sessionID
}
rt.RunID = runID
if req.ConnectionID != "" {
rt.BoundConnectionID = req.ConnectionID
rt.BoundDatabase = req.Database
}
// Agent 路径禁止模型自行带确认令牌
rt.RejectCallerConfirm = true
@@ -106,14 +110,14 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
if req.Continue && len(req.Prior) > 0 {
msgs = append([]Message{}, req.Prior...)
hasSystem := false
for _, m := range msgs {
for i, m := range msgs {
if m.Role == "system" {
msgs[i].Content = buildAgentSystem(req, rt.Policy)
hasSystem = true
break
}
}
if !hasSystem {
msgs = append([]Message{{Role: "system", Content: buildAgentSystem(req)}}, msgs...)
msgs = append([]Message{{Role: "system", Content: buildAgentSystem(req, rt.Policy)}}, msgs...)
}
if prompt != "" {
msgs = append(msgs, Message{Role: "user", Content: prompt})
@@ -121,7 +125,7 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
msgs = TrimMessages(msgs, h)
} else {
msgs = []Message{
{Role: "system", Content: buildAgentSystem(req)},
{Role: "system", Content: buildAgentSystem(req, rt.Policy)},
{Role: "user", Content: prompt},
}
}
@@ -258,18 +262,22 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
return res, nil
}
func buildAgentSystem(req AgentReq) string {
func buildAgentSystem(req AgentReq, policy aitools.Policy) 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")
if policy.WritesEnabled() {
b.WriteString("2. 当前允许申请写操作:有写入意图时调用对应工具生成待确认命令;工具要求确认后必须停下,等待用户在界面确认,不能自行确认。\n")
} else {
b.WriteString("2. 当前策略为只读:仅能查询,写操作会被拒绝,也不会出现写操作确认。用户需要写入时,说明需先在设置 → MCP 选择使用默认权限,再提交任务并逐次确认。\n")
}
b.WriteString("3. 写操作若被拒绝并提示需要确认,向用户说明须在界面确认后执行;不要声称已写入。\n")
b.WriteString("4. 先确认 connection_id(可用 opsxdb_list_connections);用户给出偏好连接时优先用它。\n")
b.WriteString("4. 只使用当前会话绑定的 connection_id 和数据库;调用工具时传入它们,不要切换到其他连接或库。\n")
b.WriteString("5. 用中文简明回答;可执行 SQL 放在 ```sql 代码块中。\n")
b.WriteString("6. 工具结果可能截断,注意说明。\n")
if req.ConnectionID != "" {
b.WriteString(fmt.Sprintf("\n用户当前偏好连接 id: %s", req.ConnectionID))
b.WriteString(fmt.Sprintf("\n当前会话绑定连接 id: %s", req.ConnectionID))
if req.Driver != "" {
b.WriteString(fmt.Sprintf("(%s)", req.Driver))
}
+115
View File
@@ -183,6 +183,121 @@ func TestRunAgentRejectsWriteViaRuntime(t *testing.T) {
}
}
func TestAgentCannotLeaveCurrentMySQLConnectionOrDatabase(t *testing.T) {
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly, AllowedConnectionIDs: []string{"current", "other"}, DefaultLimit: 10, MaxLimit: 20})
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
return &aitools.SavedConnection{ID: id, Driver: "mysql", Database: "demo"}, nil
}
var queries int
rt.ExecSQL = func(_ context.Context, req db.ConnReq) db.ExecResult {
queries++
if req.Database != "demo" {
t.Fatalf("wrong database: %s", req.Database)
}
return db.ExecResult{Columns: []string{"id"}, Rows: []map[string]interface{}{{"id": 1}}, RowCount: 1}
}
rt.Audit = func(aitools.AuditEntry) {}
call := func(id, conn, database string) ToolCall {
args, _ := json.Marshal(map[string]any{"connection_id": conn, "database": database, "sql": "SELECT id FROM users LIMIT 1"})
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: aitools.ToolExecuteQuery, Arguments: string(args)}}
}
llm := &scriptedLLM{turns: []ToolsTurn{
{ToolCalls: []ToolCall{call("other", "other", "demo"), call("db", "current", "secret"), call("ok", "current", "demo")}},
{Content: "只查询了当前库。"},
}}
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "查询当前库", ConnectionID: "current", Driver: "mysql", Database: "demo"})
if err != nil {
t.Fatal(err)
}
if len(res.Steps) != 3 || res.Steps[0].OK || res.Steps[1].OK || !res.Steps[2].OK || queries != 1 {
t.Fatalf("Agent escaped scope or rejected valid query: steps=%+v queries=%d", res.Steps, queries)
}
}
func TestAgentRedisKeepOneKeyNeedsConfirmation(t *testing.T) {
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadWrite, AllowedConnectionIDs: []string{"redis"}})
rt.RejectCallerConfirm = true
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
return &aitools.SavedConnection{ID: id, Driver: "redis", Database: "0"}, nil
}
var executed []string
rt.ExecSQL = func(_ context.Context, req db.ConnReq) db.ExecResult {
executed = append(executed, req.SQL)
return db.ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{{"value": 3}}, RowCount: 1}
}
rt.Audit = func(aitools.AuditEntry) {}
call := func(id, command string) ToolCall {
args, _ := json.Marshal(map[string]any{"connection_id": "redis", "command": command, "confirm_danger": true})
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: aitools.ToolExecuteRedisCommand, Arguments: string(args)}}
}
llm := &scriptedLLM{turns: []ToolsTurn{
{ToolCalls: []ToolCall{call("read", "EXISTS key1 key2 key3")}},
{ToolCalls: []ToolCall{call("delete", "DEL key2 key3")}},
}}
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "key1 key2 key3 只保留 key1", ConnectionID: "redis", Driver: "redis", Database: "0"})
if err != nil {
t.Fatal(err)
}
if len(executed) != 1 || executed[0] != "EXISTS key1 key2 key3" || len(res.Steps) != 2 || !res.Steps[1].NeedConfirm {
t.Fatalf("删除必须停在用户确认前: executed=%v steps=%+v", executed, res.Steps)
}
if err := aitools.VerifyAgentBind("redis", "0", "DEL key2 key3", res.Steps[1].ConfirmBind); err != nil {
t.Fatal(err)
}
}
func TestAgentContinueRefreshesWritePolicy(t *testing.T) {
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly})
llm := &scriptedLLM{turns: []ToolsTurn{{Content: "当前只读"}}}
_, err := RunAgent(context.Background(), llm, rt, AgentReq{
Prompt: "继续", Continue: true,
Prior: []Message{{Role: "system", Content: "旧策略:允许写入"}, {Role: "user", Content: "只保留 key1"}},
})
if err != nil {
t.Fatal(err)
}
if len(llm.seen) != 1 || !strings.Contains(llm.seen[0][0].Content, "当前策略为只读") || strings.Contains(llm.seen[0][0].Content, "旧策略") {
t.Fatalf("续聊必须使用当前权限: %+v", llm.seen)
}
}
func TestAgentRedisReadThenWriteNeedsCurrentDBConfirmation(t *testing.T) {
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadWrite, AllowedConnectionIDs: []string{"redis"}, DefaultLimit: 10, MaxLimit: 20})
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
return &aitools.SavedConnection{ID: id, Driver: "redis", Database: "0"}, nil
}
var commands []string
rt.ExecSQL = func(_ context.Context, req db.ConnReq) db.ExecResult {
if req.Database != "1" {
t.Fatalf("Redis DB drifted: %s", req.Database)
}
commands = append(commands, req.SQL)
return db.ExecResult{Columns: []string{"value"}, Rows: []map[string]interface{}{{"value": "old"}}, RowCount: 1}
}
rt.Audit = func(aitools.AuditEntry) {}
call := func(id, cmd string) ToolCall {
args, _ := json.Marshal(map[string]any{"connection_id": "redis", "command": cmd})
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: aitools.ToolExecuteRedisCommand, Arguments: string(args)}}
}
llm := &scriptedLLM{turns: []ToolsTurn{
{ToolCalls: []ToolCall{call("read", "GET smoke:key")}},
{ToolCalls: []ToolCall{call("write", "SET smoke:key new")}},
}}
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "读取然后修改键", ConnectionID: "redis", Driver: "redis", Database: "1"})
if err != nil {
t.Fatal(err)
}
if len(commands) != 1 || commands[0] != "GET smoke:key" || len(res.Steps) != 2 || !res.Steps[0].OK || !res.Steps[1].NeedConfirm {
t.Fatalf("unexpected Redis execution/confirmation: commands=%v steps=%+v", commands, res.Steps)
}
if err := aitools.VerifyAgentBind("redis", "1", "SET smoke:key new", res.Steps[1].ConfirmBind); err != nil {
t.Fatal(err)
}
if err := aitools.VerifyAgentBind("redis", "0", "SET smoke:key new", res.Steps[1].ConfirmBind); err == nil {
t.Fatal("other DB must not reuse the confirmation")
}
}
func TestParseToolsTurn(t *testing.T) {
content := "hi"
raw, _ := json.Marshal(map[string]any{
+52
View File
@@ -1,12 +1,18 @@
package ai
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestValidateEndpoint(t *testing.T) {
if err := validateEndpoint("https://api.deepseek.com/v1", false); err != nil {
t.Fatalf("https 应直接放行: %v", err)
@@ -98,3 +104,49 @@ func TestBuildMessagesCoversActions(t *testing.T) {
t.Fatal("user 消息应包含 SQL")
}
}
func TestAskChatsWithoutClaimingDatabaseExecution(t *testing.T) {
request := ChatReq{Action: "ask", Prompt: "这个库里有哪些表?", Driver: "mysql", Database: "demo"}
msgs := BuildMessages(request)
if !strings.Contains(msgs[0].Content, "demo(mysql)") || !strings.Contains(msgs[0].Content, "不要声称已经查询") {
t.Fatalf("Ask should carry context without pretending to query: %s", msgs[0].Content)
}
if strings.Contains(msgs[0].Content, "只输出可执行 SQL") {
t.Fatal("Ask must not force a SQL response")
}
generate := BuildMessages(ChatReq{Action: "generate", Prompt: "查 users", Driver: "mysql", Database: "demo"})
if !strings.Contains(generate[0].Content, "生成一条 mysql 方言的 SQL") {
t.Fatalf("SQL generation lost its dedicated instruction: %s", generate[0].Content)
}
offline := chatStub(request)
if offline.SQL != "" || !strings.Contains(offline.Content, "离线体验无法回答") {
t.Fatalf("offline Ask must not pretend to generate or query: %+v", offline)
}
}
func TestAskRemoteRequestIsChatAndReturnsNoUnrequestedSQL(t *testing.T) {
p := NewRemoteProvider(Config{Provider: "deepseek", Protocol: ProtoChat, BaseURL: "https://api.example.test/v1", APIKey: "test-secret", Model: "test-model"})
p.hc = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/v1/chat/completions" {
t.Fatalf("unexpected endpoint: %s", req.URL)
}
if req.Header.Get("Authorization") != "Bearer test-secret" {
t.Fatal("missing auth")
}
var payload struct {
Messages []Message `json:"messages"`
Tools []any `json:"tools"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if len(payload.Messages) != 2 || len(payload.Tools) != 0 || !strings.Contains(payload.Messages[0].Content, "demo(mysql)") || strings.Contains(payload.Messages[0].Content, "只输出可执行 SQL") {
t.Fatalf("Ask should be contextual chat without tools: %+v", payload)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"当前表结构上下文不足,无法确认表名。"}}]}`)), Header: make(http.Header)}, nil
})}
res, err := p.Complete(context.Background(), ChatReq{Action: "ask", Prompt: "有哪些表?", Driver: "mysql", Database: "demo"})
if err != nil || res.SQL != "" || !strings.Contains(res.Content, "无法确认表名") {
t.Fatalf("Ask must return a plain answer without invented SQL: %+v err=%v", res, err)
}
}
+3 -1
View File
@@ -17,8 +17,10 @@ func BuildMessages(req ChatReq) []Message {
sys := "你是资深数据库工程师助手,服务于数据库客户端 OpsxDB。回答使用中文,SQL 代码放在 ```sql 代码块中。"
var task string
switch strings.ToLower(req.Action) {
case "generate", "ask":
case "generate":
task = fmt.Sprintf("根据用户描述生成一条 %s 方言的 SQL。只输出可执行 SQL 与极简说明。", driver)
case "ask":
task = "回答用户关于当前数据库、数据或 SQL 的问题;只有用户明确要求编写 SQL 时才给出 SQL。你没有调用数据库工具,不要声称已经查询或修改数据。"
case "explain":
task = "逐部分解释用户给出的 SQL:访问路径、索引使用、潜在代价。"
case "optimize":
+6 -1
View File
@@ -28,7 +28,12 @@ func chatStub(req ChatReq) ChatRes {
prompt := strings.TrimSpace(req.Prompt)
switch strings.ToLower(req.Action) {
case "generate", "ask":
case "ask":
if prompt == "" {
prompt = "当前问题"
}
return ChatRes{Content: fmt.Sprintf("离线体验无法回答「%s」。请在设置 → 大模型配置 AI 供应商;如需试写 SQL,可切换为「生成 SQL」。", prompt)}
case "generate":
q := prompt
if q == "" {
q = "示例查询"