From 979efb606d583c3bdcdeaf9e82826bb82fdd405d Mon Sep 17 00:00:00 2001 From: logan Date: Thu, 15 Oct 2026 13:00:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui,docs):=20Agent=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=20+=20=E7=BB=93=E6=9E=9C=E5=8C=BA/MCP=20?= =?UTF-8?q?=E7=82=B9/=E7=A1=AE=E8=AE=A4=E6=9D=A1=20+=20=E5=AE=98=E7=BD=91?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏消费 Agent SSE:时间线边 append、正文 ▌、停止按钮 - 「在结果区打开」不抢编辑器 focus;MCP 活跃点;写操作确认条 - 文档/落地页补充 Ask|Agent 与 npx @rlwj-lab/opsxdb-mcp(不宣传默认可写) --- docs/15-AI-MCP-Agent.md | 25 +- server/internal/docs/content/01-intro.md | 2 +- server/internal/docs/content/10-ai.md | 20 +- server/internal/docs/content/11-faq.md | 11 + server/internal/site/template.html | 10 +- src/AiPanel.tsx | 402 ++++++++++++++++++----- src/api.ts | 143 ++++++++ src/store.ts | 53 ++- src/styles.css | 6 + 9 files changed, 580 insertions(+), 92 deletions(-) diff --git a/docs/15-AI-MCP-Agent.md b/docs/15-AI-MCP-Agent.md index d4f85a1..41e0691 100644 --- a/docs/15-AI-MCP-Agent.md +++ b/docs/15-AI-MCP-Agent.md @@ -14,7 +14,7 @@ | 写操作 | **P0 纯只读**;「确认后才可写」复用现有 dangerConfirm,进 **P1**,MCP 首版不可写 | | 代码边界 | 只动根级 `src` + `server`;禁止 `packages/ui`(死树) | | Redis | 对齐现有精确 TYPE / 集群 SCAN,不另起搜键 | -| UI | P0 无新重界面;Ask\|Agent Seg / 步骤时间线已落地阶段 2;MCP 状态点属后续 | +| UI | Ask\|Agent + SSE 时间线 + 插入/结果区 + MCP 状态点已落地;P1 确认条已接 | | 冲突 | 与未推「补偿包」冲突先停报,勿 silently 覆盖 | | 问题 | 结论 | @@ -103,7 +103,7 @@ - [x] 只读模式下 `DELETE`/`DROP`/写 SQL 被拒;审计写 `mcp-audit.jsonl` - [x] 通路代码就绪;单测用 sqlite 覆盖 list/describe/exec;MySQL/PG 真库待桌面验收 - [x] Redis 读白名单 + FLUSH*/KEYS/写拒(单测);真库待验 -- [x] 侧栏 Agent 调同一 runtime(阶段 2:Ask|Agent + `/api/ai/agent` + 步骤时间线) +- [x] 侧栏 Agent 调同一 runtime(Ask|Agent + `/api/ai/agent` SSE + 步骤时间线 + 结果区/确认) - [x] 文档:`docs/mcp-cursor.example.json` + 本文件 ## 参考路径(本机) @@ -210,3 +210,24 @@ go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp ### 真库验收 2026-10-15 MySQL 3306 demo + Redis 6379:`go run ./cmd/opsxdb-mcp-accept/` 全过。实践文档:`~/Downloads/AI-Agent接入GUI工具开发实践.md`。 + + +## 阶段 3–4 完成记录(2026-10-15) + +| 项 | 状态 | +|----|------| +| Agent SSE | ✅ `stream=true` / `Accept: text/event-stream`;步骤边推 + 正文分块;非流式 JSON fallback;停止=Abort | +| Anthropic / responses tool calling | ✅ 解析与回灌单测;DeepSeek 仍走 openai-chat | +| 在结果区打开 / 插入编辑器 | ✅ 气泡步骤与代码块动作;不抢编辑器 focus | +| MCP 状态点 | ✅ 仅 `source=mcp` 近 5 分钟活跃时显示;`GET /api/ai/mcp-status` | +| P1 可写+确认 | ✅ 默认只读;`mode=read_write` + 确认条 / `confirm_danger`;Agent 剥离模型自带令牌;`POST /api/ai/agent/confirm` | +| 官网/文档 | ✅ intro / AI / FAQ + homepage AI/MCP 文案;**未**宣传默认可写 | + +手动测: + +```bash +cd server && go test ./internal/ai/ ./internal/aitools/ ./internal/server/ -count=1 +# 侧栏 Agent:发问看步骤流式出现;有行列结果点「在结果区打开」 +# 写:mcp.json mode=read_write 后让 Agent 尝试写 → 气泡确认条 → 执行/取消 +# MCP:npx @rlwj-lab/opsxdb-mcp 调工具后侧栏应出现 MCP 点 +``` diff --git a/server/internal/docs/content/01-intro.md b/server/internal/docs/content/01-intro.md index beb50bc..350a1cd 100644 --- a/server/internal/docs/content/01-intro.md +++ b/server/internal/docs/content/01-intro.md @@ -13,7 +13,7 @@ OpsxDB 是一款**本地优先**的桌面数据库工作台,面向运维与开 | 结果处理 | 一键复制 / CSV / 生成 INSERT;双击单元格复制;截断有提示 | | Redis 工作台 | 编辑器执行 PING / GET / SET / DEL / KEYS,键空间浏览 | | 安全通道 | 每连接独立配置 SSL/TLS 与 SSH 隧道;密码本机混淆存储 | -| AI 面板 | 生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL | +| AI 面板 | Ask 生成/解释/优化;Agent 真查库(与 MCP 同源只读工具) | | 危险操作防护 | DROP / TRUNCATE / 无 WHERE 的 UPDATE·DELETE 执行前确认 | | 桌面唯一形态 | 桌面 GUI(Tauri 2),本地后端随应用自动拉起 | diff --git a/server/internal/docs/content/10-ai.md b/server/internal/docs/content/10-ai.md index 289846a..9a2bbaa 100644 --- a/server/internal/docs/content/10-ai.md +++ b/server/internal/docs/content/10-ai.md @@ -12,16 +12,30 @@ AI 面板的目标:让「写不出的 SQL」和「看不懂的 SQL」不再阻 | 样例 | 按表结构生成示例查询 | -## Ask | Agent(阶段 2) +## Ask | Agent 侧栏输入框上方可切换模式: | 模式 | 行为 | | --- | --- | | **Ask** | 只生成/解释/优化 SQL,**不执行**数据库工具 | -| **Agent** | 多轮调用与 MCP 同源的只读工具(列连接/库表、描述表、只读 SQL / Redis 读命令),步骤时间线挂在回复下 | +| **Agent** | 多轮调用与 MCP 同源工具;步骤时间线边跑边推(SSE);可「插入编辑器」「在结果区打开」 | -Agent 需要已配置的 OpenAI 兼容模型(如 DeepSeek)。连接须在 `~/.opsxdb/mcp.json` 白名单内;写操作一律拒绝。 +Agent 支持 openai-chat(DeepSeek 等)、Anthropic messages、OpenAI responses。连接须在 `~/.opsxdb/mcp.json` 白名单内。 + +默认 **只读**。若将 `mode` 设为 `read_write`,写操作仍须在气泡确认条点「执行」(一次一刀),模型不能自行带确认令牌。 + +## MCP + +把同一套工具暴露给 Cursor / Claude / Codex: + +```bash +npx @rlwj-lab/opsxdb-mcp +``` + +配置示例见仓库 `docs/mcp-cursor.example.json`。白名单写在 `~/.opsxdb/mcp.json` 的 `allowed_connection_ids`;空白名单拒绝全部连接。审计:`~/.opsxdb/logs/mcp-audit.jsonl`。 + +侧栏标题旁仅在外部 MCP **近期有调用**时显示绿色「MCP」点;悬停/点击可看最近摘要。 ## 使用方式 diff --git a/server/internal/docs/content/11-faq.md b/server/internal/docs/content/11-faq.md index 53e7e8a..a623661 100644 --- a/server/internal/docs/content/11-faq.md +++ b/server/internal/docs/content/11-faq.md @@ -38,3 +38,14 @@ **Q:快捷键有哪些?** 核心就一个:⌘/Ctrl + Enter 执行当前语句或选区;双击结果单元格复制。完整列表见《SQL 工作台》。 + + +## 如何把 OpsxDB 接到 Cursor? + +安装并配置 MCP: + +```bash +npx @rlwj-lab/opsxdb-mcp +``` + +在 Cursor 的 `mcp.json` 中指向该命令(或本机构建的 `bin/opsxdb-mcp`),并在 `~/.opsxdb/mcp.json` 填写允许的连接 id。默认只读;详见《AI 助手》。 diff --git a/server/internal/site/template.html b/server/internal/site/template.html index 5e9cb96..9a0c8a7 100644 --- a/server/internal/site/template.html +++ b/server/internal/site/template.html @@ -60,7 +60,7 @@ 30 秒连上第一个库 →
- 本地优先SSH 隧道SSL 加密国产库兼容AI 写 SQL + 本地优先SSH 隧道SSL 加密国产库兼容AI / MCP
@@ -133,8 +133,8 @@
-

