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.
786 lines
30 KiB
TypeScript
786 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|