Files
opsxdb/src/AiPanel.tsx
T
logan 5416e86379 feat(ai): session harness contract — incomplete, run_id, confirm bind
Add docs/16 for session state machine, incomplete/resume, queue semantics,
and agent-loop/harness/MCP gap review. Wire RunOutcome on max-rounds,
run_id through audit, TrimMessages/Redact hooks, ConfirmBindHash on write
confirm path; session file skeleton without HTTP yet. No push.
2026-10-15 13:58:10 +08:00

786 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, type QueryResult } from "./store";
import { editorDatabase } from "./redisContext";
const ACTIONS: { id: AiAction; label: string }[] = [
{ id: "generate", label: "生成" },
{ id: "explain", label: "解释" },
{ id: "optimize", label: "优化" },
{ id: "fix", label: "修复" },
{ id: "sample", label: "样例" },
];
type ContentSeg = { kind: "text" | "code"; text: string };
/** R2-F7:拆分 AI 回复中的 ``` 代码块(通常 sql);块外文本维持纯文本 */
function splitContent(content: string): ContentSeg[] {
const segs: ContentSeg[] = [];
const re = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(content))) {
if (m.index > last) segs.push({ kind: "text", text: content.slice(last, m.index) });
segs.push({ kind: "code", text: m[1].replace(/\n$/, "") });
last = m.index + m[0].length;
}
if (last < content.length) segs.push({ kind: "text", text: content.slice(last) });
return segs.length ? segs : [{ kind: "text", text: content }];
}
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
function CodeBlock({ code }: { code: string }) {
const notify = useWorkspaceStore((s) => s.notify);
const notifyError = useWorkspaceStore((s) => s.notifyError);
const [copied, setCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => { if (copiedTimer.current) clearTimeout(copiedTimer.current); }, []);
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
if (copiedTimer.current) clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
} catch (error) { notifyError(error, "toast"); }
};
const insertToEditor = () => {
const st = useWorkspaceStore.getState();
const tab = st.tabs.find((t) => t.id === st.activeTabId);
const cur = tab?.sql ?? "";
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${code}` : code);
notify({ kind: "success", title: "已插入编辑器", message: "SQL 已追加到当前查询", mode: "toast", durationMs: 1400 });
};
return (
<div className="my-1 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border-soft)" }}>
<div
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()}>
{copied ? "已复制 ✓" : "复制"}
</button>
<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)" }}
>
{code}
</pre>
</div>
);
}
function MessageContent({ content, streaming }: { content: string; streaming?: boolean }) {
const segs = splitContent(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>))}
{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 });
}
function AgentSteps({ steps }: { steps: AiAgentStepView[] }) {
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)}>
{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 ${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.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>
))}
</ul>
</div>
);
}
function DangerConfirmBar({
sql,
connectionId,
database,
kind,
confirmBind,
runId,
onDone,
}: {
sql: string;
connectionId?: string;
database?: string;
kind: "query" | "redis";
confirmBind?: string;
runId?: string;
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,
confirmBind,
runId,
});
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);
const set = useWorkspaceStore((s) => s.set);
const [provider, setProvider] = useState("stub");
const [baseURL, setBaseURL] = useState("");
const [apiKey, setApiKey] = 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 [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [reload, setReload] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
setLoadError("");
getAiConfig()
.then((cfg) => {
if (!alive) return;
setProvider(cfg.provider || "stub");
setBaseURL(cfg.baseURL || "");
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);
})
.catch(() => { if (alive) setLoadError("无法读取 AI 配置,请确认本机服务已启动后重试。"); })
.finally(() => { if (alive) setLoading(false); });
return () => {
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 () => {
if (provider !== "stub" && !baseURL.trim()) {
notify({ kind: "error", title: "AI 配置", message: "请填写 Base URL,或选择一个预设。", mode: "modal" });
return false;
}
setBusy(true);
try {
await saveAiConfig({
provider,
baseURL: baseURL.trim(),
// 留空表示不改动已保存的 key(服务端掩码不回传明文,保存时保留旧值)
apiKey: apiKey.trim() || undefined,
model: model.trim(),
protocol,
authStyle,
allowInsecureHttp,
});
if (model.trim()) set("aiModel", model.trim());
setHasKey(hasKey || !!apiKey.trim() || authStyle === "none");
setApiKey("");
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
return true;
} catch (e) {
notify({ kind: "error", title: "AI 配置", message: e instanceof Error ? e.message : String(e), mode: "modal" });
return false;
} finally {
setBusy(false);
}
};
const test = async () => {
if (!(await save())) return;
setBusy(true);
try {
const res = await callAi({ action: "ask", prompt: "用一句话回答:连接正常", model: model.trim() || undefined });
notify({
kind: "success",
title: "AI 连接正常",
message: res.content.slice(0, 120) || "(空响应)",
mode: "toast",
durationMs: 4000,
});
} catch (e) {
notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" });
} finally {
setBusy(false);
}
};
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 (
<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">
<select
className="ops-input py-0.5 text-[11px]"
value={provider}
onChange={(e) => applyPreset(e.target.value)}
aria-label="Provider 预设"
>
<option value="stub">stub(离线占位)</option>
{AI_PRESETS.map((p) => (
<option key={p.id} value={p.id}>{p.name}({p.desc})</option>
))}
</select>
<input
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
placeholder="模型 ID"
value={model}
list="ai-model-options"
onChange={(e) => setModel(e.target.value)}
aria-label="模型"
/>
<datalist id="ai-model-options">
{presetModels.map((m) => <option key={m} value={m} />)}
</datalist>
</div>
{provider !== "stub" && (
<>
<input
className="ops-input w-full py-0.5 text-[11px]"
placeholder="Base URL,如 https://api.deepseek.com/v1"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
aria-label="Base URL"
/>
<input
className="ops-input w-full py-0.5 text-[11px]"
type="password"
placeholder={hasKey || authStyle === "none" ? "API Key(已配置,留空不改动)" : "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">
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
保存
</button>
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void test()}>
{busy ? "…" : "测试"}
</button>
<button type="button" className="ops-btn-ghost ml-auto h-6 px-2 text-[11px]" onClick={onClose}>
收起
</button>
</div>
</fieldset>
);
}
export function AiPanel() {
const messages = useWorkspaceStore((s) => s.aiMessages);
const ask = useWorkspaceStore((s) => s.aiAsk);
const model = useWorkspaceStore((s) => s.aiModel);
const busy = useWorkspaceStore((s) => s.aiBusy);
const aiMode = useWorkspaceStore((s) => s.aiMode);
const connections = useWorkspaceStore((s) => s.connections);
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
const tabs = useWorkspaceStore((s) => s.tabs);
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
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()
.then((cfg) => {
if (!alive) return;
if (cfg.model) {
const cur = useWorkspaceStore.getState().aiModel;
if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model);
}
})
.catch(() => {});
return () => {
alive = false;
};
}, [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 ?? "";
const database = editorDatabase(conn, tab);
const [action, setAction] = useState<AiAction>("generate");
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;
set("aiBusy", true);
if (action === "ask" || action === "generate") {
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
} else {
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
}
try {
const schema = schemaContextFor(conn?.id, database);
const res = await callAi({
action,
prompt: text,
sql,
driver: conn?.driver,
database: database,
error: result?.error,
schema: schema || undefined,
model: model && model !== "opsxdb-stub" ? model : undefined,
});
pushAi("assistant", res.content);
if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql);
if (action === "ask" || action === "generate") set("aiAsk", "");
} catch (e) {
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
} finally {
set("aiBusy", false);
}
};
const runAgent = async () => {
const text = ask.trim();
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 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,
runId: final.runId,
});
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) {
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 }));
}
};
const send = () => {
if (aiMode === "agent") void runAgent();
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 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"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
title="AI 设置"
aria-label="AI 设置"
onClick={() => setShowSettings(!showSettings)}
>
⚙
</button>
<button
type="button"
className="ops-close-faint"
title="关闭"
aria-label="关闭 AI"
onClick={() => {
set("showAi", false);
useWorkspaceStore.getState().persistPrefs();
}}
>
×
</button>
</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)" }}>
{aiMode === "agent"
? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表"
: "聊聊你想查什么,我来写 SQL"}
</div>
)}
{messages.map((m) => (
<div
key={m.id}
className="rounded border px-2 py-1.5 text-[12px] whitespace-pre-wrap"
style={
m.role === "user"
? {
borderColor: "color-mix(in srgb, var(--ops-accent) 35%, var(--ops-border))",
background: "color-mix(in srgb, var(--ops-accent) 8%, transparent)",
}
: {
borderColor: "var(--ops-border)",
background: "var(--ops-surface-2)",
}
}
>
<div className="mb-0.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
{m.role === "user" ? "你" : "AI"} · {m.at}
</div>
<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"}
confirmBind={pendingConfirm.step.confirmBind}
runId={m.runId}
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>
</div>
<div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库">
{conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}
{aiMode === "agent" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""}
</div>
<div className="flex gap-1">
<textarea
rows={3}
className="ops-input min-w-0 flex-1 resize-none"
placeholder={aiMode === "agent" ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行" : "描述你想查询什么…"}
value={ask}
disabled={busy}
onChange={(e) => set("aiAsk", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
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}
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 与 MCP 同源工具;默认只读。写操作需 mcp.json mode=read_write 且在气泡内确认。
</div>
)}
</div>
</aside>
);
}