feat(ai): 阶段 2 Ask|Agent 共用 aitools Runtime 真查库
- 侧栏 Ask|Agent 分段;Ask 仍走 /api/ai/chat,Agent 走 /api/ai/agent - RunAgent 多轮 tool call → 进程内 aitools(与 MCP 同源只读/白名单/审计) - 回复下步骤时间线;openai-chat tools(DeepSeek);agent-accept 冒烟
This commit is contained in:
1 parent
b8134c4503
commit
cd319ce7bd
14 files changed
+1270
-182
No files matched your search
+16
-2
@@ -14,7 +14,7 @@
|
|||||||
| 写操作 | **P0 纯只读**;「确认后才可写」复用现有 dangerConfirm,进 **P1**,MCP 首版不可写 |
|
| 写操作 | **P0 纯只读**;「确认后才可写」复用现有 dangerConfirm,进 **P1**,MCP 首版不可写 |
|
||||||
| 代码边界 | 只动根级 `src` + `server`;禁止 `packages/ui`(死树) |
|
| 代码边界 | 只动根级 `src` + `server`;禁止 `packages/ui`(死树) |
|
||||||
| Redis | 对齐现有精确 TYPE / 集群 SCAN,不另起搜键 |
|
| Redis | 对齐现有精确 TYPE / 集群 SCAN,不另起搜键 |
|
||||||
| UI | P0 无新重界面;Ask\|Agent Seg / 步骤时间线 / MCP 状态点属阶段 2–4(见文末设计备忘) |
|
| UI | P0 无新重界面;Ask\|Agent Seg / 步骤时间线已落地阶段 2;MCP 状态点属后续 |
|
||||||
| 冲突 | 与未推「补偿包」冲突先停报,勿 silently 覆盖 |
|
| 冲突 | 与未推「补偿包」冲突先停报,勿 silently 覆盖 |
|
||||||
|
|
||||||
| 问题 | 结论 |
|
| 问题 | 结论 |
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
- [x] 只读模式下 `DELETE`/`DROP`/写 SQL 被拒;审计写 `mcp-audit.jsonl`
|
- [x] 只读模式下 `DELETE`/`DROP`/写 SQL 被拒;审计写 `mcp-audit.jsonl`
|
||||||
- [x] 通路代码就绪;单测用 sqlite 覆盖 list/describe/exec;MySQL/PG 真库待桌面验收
|
- [x] 通路代码就绪;单测用 sqlite 覆盖 list/describe/exec;MySQL/PG 真库待桌面验收
|
||||||
- [x] Redis 读白名单 + FLUSH*/KEYS/写拒(单测);真库待验
|
- [x] Redis 读白名单 + FLUSH*/KEYS/写拒(单测);真库待验
|
||||||
- [ ] 侧栏 Agent 调同一 runtime(**P1 / 设计阶段 2,本轮不做 UI**)
|
- [x] 侧栏 Agent 调同一 runtime(阶段 2:Ask|Agent + `/api/ai/agent` + 步骤时间线)
|
||||||
- [x] 文档:`docs/mcp-cursor.example.json` + 本文件
|
- [x] 文档:`docs/mcp-cursor.example.json` + 本文件
|
||||||
|
|
||||||
## 参考路径(本机)
|
## 参考路径(本机)
|
||||||
@@ -193,6 +193,20 @@ go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp
|
|||||||
落地次序:MCP 开关+白名单 → Ask|Agent Seg → 时间线与插入 → 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
|
### 真库验收 2026-10-15
|
||||||
|
|
||||||
MySQL 3306 demo + Redis 6379:`go run ./cmd/opsxdb-mcp-accept/` 全过。实践文档:`~/Downloads/AI-Agent接入GUI工具开发实践.md`。
|
MySQL 3306 demo + Redis 6379:`go run ./cmd/opsxdb-mcp-accept/` 全过。实践文档:`~/Downloads/AI-Agent接入GUI工具开发实践.md`。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -33,17 +33,10 @@ func main() {
|
|||||||
server.WithToolCapabilities(true),
|
server.WithToolCapabilities(true),
|
||||||
)
|
)
|
||||||
|
|
||||||
register := func(name, desc string, props map[string]any, required []string) {
|
for _, spec := range aitools.ToolSpecs() {
|
||||||
schema := map[string]any{
|
raw, _ := json.Marshal(spec.InputSchema())
|
||||||
"type": "object",
|
t := mcp.NewToolWithRawSchema(spec.Name, spec.Description, raw)
|
||||||
"properties": props,
|
toolName := spec.Name
|
||||||
}
|
|
||||||
if len(required) > 0 {
|
|
||||||
schema["required"] = required
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(schema)
|
|
||||||
t := mcp.NewToolWithRawSchema(name, desc, raw)
|
|
||||||
toolName := name
|
|
||||||
s.AddTool(t, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
s.AddTool(t, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := req.GetArguments()
|
args := req.GetArguments()
|
||||||
out, err := rt.Call(ctx, toolName, args)
|
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 {
|
if err := server.ServeStdio(s); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "opsxdb-mcp: %v\n", err)
|
fmt.Fprintf(os.Stderr, "opsxdb-mcp: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -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]) + "…"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,25 @@ package ai
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
// Message 对话消息(OpenAI 兼容协议)。
|
// Message 对话消息(OpenAI 兼容协议)。Agent 多轮可带 tool_calls / tool 角色。
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
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 可选字段。
|
// ChatReq 在 stub 时代字段上演进,前端契约不变;新增 schema/model 可选字段。
|
||||||
@@ -32,14 +47,14 @@ type Provider interface {
|
|||||||
Complete(ctx context.Context, req ChatReq) (ChatRes, error)
|
Complete(ctx context.Context, req ChatReq) (ChatRes, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default 按当前配置选择 Provider;未配置 API Key 时回落 stub(离线占位)。
|
// Default 按当前配置选择 Provider;stub 时或远端配置缺 key(免鉴权除外)回落 stub(离线占位)。
|
||||||
func Default() Provider {
|
func Default() Provider {
|
||||||
cfg := LoadConfig()
|
cfg := LoadConfig()
|
||||||
switch cfg.Provider {
|
if cfg.Provider == "" || cfg.Provider == "stub" {
|
||||||
case "openai":
|
return stubProvider{}
|
||||||
if cfg.APIKey != "" {
|
|
||||||
return NewOpenAIProvider(cfg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return stubProvider{}
|
if cfg.APIKey == "" && cfg.AuthStyle != AuthNone {
|
||||||
|
return stubProvider{}
|
||||||
|
}
|
||||||
|
return NewRemoteProvider(cfg)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,6 +11,18 @@ AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻
|
|||||||
| 方言 | MySQL ↔ PostgreSQL 等跨方言转换 |
|
| 方言 | MySQL ↔ PostgreSQL 等跨方言转换 |
|
||||||
| 样例 | 按表结构生成示例查询 |
|
| 样例 | 按表结构生成示例查询 |
|
||||||
|
|
||||||
|
|
||||||
|
## Ask | Agent(阶段 2)
|
||||||
|
|
||||||
|
侧栏输入框上方可切换模式:
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| **Ask** | 只生成/解释/优化 SQL,**不执行**数据库工具 |
|
||||||
|
| **Agent** | 多轮调用与 MCP 同源的只读工具(列连接/库表、描述表、只读 SQL / Redis 读命令),步骤时间线挂在回复下 |
|
||||||
|
|
||||||
|
Agent 需要已配置的 OpenAI 兼容模型(如 DeepSeek)。连接须在 `~/.opsxdb/mcp.json` 白名单内;写操作一律拒绝。
|
||||||
|
|
||||||
## 使用方式
|
## 使用方式
|
||||||
|
|
||||||
1. 连接库后打开右侧 AI 面板;
|
1. 连接库后打开右侧 AI 面板;
|
||||||
@@ -19,9 +31,11 @@ AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻
|
|||||||
|
|
||||||
> **提示**:生成的 SQL 依旧走危险语句确认流程 —— AI 产出与手写 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 功能;
|
||||||
- 使用云端模型时在界面上明确标识。
|
- 使用云端模型时在界面上明确标识。
|
||||||
|
|
||||||
|
AI 自动跟随 SQL 编辑器当前连接与数据库(Redis 显示 DB 编号,集群固定 DB0),输入框上方可核对目标。直接点选生成 SQL、解释、优化、修复或样例后发送;Shift+Enter 换行。切换标签不会把晚到的生成结果写入新标签,等待期间修改过的 SQL 也不会被自动覆盖。
|
||||||
@@ -474,6 +474,31 @@ func NewRouter(token string) *gin.Engine {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Connections)})
|
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) {
|
api.POST("/ai/generate", func(c *gin.Context) {
|
||||||
var req ai.ChatReq
|
var req ai.ChatReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
+200
-103
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
|
import { callAi, callAiAgent, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
|
||||||
import { useWorkspaceStore } from "./store";
|
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 }[] = [
|
const ACTIONS: { id: AiAction; label: string }[] = [
|
||||||
{ id: "generate", label: "生成" },
|
{ id: "generate", label: "生成" },
|
||||||
{ id: "explain", label: "解释" },
|
{ id: "explain", label: "解释" },
|
||||||
{ id: "optimize", label: "优化" },
|
{ id: "optimize", label: "优化" },
|
||||||
{ id: "fix", label: "修复" },
|
{ id: "fix", label: "修复" },
|
||||||
{ id: "dialect", label: "方言" },
|
|
||||||
{ id: "sample", 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 (
|
||||||
|
<div className="ops-ai-steps mt-1.5 border-t pt-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mb-0.5 text-[10px] hover:underline"
|
||||||
|
style={{ color: "var(--ops-muted)" }}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
{open ? "收起" : "展开"}步骤 · {steps.length}
|
||||||
|
</button>
|
||||||
|
<ul className="m-0 list-none space-y-0.5 p-0">
|
||||||
|
{shown.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-1.5 text-[10px] leading-snug"
|
||||||
|
style={{ color: "var(--ops-muted)" }}
|
||||||
|
title={s.args || s.error || s.summary}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mt-0.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ background: s.ok ? "var(--ops-success, #22c55e)" : "var(--ops-danger, #ef4444)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 break-all">
|
||||||
|
<span style={{ color: "var(--ops-text)" }}>{s.tool.replace(/^opsxdb_/, "")}</span>
|
||||||
|
{" · "}
|
||||||
|
{s.summary || s.error || (s.ok ? "ok" : "fail")}
|
||||||
|
{s.ms != null ? ` · ${s.ms}ms` : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C4:AI 设置(预设 Provider / Base URL / API Key / 模型 + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
|
||||||
|
export function AiSettings({ onClose }: { onClose: () => void }) {
|
||||||
const notify = useWorkspaceStore((s) => s.notify);
|
const notify = useWorkspaceStore((s) => s.notify);
|
||||||
const set = useWorkspaceStore((s) => s.set);
|
const set = useWorkspaceStore((s) => s.set);
|
||||||
const [provider, setProvider] = useState("openai");
|
const [provider, setProvider] = useState("stub");
|
||||||
const [baseURL, setBaseURL] = useState("");
|
const [baseURL, setBaseURL] = useState("");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
|
const [protocol, setProtocol] = useState<AiProtocol>("openai-chat");
|
||||||
|
const [authStyle, setAuthStyle] = useState<AiAuthStyle>("bearer");
|
||||||
|
const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
|
||||||
const [hasKey, setHasKey] = useState(false);
|
const [hasKey, setHasKey] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
const [reload, setReload] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError("");
|
||||||
getAiConfig()
|
getAiConfig()
|
||||||
.then((cfg) => {
|
.then((cfg) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setProvider(cfg.provider || "stub");
|
setProvider(cfg.provider || "stub");
|
||||||
setBaseURL(cfg.baseURL || "");
|
setBaseURL(cfg.baseURL || "");
|
||||||
setModel(cfg.model || "");
|
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);
|
setHasKey(!!cfg.hasKey);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { if (alive) setLoadError("无法读取 AI 配置,请确认本机服务已启动后重试。"); })
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
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 () => {
|
const save = async () => {
|
||||||
|
if (provider !== "stub" && !baseURL.trim()) {
|
||||||
|
notify({ kind: "error", title: "AI 配置", message: "请填写 Base URL,或选择一个预设。", mode: "modal" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await saveAiConfig({
|
await saveAiConfig({
|
||||||
provider,
|
provider,
|
||||||
baseURL: baseURL.trim(),
|
baseURL: baseURL.trim(),
|
||||||
// 留空表示不改动已保存的 key(服务端掩码不回传明文)
|
// 留空表示不改动已保存的 key(服务端掩码不回传明文,保存时保留旧值)
|
||||||
apiKey: apiKey.trim() || undefined,
|
apiKey: apiKey.trim() || undefined,
|
||||||
model: model.trim(),
|
model: model.trim(),
|
||||||
|
protocol,
|
||||||
|
authStyle,
|
||||||
|
allowInsecureHttp,
|
||||||
});
|
});
|
||||||
if (model.trim()) set("aiModel", model.trim());
|
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 });
|
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -160,41 +236,68 @@ function AiSettings({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) return <p role="status" className="text-xs">正在读取 AI 配置…</p>;
|
||||||
|
if (loadError) return <div role="alert" className="text-xs">{loadError}<button type="button" className="ops-btn-secondary mt-2" onClick={() => setReload((n) => n + 1)}>重试</button></div>;
|
||||||
|
|
||||||
|
const presetModels = findAiPreset(provider)?.models ?? [];
|
||||||
|
const isLocalHttp = baseURL.trim().startsWith("http://");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
<fieldset disabled={busy} className="min-w-0 space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<select
|
<select
|
||||||
className="ops-input py-0.5 text-[11px]"
|
className="ops-input py-0.5 text-[11px]"
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={(e) => setProvider(e.target.value)}
|
onChange={(e) => applyPreset(e.target.value)}
|
||||||
aria-label="Provider"
|
aria-label="Provider 预设"
|
||||||
>
|
>
|
||||||
<option value="stub">stub(离线占位)</option>
|
<option value="stub">stub(离线占位)</option>
|
||||||
<option value="openai">openai(OpenAI 兼容)</option>
|
{AI_PRESETS.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}({p.desc})</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
||||||
placeholder="模型,如 gpt-4o-mini"
|
placeholder="模型 ID"
|
||||||
value={model}
|
value={model}
|
||||||
|
list="ai-model-options"
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
aria-label="模型"
|
aria-label="模型"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="ai-model-options">
|
||||||
|
{presetModels.map((m) => <option key={m} value={m} />)}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<input
|
{provider !== "stub" && (
|
||||||
className="ops-input w-full py-0.5 text-[11px]"
|
<>
|
||||||
placeholder="Base URL,如 https://api.openai.com/v1"
|
<input
|
||||||
value={baseURL}
|
className="ops-input w-full py-0.5 text-[11px]"
|
||||||
onChange={(e) => setBaseURL(e.target.value)}
|
placeholder="Base URL,如 https://api.deepseek.com/v1"
|
||||||
aria-label="Base URL"
|
value={baseURL}
|
||||||
/>
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
<input
|
aria-label="Base URL"
|
||||||
className="ops-input w-full py-0.5 text-[11px]"
|
/>
|
||||||
type="password"
|
<input
|
||||||
placeholder={hasKey ? "API Key(已配置,留空不改动)" : "API Key"}
|
className="ops-input w-full py-0.5 text-[11px]"
|
||||||
value={apiKey}
|
type="password"
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
placeholder={hasKey || authStyle === "none" ? "API Key(已配置,留空不改动)" : "API Key"}
|
||||||
aria-label="API Key"
|
value={apiKey}
|
||||||
/>
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
aria-label="API Key"
|
||||||
|
disabled={authStyle === "none"}
|
||||||
|
/>
|
||||||
|
{isLocalHttp && (
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowInsecureHttp}
|
||||||
|
onChange={(e) => setAllowInsecureHttp(e.target.checked)}
|
||||||
|
/>
|
||||||
|
允许 HTTP(本地/内网服务,如 Ollama、vLLM;默认仅放行 HTTPS)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
|
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
|
||||||
保存
|
保存
|
||||||
@@ -206,7 +309,7 @@ function AiSettings({ onClose }: { onClose: () => void }) {
|
|||||||
收起
|
收起
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +318,7 @@ export function AiPanel() {
|
|||||||
const ask = useWorkspaceStore((s) => s.aiAsk);
|
const ask = useWorkspaceStore((s) => s.aiAsk);
|
||||||
const model = useWorkspaceStore((s) => s.aiModel);
|
const model = useWorkspaceStore((s) => s.aiModel);
|
||||||
const busy = useWorkspaceStore((s) => s.aiBusy);
|
const busy = useWorkspaceStore((s) => s.aiBusy);
|
||||||
|
const aiMode = useWorkspaceStore((s) => s.aiMode);
|
||||||
const connections = useWorkspaceStore((s) => s.connections);
|
const connections = useWorkspaceStore((s) => s.connections);
|
||||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||||
const tabs = useWorkspaceStore((s) => s.tabs);
|
const tabs = useWorkspaceStore((s) => s.tabs);
|
||||||
@@ -222,18 +326,14 @@ export function AiPanel() {
|
|||||||
const result = useWorkspaceStore((s) => s.result);
|
const result = useWorkspaceStore((s) => s.result);
|
||||||
const set = useWorkspaceStore((s) => s.set);
|
const set = useWorkspaceStore((s) => s.set);
|
||||||
const pushAi = useWorkspaceStore((s) => s.pushAi);
|
const pushAi = useWorkspaceStore((s) => s.pushAi);
|
||||||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
const updateTabSqlIfUnchanged = useWorkspaceStore((s) => s.updateTabSqlIfUnchanged);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
/** C4:真实配置的模型清单(来自 /api/ai/config) */
|
|
||||||
const [cfgModel, setCfgModel] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
getAiConfig()
|
getAiConfig()
|
||||||
.then((cfg) => {
|
.then((cfg) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (cfg.model) {
|
if (cfg.model) {
|
||||||
setCfgModel(cfg.model);
|
|
||||||
const cur = useWorkspaceStore.getState().aiModel;
|
const cur = useWorkspaceStore.getState().aiModel;
|
||||||
if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model);
|
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 conn = connections.find((c) => c.id === activeConnectionId);
|
||||||
const tab = tabs.find((t) => t.id === activeTabId);
|
const tab = tabs.find((t) => t.id === activeTabId);
|
||||||
const sql = tab?.sql ?? "";
|
const sql = tab?.sql ?? "";
|
||||||
|
const database = editorDatabase(conn, tab);
|
||||||
|
const [action, setAction] = useState<AiAction>("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();
|
const text = (prompt ?? ask).trim();
|
||||||
if (action === "ask" && !text) return;
|
if (busy || !conn || ((action === "ask" || action === "generate") && !text)) return;
|
||||||
set("aiBusy", true);
|
set("aiBusy", true);
|
||||||
if (action === "ask" || action === "generate") {
|
if (action === "ask" || action === "generate") {
|
||||||
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
|
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}`);
|
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts)
|
const schema = schemaContextFor(conn?.id, database);
|
||||||
const schema = schemaContextFor(conn?.id, conn?.database || tab?.database);
|
|
||||||
const res = await callAi({
|
const res = await callAi({
|
||||||
action,
|
action,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
sql,
|
sql,
|
||||||
driver: conn?.driver,
|
driver: conn?.driver,
|
||||||
database: conn?.database || tab?.database,
|
database: database,
|
||||||
error: result?.error,
|
error: result?.error,
|
||||||
schema: schema || undefined,
|
schema: schema || undefined,
|
||||||
model: model && model !== "opsxdb-stub" ? model : undefined,
|
model: model && model !== "opsxdb-stub" ? model : undefined,
|
||||||
});
|
});
|
||||||
pushAi("assistant", res.content);
|
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", "");
|
if (action === "ask" || action === "generate") set("aiAsk", "");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(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 (
|
return (
|
||||||
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
||||||
<div className="ops-section-label justify-between border-b gap-1">
|
<div className="ops-section-label justify-between border-b gap-1">
|
||||||
<span>AI</span>
|
<span>新对话</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<select
|
|
||||||
className="ops-input h-6 py-0 text-[10px] normal-case tracking-normal"
|
|
||||||
value={model}
|
|
||||||
onChange={(e) => set("aiModel", e.target.value)}
|
|
||||||
title="模型(来自 /api/ai/config,可在设置中修改)"
|
|
||||||
>
|
|
||||||
{modelOptions.map((m) => (
|
|
||||||
<option key={m} value={m}>
|
|
||||||
{m}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
||||||
@@ -325,52 +442,15 @@ export function AiPanel() {
|
|||||||
|
|
||||||
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-1 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
|
||||||
<select
|
|
||||||
className="ops-input py-0.5 text-[11px]"
|
|
||||||
value={activeConnectionId ?? ""}
|
|
||||||
onChange={(e) => set("activeConnectionId", e.target.value || null)}
|
|
||||||
>
|
|
||||||
<option value="">连接</option>
|
|
||||||
{connections.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
className="ops-input py-0.5 text-[11px]"
|
|
||||||
placeholder="库"
|
|
||||||
value={conn?.database ?? ""}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex flex-wrap gap-1 border-b px-1.5 py-1"
|
|
||||||
style={{ borderColor: "var(--ops-border)" }}
|
|
||||||
>
|
|
||||||
{ACTIONS.map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.id}
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void run(a.id, ask)}
|
|
||||||
className="ops-btn-ai"
|
|
||||||
title={a.label}
|
|
||||||
>
|
|
||||||
{a.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div
|
<div
|
||||||
className="grid h-full place-items-center px-2 text-center text-[12px]"
|
className="grid h-full place-items-center px-2 text-center text-[12px]"
|
||||||
style={{ color: "var(--ops-muted)" }}
|
style={{ color: "var(--ops-muted)" }}
|
||||||
>
|
>
|
||||||
描述需求,或点上方快捷操作
|
{aiMode === "agent"
|
||||||
|
? "Agent 会真查白名单里的库(只读)。试试:列出 demo 有哪些表"
|
||||||
|
: "聊聊你想查什么,我来写 SQL"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
@@ -396,29 +476,46 @@ export function AiPanel() {
|
|||||||
{m.role === "user" ? "你" : "AI"} · {m.at}
|
{m.role === "user" ? "你" : "AI"} · {m.at}
|
||||||
</div>
|
</div>
|
||||||
<MessageContent content={m.content} />
|
<MessageContent content={m.content} />
|
||||||
|
{m.role === "assistant" && m.steps && m.steps.length > 0 ? <AgentSteps steps={m.steps} /> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
<div className="ops-ai-composer">
|
||||||
|
<div className="ops-ai-mode" role="group" aria-label="Ask 或 Agent">
|
||||||
|
<button type="button" aria-pressed={aiMode === "ask"} className={aiMode === "ask" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("ask")} disabled={busy}>Ask</button>
|
||||||
|
<button type="button" aria-pressed={aiMode === "agent"} className={aiMode === "agent" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("agent")} disabled={busy}>Agent</button>
|
||||||
|
</div>
|
||||||
|
<div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库">{conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}{aiMode === "agent" ? " · 只读工具" : ""}</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input
|
<textarea rows={3}
|
||||||
className="ops-input flex-1"
|
className="ops-input min-w-0 flex-1 resize-none"
|
||||||
placeholder="描述查询…"
|
placeholder={aiMode === "agent" ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行" : "描述你想查询什么…"}
|
||||||
value={ask}
|
value={ask}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => set("aiAsk", e.target.value)}
|
onChange={(e) => set("aiAsk", e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void run("ask");
|
send();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="ops-btn-primary" disabled={busy} onClick={() => void run("ask")}>
|
<button type="button" className="ops-btn-primary" disabled={busy || (aiMode === "agent" && !ask.trim())} onClick={send}>
|
||||||
{busy ? "…" : "发送"}
|
{busy ? "…" : "发送"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{aiMode === "ask" ? (
|
||||||
|
<div className="ops-ai-actions" role="group" aria-label="AI 操作">
|
||||||
|
{ACTIONS.map(a => <button type="button" key={a.id} aria-pressed={action === a.id}
|
||||||
|
className={action === a.id ? "ops-ai-action-active" : undefined}
|
||||||
|
onClick={() => setAction(a.id)}>{a.id === "generate" ? "生成 SQL" : a.label}</button>)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||||
|
Agent 经 aitools 只读查库(与 MCP 同源);写操作一律拒绝。连接须在 ~/.opsxdb/mcp.json 白名单。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+27
@@ -525,6 +525,33 @@ export async function callAi(payload: {
|
|||||||
return post<{ content: string; sql?: string }>("/api/ai/chat", 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<AiAgentRes>("/api/ai/agent", payload);
|
||||||
|
}
|
||||||
|
|
||||||
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
|
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
|
||||||
export type AiConfig = {
|
export type AiConfig = {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
+19
-3
@@ -189,13 +189,26 @@ export type ConfirmRequest = {
|
|||||||
items?: string[];
|
items?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AiAgentStepView = {
|
||||||
|
tool: string;
|
||||||
|
args?: string;
|
||||||
|
ok: boolean;
|
||||||
|
summary: string;
|
||||||
|
error?: string;
|
||||||
|
ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AiMessage = {
|
export type AiMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
content: string;
|
content: string;
|
||||||
at: string;
|
at: string;
|
||||||
|
/** Agent 工具步骤时间线(仅 assistant) */
|
||||||
|
steps?: AiAgentStepView[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AiChatMode = "ask" | "agent";
|
||||||
|
|
||||||
export type ViewMode = "workbench" | "picker";
|
export type ViewMode = "workbench" | "picker";
|
||||||
export type PickerMode = "list" | "drivers";
|
export type PickerMode = "list" | "drivers";
|
||||||
|
|
||||||
@@ -236,6 +249,8 @@ type State = {
|
|||||||
aiAsk: string;
|
aiAsk: string;
|
||||||
aiModel: string;
|
aiModel: string;
|
||||||
aiBusy: boolean;
|
aiBusy: boolean;
|
||||||
|
/** Ask=不调工具;Agent=多轮 aitools */
|
||||||
|
aiMode: AiChatMode;
|
||||||
status: string;
|
status: string;
|
||||||
notifyModal: NotifyPayload | null;
|
notifyModal: NotifyPayload | null;
|
||||||
notifyToasts: NotifyPayload[];
|
notifyToasts: NotifyPayload[];
|
||||||
@@ -305,7 +320,7 @@ type State = {
|
|||||||
updateActiveSql: (sql: string) => void;
|
updateActiveSql: (sql: string) => void;
|
||||||
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
|
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
|
||||||
updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean;
|
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;
|
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
@@ -465,6 +480,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
|||||||
aiAsk: "",
|
aiAsk: "",
|
||||||
aiModel: "opsxdb-stub",
|
aiModel: "opsxdb-stub",
|
||||||
aiBusy: false,
|
aiBusy: false,
|
||||||
|
aiMode: "ask",
|
||||||
status: "就绪",
|
status: "就绪",
|
||||||
notifyModal: null,
|
notifyModal: null,
|
||||||
notifyToasts: [],
|
notifyToasts: [],
|
||||||
@@ -878,11 +894,11 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
pushAi: (role, content) =>
|
pushAi: (role, content, steps) =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
aiMessages: [
|
aiMessages: [
|
||||||
...s.aiMessages,
|
...s.aiMessages,
|
||||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() },
|
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString(), steps },
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|||||||
+154
-3
@@ -90,6 +90,7 @@
|
|||||||
.ops-shell {
|
.ops-shell {
|
||||||
background: var(--ops-bg);
|
background: var(--ops-bg);
|
||||||
color: var(--ops-text);
|
color: var(--ops-text);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ops-input {
|
.ops-input {
|
||||||
@@ -162,11 +163,37 @@
|
|||||||
.ops-btn-ai:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ops-btn-ai:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
.ops-tabbar {
|
.ops-tabbar {
|
||||||
@apply flex shrink-0 items-stretch gap-0.5 overflow-x-auto border-b px-1;
|
@apply flex shrink-0 items-end gap-1 overflow-x-auto border-b px-2;
|
||||||
height: var(--ops-tab-h);
|
height: 38px;
|
||||||
border-color: var(--ops-border);
|
border-color: var(--ops-border);
|
||||||
background: var(--ops-surface-2);
|
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 {
|
.ops-empty {
|
||||||
@apply flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center;
|
@apply flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center;
|
||||||
color: var(--ops-muted);
|
color: var(--ops-muted);
|
||||||
@@ -293,9 +320,20 @@
|
|||||||
background: var(--ops-bg-active) !important;
|
background: var(--ops-bg-active) !important;
|
||||||
}
|
}
|
||||||
.ops-grid th {
|
.ops-grid th {
|
||||||
position: relative;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
user-select: none;
|
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 {
|
.ops-grid th .ops-col-resize {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -365,6 +403,32 @@
|
|||||||
box-shadow: var(--ops-shadow-modal);
|
box-shadow: var(--ops-shadow-modal);
|
||||||
font-size: var(--ops-text-md);
|
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 {
|
.ops-modal-header {
|
||||||
@apply border-b px-3.5 py-2 font-semibold;
|
@apply border-b px-3.5 py-2 font-semibold;
|
||||||
font-size: var(--ops-text-md);
|
font-size: var(--ops-text-md);
|
||||||
@@ -380,6 +444,11 @@
|
|||||||
@apply flex justify-end gap-2 border-t px-3.5 py-2;
|
@apply flex justify-end gap-2 border-t px-3.5 py-2;
|
||||||
border-color: var(--ops-border);
|
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) */
|
/* 危险确认语句清单(前 3 条 + 折叠,修 C2) */
|
||||||
.ops-modal-items {
|
.ops-modal-items {
|
||||||
@apply mt-2 space-y-1 rounded border px-2.5 py-2 font-mono whitespace-pre-wrap break-all;
|
@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;
|
position: fixed;
|
||||||
z-index: var(--ops-z-ctx);
|
z-index: var(--ops-z-ctx);
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
padding: var(--ops-space-1);
|
padding: var(--ops-space-1);
|
||||||
border-radius: var(--ops-radius-md);
|
border-radius: var(--ops-radius-md);
|
||||||
border: 1px solid var(--ops-border);
|
border: 1px solid var(--ops-border);
|
||||||
@@ -540,6 +611,20 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
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 {
|
.ops-ctx-sep {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 4px 6px;
|
margin: 4px 6px;
|
||||||
@@ -551,3 +636,69 @@
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--ops-muted);
|
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; }
|
||||||
|
|
||||||
Reference in new issue
Block a user