feat(ui,docs): Agent 流式侧栏 + 结果区/MCP 点/确认条 + 官网文案

- 侧栏消费 Agent SSE:时间线边 append、正文 ▌、停止按钮
- 「在结果区打开」不抢编辑器 focus;MCP 活跃点;写操作确认条
- 文档/落地页补充 Ask|Agent 与 npx @rlwj-lab/opsxdb-mcp(不宣传默认可写)
This commit is contained in:
logan committed 2026-10-15 13:00:58 +08:00
1 parent 2336c9433c
commit 979efb606d
9 files changed
+580 -92

No files matched your search

+324 -78
View File
@@ -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 (
<>
{content}
{cursor}
</>
);
}
return (
<>
{segs.map((s, i) =>
s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>,
)}
{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({
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 (
<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();
}
}}
/>
<button type="button" className="ops-btn-primary" disabled={busy || (aiMode === "agent" && !ask.trim())} onClick={send}>
{busy ? "…" : "发送"}
</button>
{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}
className={action === a.id ? "ops-ai-action-active" : undefined}
onClick={() => setAction(a.id)}>{a.id === "generate" ? "生成 SQL" : a.label}</button>)}
{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 白名单。
Agent 与 MCP 同源工具;默认只读。写操作需 mcp.json mode=read_write 且在气泡内确认。
</div>
)}
</div>