AI 把 SQL 写快

-

自然语言生成、解释、优化、修复、方言转换,写不出的那句 SQL 交给 AI 起草。

+

AI 写 SQL,也能查库

+

Ask 生成/解释/优化;Agent 用与 MCP 同源的只读工具真查库。Cursor 可 npx @rlwj-lab/opsxdb-mcp 接入。

@@ -151,7 +151,7 @@

结果随手取

结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。

对象树与表数据

懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。

Redis 工作台

编辑器直接执行 PING / GET / SET / KEYS,键空间浏览、TTL 管理。

-

AI 面板

生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL,六种动作贴着查询上下文。

+

AI / MCP

Ask 写 SQL;Agent 真查白名单库。可用 npx @rlwj-lab/opsxdb-mcp 接到 Cursor(默认只读)。

@@ -249,7 +249,7 @@
能在浏览器里操作数据库吗?

不能。客户端唯一形态是桌面应用(Tauri 2);浏览器只承载官网与文档,由后端 :8788 内置提供。开发时的 :5174 只是桌面 devUrl 的资产服务。

和 DBeaver / Navicat 有什么差别?

OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。

Oracle 支持好了吗?

驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。

-
AI 功能需要联网吗?

当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。

+
AI 功能需要联网吗?

配置云端模型(如 DeepSeek)时,提问与结构上下文会发往该服务;可改用本地兼容端点。MCP / Agent 查库始终在本机执行,默认只读。

