feat(ui,docs): Agent 流式侧栏 + 结果区/MCP 点/确认条 + 官网文案
- 侧栏消费 Agent SSE:时间线边 append、正文 ▌、停止按钮 - 「在结果区打开」不抢编辑器 focus;MCP 活跃点;写操作确认条 - 文档/落地页补充 Ask|Agent 与 npx @rlwj-lab/opsxdb-mcp(不宣传默认可写)
This commit is contained in:
1 parent
2336c9433c
commit
979efb606d
9 files changed
+570
-82
No files matched your search
+23
-2
@@ -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 点
|
||||
```
|
||||
@@ -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),本地后端随应用自动拉起 |
|
||||
|
||||
|
||||
@@ -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」点;悬停/点击可看最近摘要。
|
||||
|
||||
## 使用方式
|
||||
|
||||
|
||||
@@ -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 助手》。
|
||||
@@ -60,7 +60,7 @@
|
||||
<a class="btn btn-ghost btn-lg" href="/docs/quickstart" target="_blank" rel="noopener">30 秒连上第一个库 →</a>
|
||||
</div>
|
||||
<div class="hero-pills">
|
||||
<span>本地优先</span><span>SSH 隧道</span><span>SSL 加密</span><span>国产库兼容</span><span>AI 写 SQL</span>
|
||||
<span>本地优先</span><span>SSH 隧道</span><span>SSL 加密</span><span>国产库兼容</span><span>AI / MCP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,8 +133,8 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div>
|
||||
<h3>AI 把 SQL 写快</h3>
|
||||
<p>自然语言生成、解释、优化、修复、方言转换,写不出的那句 SQL 交给 AI 起草。</p>
|
||||
<h3>AI 写 SQL,也能查库</h3>
|
||||
<p>Ask 生成/解释/优化;Agent 用与 MCP 同源的只读工具真查库。Cursor 可 <code>npx @rlwj-lab/opsxdb-mcp</code> 接入。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-table"/></svg></div><div><h3>结果随手取</h3><p>结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-tree"/></svg></div><div><h3>对象树与表数据</h3><p>懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div><div><h3>Redis 工作台</h3><p>编辑器直接执行 <code>PING</code> / <code>GET</code> / <code>SET</code> / <code>KEYS</code>,键空间浏览、TTL 管理。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI 面板</h3><p>生成 / 解释 / 优化 / 修复 / 方言转换 / 样例 SQL,六种动作贴着查询上下文。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI / MCP</h3><p>Ask 写 SQL;Agent 真查白名单库。可用 <code>npx @rlwj-lab/opsxdb-mcp</code> 接到 Cursor(默认只读)。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -249,7 +249,7 @@
|
||||
<details><summary>能在浏览器里操作数据库吗?</summary><p>不能。客户端唯一形态是桌面应用(Tauri 2);浏览器只承载官网与文档,由后端 :8788 内置提供。开发时的 :5174 只是桌面 devUrl 的资产服务。</p></details>
|
||||
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
|
||||
<details><summary>Oracle 支持好了吗?</summary><p>驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。</p></details>
|
||||
<details><summary>AI 功能需要联网吗?</summary><p>当前版本 AI 面板为本地 stub(生成 / 解释 / 优化等动作的框架已就位),接入模型服务的版本会明确标注数据流向。</p></details>
|
||||
<details><summary>AI 功能需要联网吗?</summary><p>配置云端模型(如 DeepSeek)时,提问与结构上下文会发往该服务;可改用本地兼容端点。MCP / Agent 查库始终在本机执行,默认只读。</p></details>
|
||||
<details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+314
-68
@@ -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)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => void copy()}>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={insertToEditor}
|
||||
>
|
||||
<button type="button" className="text-[10px] hover:underline" style={{ color: "var(--ops-accent)" }} onClick={insertToEditor}>
|
||||
插入编辑器
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
className="m-0 overflow-auto whitespace-pre-wrap break-all px-2 py-1 font-mono"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
background: "var(--ops-editor-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
style={{ fontSize: "var(--ops-text-xs)", background: "var(--ops-editor-bg)", color: "var(--ops-text)" }}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
@@ -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 ? <span className="ops-ai-cursor">▌</span> : null;
|
||||
if (segs.length === 1 && segs[0].kind === "text") {
|
||||
return (
|
||||
<>
|
||||
{segs.map((s, i) =>
|
||||
s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>,
|
||||
)}
|
||||
{content}
|
||||
{cursor}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{segs.map((s, i) => (s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>))}
|
||||
{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<string, unknown>[],
|
||||
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 (
|
||||
<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)}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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)" }}
|
||||
className={`mt-0.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full ${s.pending ? "ops-ai-step-spin" : ""}`}
|
||||
style={{
|
||||
background: s.pending
|
||||
? "var(--ops-accent)"
|
||||
: s.needConfirm
|
||||
? "var(--ops-warning, #f59e0b)"
|
||||
: 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` : ""}
|
||||
{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) ? (
|
||||
<>
|
||||
{" · "}
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => openStepResult(s)}>
|
||||
在结果区打开
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="mt-1.5 rounded border px-2 py-1.5 text-[11px]"
|
||||
style={{ borderColor: "color-mix(in srgb, var(--ops-danger, #ef4444) 45%, var(--ops-border))", background: "color-mix(in srgb, var(--ops-danger, #ef4444) 8%, transparent)" }}
|
||||
>
|
||||
<div className="mb-1" style={{ color: "var(--ops-text)" }}>
|
||||
写操作需确认(一次一刀)
|
||||
</div>
|
||||
<pre className="mb-1.5 max-h-16 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{sql}
|
||||
</pre>
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" className="ops-btn-ghost h-6 px-2 text-[11px]" disabled={busy} onClick={() => onDone(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" style={{ background: "var(--ops-danger, #ef4444)" }} disabled={busy || !connectionId} onClick={() => void run()}>
|
||||
{busy ? "…" : "执行"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<McpStatus | null>(null);
|
||||
const [mcpOpen, setMcpOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(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({
|
||||
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] };
|
||||
});
|
||||
pushAi("assistant", res.content, res.steps || []);
|
||||
if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql);
|
||||
set("aiAsk", "");
|
||||
},
|
||||
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 (
|
||||
<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">
|
||||
<span>新对话</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
新对话
|
||||
{mcp?.active ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded px-1 text-[10px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
title={mcp.recent?.[mcp.recent.length - 1]?.summary || "外部 MCP 近期有调用"}
|
||||
onClick={() => setMcpOpen(!mcpOpen)}
|
||||
>
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: "var(--ops-success, #22c55e)" }} aria-hidden />
|
||||
MCP
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -440,16 +616,34 @@ export function AiPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpOpen && mcp?.active ? (
|
||||
<div className="border-b px-2 py-1.5 text-[10px]" style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)" }}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span>最近 MCP 调用</span>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={() => setMcpOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<ul className="m-0 max-h-24 list-none space-y-0.5 overflow-auto p-0">
|
||||
{(mcp.recent || []).slice(-8).reverse().map((e, i) => (
|
||||
<li key={i}>
|
||||
{e.tool?.replace(/^opsxdb_/, "")} · {e.ok ? "ok" : "fail"} · {e.summary || e.error || ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-1" style={{ color: "var(--ops-muted)" }}>
|
||||
断开请在 Cursor / 客户端停用 opsxdb-mcp;本面板仅展示审计。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
||||
{messages.length === 0 && (
|
||||
<div
|
||||
className="grid h-full place-items-center px-2 text-center text-[12px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
>
|
||||
<div className="grid h-full place-items-center px-2 text-center text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{aiMode === "agent"
|
||||
? "Agent 会真查白名单里的库(只读)。试试:列出 demo 有哪些表"
|
||||
? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表"
|
||||
: "聊聊你想查什么,我来写 SQL"}
|
||||
</div>
|
||||
)}
|
||||
@@ -469,26 +663,64 @@ export function AiPanel() {
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mb-0.5 text-[10px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
>
|
||||
<div className="mb-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{m.role === "user" ? "你" : "AI"} · {m.at}
|
||||
</div>
|
||||
<MessageContent content={m.content} />
|
||||
<MessageContent content={m.content} streaming={m.streaming} />
|
||||
{m.role === "assistant" && m.steps && m.steps.length > 0 ? <AgentSteps steps={m.steps} /> : null}
|
||||
{pendingConfirm && pendingConfirm.messageId === m.id && pendingConfirm.step.confirmSql ? (
|
||||
<DangerConfirmBar
|
||||
sql={pendingConfirm.step.confirmSql}
|
||||
connectionId={conn?.id}
|
||||
database={database || undefined}
|
||||
kind={pendingConfirm.step.tool?.includes("redis") ? "redis" : "query"}
|
||||
onDone={(ok, msg) => {
|
||||
if (ok) {
|
||||
patchAiMessage(m.id, (cur) => ({
|
||||
...cur,
|
||||
steps: (cur.steps || []).map((s) =>
|
||||
s.needConfirm ? { ...s, needConfirm: false, ok: true, summary: "已确认执行" } : s,
|
||||
),
|
||||
content: cur.content + (cur.content ? "\n" : "") + "(已确认执行写操作)",
|
||||
}));
|
||||
useWorkspaceStore.getState().notify({
|
||||
kind: "success",
|
||||
title: "已执行",
|
||||
message: "写操作已确认执行",
|
||||
mode: "toast",
|
||||
durationMs: 1400,
|
||||
});
|
||||
} else if (msg) {
|
||||
useWorkspaceStore.getState().notify({ kind: "error", title: "执行失败", message: msg, mode: "toast", durationMs: 2400 });
|
||||
} else {
|
||||
patchAiMessage(m.id, (cur) => ({
|
||||
...cur,
|
||||
steps: (cur.steps || []).map((s) => (s.needConfirm ? { ...s, needConfirm: false, summary: "已取消" } : s)),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""}
|
||||
</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">
|
||||
<textarea rows={3}
|
||||
<textarea
|
||||
rows={3}
|
||||
className="ops-input min-w-0 flex-1 resize-none"
|
||||
placeholder={aiMode === "agent" ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行" : "描述你想查询什么…"}
|
||||
value={ask}
|
||||
@@ -497,23 +729,37 @@ export function AiPanel() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
if (!busy) send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{busy && aiMode === "agent" ? (
|
||||
<button type="button" className="ops-btn-secondary" onClick={stopAgent}>
|
||||
停止
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="ops-btn-primary" disabled={busy || (aiMode === "agent" && !ask.trim())} onClick={send}>
|
||||
{busy ? "…" : "发送"}
|
||||
</button>
|
||||
)}
|
||||
</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}
|
||||
{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>)}
|
||||
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 白名单。
|
||||
Agent 与 MCP 同源工具;默认只读。写操作需 mcp.json mode=read_write 且在气泡内确认。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+143
@@ -552,6 +552,149 @@ export async function callAiAgent(payload: {
|
||||
return post<AiAgentRes>("/api/ai/agent", payload);
|
||||
}
|
||||
|
||||
export type AiAgentQueryResult = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
sql?: string;
|
||||
tool?: string;
|
||||
};
|
||||
|
||||
export type AiAgentStepExt = AiAgentStep & {
|
||||
pending?: boolean;
|
||||
needConfirm?: boolean;
|
||||
confirmSql?: string;
|
||||
result?: AiAgentQueryResult;
|
||||
};
|
||||
|
||||
export type AiAgentStreamHandlers = {
|
||||
onStepStart?: (step: AiAgentStepExt) => void;
|
||||
onStep?: (step: AiAgentStepExt) => void;
|
||||
onDelta?: (delta: string) => void;
|
||||
onDone?: (res: AiAgentRes) => void;
|
||||
onError?: (error: string, partial?: Partial<AiAgentRes>) => void;
|
||||
};
|
||||
|
||||
/** Agent SSE(stream=true);不可用时回落非流式。signal 供「停止」。 */
|
||||
export async function callAiAgentStream(
|
||||
payload: {
|
||||
prompt: string;
|
||||
connectionId?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
model?: string;
|
||||
},
|
||||
handlers: AiAgentStreamHandlers,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AiAgentRes> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/agent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify({ ...payload, stream: true }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !res.body || !(res.headers.get("content-type") || "").includes("text/event-stream")) {
|
||||
const plain = await callAiAgent(payload);
|
||||
handlers.onDone?.(plain);
|
||||
return plain;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let content = "";
|
||||
const steps: AiAgentStep[] = [];
|
||||
let rounds = 0;
|
||||
let finalRes: AiAgentRes | null = null;
|
||||
|
||||
const handleEvent = (event: string, data: string) => {
|
||||
let ev: Record<string, unknown>;
|
||||
try {
|
||||
ev = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const typ = event || String(ev.type || "");
|
||||
if (typ === "step_start" && ev.step) {
|
||||
handlers.onStepStart?.(ev.step as AiAgentStepExt);
|
||||
} else if (typ === "step" && ev.step) {
|
||||
const st = ev.step as AiAgentStepExt;
|
||||
steps.push(st);
|
||||
handlers.onStep?.(st);
|
||||
} else if (typ === "delta") {
|
||||
const d = String(ev.delta || "");
|
||||
content += d;
|
||||
handlers.onDelta?.(d);
|
||||
} else if (typ === "done") {
|
||||
finalRes = {
|
||||
content: String(ev.content ?? content),
|
||||
sql: ev.sql ? String(ev.sql) : undefined,
|
||||
steps: (ev.steps as AiAgentStep[]) || steps,
|
||||
rounds: Number(ev.rounds ?? rounds) || 0,
|
||||
};
|
||||
rounds = finalRes.rounds;
|
||||
handlers.onDone?.(finalRes);
|
||||
} else if (typ === "error") {
|
||||
handlers.onError?.(String(ev.error || "agent error"), { content, steps, rounds: Number(ev.rounds) || 0 });
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
||||
const chunk = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
let event = "";
|
||||
let data = "";
|
||||
for (const line of chunk.split("\n")) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
|
||||
}
|
||||
if (data) handleEvent(event, data);
|
||||
}
|
||||
}
|
||||
if (finalRes) return finalRes;
|
||||
const fallback: AiAgentRes = { content, steps, rounds };
|
||||
handlers.onDone?.(fallback);
|
||||
return fallback;
|
||||
} catch (e) {
|
||||
if (signal?.aborted) throw e;
|
||||
const plain = await callAiAgent(payload);
|
||||
handlers.onDone?.(plain);
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
|
||||
export type McpStatus = {
|
||||
configured: boolean;
|
||||
mode: string;
|
||||
whitelistCount: number;
|
||||
writesEnabled: boolean;
|
||||
active: boolean;
|
||||
recent?: { time: string; tool: string; ok: boolean; summary?: string; error?: string; source?: string }[];
|
||||
};
|
||||
|
||||
export async function getMcpStatus(signal?: AbortSignal) {
|
||||
return get<McpStatus>("/api/ai/mcp-status", signal);
|
||||
}
|
||||
|
||||
export async function confirmAiAgentWrite(payload: {
|
||||
connectionId: string;
|
||||
sql?: string;
|
||||
command?: string;
|
||||
database?: string;
|
||||
kind?: "query" | "redis";
|
||||
}) {
|
||||
return post<Record<string, unknown>>("/api/ai/agent/confirm", payload);
|
||||
}
|
||||
|
||||
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
|
||||
export type AiConfig = {
|
||||
provider: string;
|
||||
|
||||
+50
-3
@@ -189,6 +189,14 @@ export type ConfirmRequest = {
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type AiAgentQueryResultView = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
sql?: string;
|
||||
tool?: string;
|
||||
};
|
||||
|
||||
export type AiAgentStepView = {
|
||||
tool: string;
|
||||
args?: string;
|
||||
@@ -196,6 +204,10 @@ export type AiAgentStepView = {
|
||||
summary: string;
|
||||
error?: string;
|
||||
ms: number;
|
||||
pending?: boolean;
|
||||
needConfirm?: boolean;
|
||||
confirmSql?: string;
|
||||
result?: AiAgentQueryResultView;
|
||||
};
|
||||
|
||||
export type AiMessage = {
|
||||
@@ -205,6 +217,7 @@ export type AiMessage = {
|
||||
at: string;
|
||||
/** Agent 工具步骤时间线(仅 assistant) */
|
||||
steps?: AiAgentStepView[];
|
||||
streaming?: boolean;
|
||||
};
|
||||
|
||||
export type AiChatMode = "ask" | "agent";
|
||||
@@ -320,11 +333,14 @@ type State = {
|
||||
updateActiveSql: (sql: string) => void;
|
||||
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
|
||||
updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean;
|
||||
pushAi: (role: AiMessage["role"], content: string, steps?: AiAgentStepView[]) => void;
|
||||
pushAi: (role: AiMessage["role"], content: string, steps?: AiAgentStepView[], extra?: Partial<AiMessage>) => string;
|
||||
patchAiMessage: (id: string, patch: Partial<AiMessage> | ((m: AiMessage) => AiMessage)) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||
clearHistory: () => void;
|
||||
setResultSets: (items: ResultSetItem[]) => void;
|
||||
/** AI「在结果区打开」:新开/切换结果页,不改 SQL Tab、不抢编辑器 focus */
|
||||
openAiResultSet: (label: string, sql: string, result: QueryResult) => void;
|
||||
selectResultSet: (id: string) => void;
|
||||
/** C5/G3:局部更新某个结果集(翻页结果、视图持久化);若为激活项同步 result */
|
||||
updateResultSet: (id: string, patch: Partial<Omit<ResultSetItem, "id">>) => void;
|
||||
@@ -894,14 +910,33 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
pushAi: (role, content, steps) =>
|
||||
pushAi: (role, content, steps, extra) => {
|
||||
const id = crypto.randomUUID();
|
||||
set((s) => ({
|
||||
aiMessages: [
|
||||
...s.aiMessages,
|
||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString(), steps },
|
||||
{
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
at: new Date().toLocaleTimeString(),
|
||||
steps,
|
||||
...extra,
|
||||
},
|
||||
],
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
patchAiMessage: (id, patch) =>
|
||||
set((s) => ({
|
||||
aiMessages: s.aiMessages.map((m) => {
|
||||
if (m.id !== id) return m;
|
||||
return typeof patch === "function" ? patch(m) : { ...m, ...patch };
|
||||
}),
|
||||
})),
|
||||
|
||||
|
||||
setSession: (connectionId, sessionId) => {
|
||||
const sessions = { ...get().sessions };
|
||||
if (sessionId) sessions[connectionId] = sessionId;
|
||||
@@ -942,6 +977,18 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
resultTab: "data",
|
||||
});
|
||||
},
|
||||
openAiResultSet: (label, sql, result) => {
|
||||
const id = crypto.randomUUID();
|
||||
const item: ResultSetItem = { id, label: label || "AI 结果", sql: sql || "", result };
|
||||
const prev = get().resultSets.filter((x) => x.id !== id);
|
||||
set({
|
||||
resultSets: [item, ...prev].slice(0, 30),
|
||||
activeResultSetId: id,
|
||||
result,
|
||||
resultTab: "data",
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
selectResultSet: (id) => {
|
||||
const item = get().resultSets.find((x) => x.id === id);
|
||||
|
||||
@@ -702,3 +702,9 @@
|
||||
.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; }
|
||||
|
||||
|
||||
/* Agent 流式光标 / 步骤 spinner */
|
||||
.ops-ai-cursor { display: inline-block; margin-left: 1px; animation: ops-ai-blink 1s step-end infinite; color: var(--ops-accent); }
|
||||
@keyframes ops-ai-blink { 50% { opacity: 0; } }
|
||||
.ops-ai-step-spin { animation: ops-ai-pulse 0.9s ease-in-out infinite; }
|
||||
@keyframes ops-ai-pulse { 50% { opacity: 0.35; transform: scale(0.85); } }
|
||||
Reference in new issue
Block a user