支持达梦 / MongoDB 吗?

已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。

diff --git a/src/AiPanel.tsx b/src/AiPanel.tsx index 2d5019a..0e23795 100644 --- a/src/AiPanel.tsx +++ b/src/AiPanel.tsx @@ -1,7 +1,18 @@ -import { useEffect, useState } from "react"; -import { callAi, callAiAgent, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api"; +import { useEffect, useRef, useState } from "react"; +import { + callAi, + callAiAgentStream, + confirmAiAgentWrite, + getAiConfig, + getMcpStatus, + saveAiConfig, + schemaContextFor, + type AiAction, + type AiAgentStepExt, + type McpStatus, +} from "./api"; import { AI_PRESETS, findAiPreset, type AiAuthStyle, type AiProtocol } from "./aiPresets"; -import { useWorkspaceStore, type AiAgentStepView, type AiChatMode } from "./store"; +import { useWorkspaceStore, type AiAgentStepView, type AiChatMode, type QueryResult } from "./store"; import { editorDatabase } from "./redisContext"; const ACTIONS: { id: AiAction; label: string }[] = [ @@ -49,30 +60,16 @@ function CodeBlock({ code }: { code: string }) { className="flex items-center justify-end gap-1.5 border-b px-1.5 py-0.5" style={{ borderColor: "var(--ops-border-soft)" }} > - -
         {code}
       
@@ -80,53 +77,77 @@ function CodeBlock({ code }: { code: string }) { ); } -/** R2-F7:AI 消息渲染 —— 代码块走等宽块,其余纯文本(无代码块时整体不变) */ -function MessageContent({ content }: { content: string }) { +function MessageContent({ content, streaming }: { content: string; streaming?: boolean }) { const segs = splitContent(content); - if (segs.length === 1 && segs[0].kind === "text") return <>{content}; + const cursor = streaming ? ▌ : null; + if (segs.length === 1 && segs[0].kind === "text") { + return ( + <> + {content} + {cursor} + + ); + } return ( <> - {segs.map((s, i) => - s.kind === "code" ? : {s.text}, - )} + {segs.map((s, i) => (s.kind === "code" ? : {s.text}))} + {cursor} ); } +function openStepResult(step: AiAgentStepView) { + const r = step.result; + if (!r || (!r.columns?.length && !r.rows?.length)) return; + const st = useWorkspaceStore.getState(); + const qr: QueryResult = { + columns: r.columns || [], + rows: (r.rows || []) as Record[], + rowCount: r.rowCount ?? (r.rows?.length || 0), + timeMs: step.ms || 0, + }; + st.openAiResultSet(step.tool.replace(/^opsxdb_/, "") || "AI 结果", r.sql || "", qr); + st.notify({ kind: "success", title: "已在结果区打开", message: `${qr.rowCount} 行`, mode: "toast", durationMs: 1400 }); +} -/** 阶段 2:Agent 步骤时间线 — 挂在回复下,默认可折叠,展示最近步骤 */ function AgentSteps({ steps }: { steps: AiAgentStepView[] }) { - const [open, setOpen] = useState(false); // 默认最近 3 步 + const [open, setOpen] = useState(false); if (!steps.length) return null; const shown = open || steps.length <= 3 ? steps : steps.slice(-3); return (
-
    {shown.map((s, i) => ( -
  • +
  • {s.tool.replace(/^opsxdb_/, "")} {" · "} - {s.summary || s.error || (s.ok ? "ok" : "fail")} - {s.ms != null ? ` · ${s.ms}ms` : ""} + {s.pending ? "执行中…" : s.summary || s.error || (s.ok ? "ok" : "fail")} + {!s.pending && s.ms != null ? ` · ${s.ms}ms` : ""} + {s.result && (s.result.columns?.length || s.result.rows?.length) ? ( + <> + {" · "} + + + ) : null}
  • ))} @@ -135,6 +156,61 @@ function AgentSteps({ steps }: { steps: AiAgentStepView[] }) { ); } +function DangerConfirmBar({ + sql, + connectionId, + database, + kind, + onDone, +}: { + sql: string; + connectionId?: string; + database?: string; + kind: "query" | "redis"; + onDone: (ok: boolean, msg?: string) => void; +}) { + const [busy, setBusy] = useState(false); + const run = async () => { + if (!connectionId || busy) return; + setBusy(true); + try { + await confirmAiAgentWrite({ + connectionId, + sql: kind === "query" ? sql : undefined, + command: kind === "redis" ? sql : undefined, + database, + kind, + }); + onDone(true); + } catch (e) { + onDone(false, e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + return ( +
    +
    + 写操作需确认(一次一刀) +
    +
    +        {sql}
    +      
    +
    + + +
    +
    + ); +} + /** C4:AI 设置(预设 Provider / Base URL / API Key / 模型 + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */ export function AiSettings({ onClose }: { onClose: () => void }) { const notify = useWorkspaceStore((s) => s.notify); @@ -326,8 +402,13 @@ export function AiPanel() { const result = useWorkspaceStore((s) => s.result); const set = useWorkspaceStore((s) => s.set); const pushAi = useWorkspaceStore((s) => s.pushAi); + const patchAiMessage = useWorkspaceStore((s) => s.patchAiMessage); const updateTabSqlIfUnchanged = useWorkspaceStore((s) => s.updateTabSqlIfUnchanged); const [showSettings, setShowSettings] = useState(false); + const [mcp, setMcp] = useState(null); + const [mcpOpen, setMcpOpen] = useState(false); + const abortRef = useRef(null); + useEffect(() => { let alive = true; getAiConfig() @@ -344,6 +425,25 @@ export function AiPanel() { }; }, [set]); + useEffect(() => { + let alive = true; + const tick = () => { + getMcpStatus() + .then((st) => { + if (alive) setMcp(st); + }) + .catch(() => { + if (alive) setMcp(null); + }); + }; + tick(); + const id = window.setInterval(tick, 15000); + return () => { + alive = false; + window.clearInterval(id); + }; + }, []); + const conn = connections.find((c) => c.id === activeConnectionId); const tab = tabs.find((t) => t.id === activeTabId); const sql = tab?.sql ?? ""; @@ -352,6 +452,12 @@ export function AiPanel() { const setMode = (mode: AiChatMode) => set("aiMode", mode); + const stopAgent = () => { + abortRef.current?.abort(); + abortRef.current = null; + set("aiBusy", false); + }; + const runAsk = async (action: AiAction, prompt?: string) => { const text = (prompt ?? ask).trim(); if (busy || !conn || ((action === "ask" || action === "generate") && !text)) return; @@ -388,21 +494,67 @@ export function AiPanel() { if (busy || !text) return; set("aiBusy", true); pushAi("user", text); + set("aiAsk", ""); + const asstId = pushAi("assistant", "", [], { streaming: true }); + const ac = new AbortController(); + abortRef.current = ac; 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", ""); + const res = await callAiAgentStream( + { + prompt: text, + connectionId: conn?.id || undefined, + driver: conn?.driver, + database: database || undefined, + model: model && model !== "opsxdb-stub" ? model : undefined, + }, + { + onStepStart: (step) => { + patchAiMessage(asstId, (m) => ({ + ...m, + steps: [...(m.steps || []).filter((s) => !s.pending), { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }], + })); + }, + onStep: (step: AiAgentStepExt) => { + patchAiMessage(asstId, (m) => { + const withoutPending = (m.steps || []).filter((s) => !s.pending); + return { ...m, steps: [...withoutPending, step] }; + }); + }, + onDelta: (delta) => { + patchAiMessage(asstId, (m) => ({ ...m, content: (m.content || "") + delta, streaming: true })); + }, + onDone: (final) => { + patchAiMessage(asstId, { + content: final.content, + steps: final.steps || [], + streaming: false, + }); + if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql); + }, + onError: (err, partial) => { + patchAiMessage(asstId, { + content: partial?.content || `失败:${err}`, + steps: (partial?.steps as AiAgentStepView[]) || [], + streaming: false, + }); + }, + }, + ac.signal, + ); + void res; } catch (e) { - pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`); + if ((e as Error)?.name === "AbortError") { + patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false })); + } else { + patchAiMessage(asstId, { + content: `失败:${e instanceof Error ? e.message : String(e)}`, + streaming: false, + }); + } } finally { + abortRef.current = null; set("aiBusy", false); + patchAiMessage(asstId, (m) => ({ ...m, streaming: false })); } }; @@ -411,10 +563,34 @@ export function AiPanel() { else void runAsk(action); }; + const pendingConfirm = (() => { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role !== "assistant" || !m.steps) continue; + const step = [...m.steps].reverse().find((s) => s.needConfirm && s.confirmSql); + if (step) return { messageId: m.id, step }; + } + return null; + })(); + return (