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 | 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 (
        {code}
      
); } function MessageContent({ content, streaming }: { content: string; streaming?: boolean }) { const segs = splitContent(content); const cursor = streaming ? ▌ : null; if (segs.length === 1 && segs[0].kind === "text") { return ( <> {content} {cursor} ); } return ( <> {segs.map((s, i) => (s.kind === "code" ? : {s.text}))} {cursor} ); } function openStepResult(step: AiAgentStepView) { const r = step.result; if (!r || (!r.columns?.length && !r.rows?.length)) return; const st = useWorkspaceStore.getState(); const qr: QueryResult = { columns: r.columns || [], rows: (r.rows || []) as Record[], rowCount: r.rowCount ?? (r.rows?.length || 0), timeMs: step.ms || 0, }; st.openAiResultSet(step.tool.replace(/^opsxdb_/, "") || "AI 结果", r.sql || "", qr); st.notify({ kind: "success", title: "已在结果区打开", message: `${qr.rowCount} 行`, mode: "toast", durationMs: 1400 }); } 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 (
    {shown.map((s, i) => (
  • {s.tool.replace(/^opsxdb_/, "")} {" · "} {s.pending ? "执行中…" : s.summary || s.error || (s.ok ? "ok" : "fail")} {!s.pending && s.ms != null ? ` · ${s.ms}ms` : ""} {s.result && (s.result.columns?.length || s.result.rows?.length) ? ( <> {" · "} ) : null}
  • ))}
); } 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 (
写操作需确认(一次一刀)
        {sql}
      
); } /** 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("openai-chat"); const [authStyle, setAuthStyle] = useState("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

正在读取 AI 配置…

; if (loadError) return
{loadError}
; const presetModels = findAiPreset(provider)?.models ?? []; const isLocalHttp = baseURL.trim().startsWith("http://"); return (
setModel(e.target.value)} aria-label="模型" /> {presetModels.map((m) =>
{provider !== "stub" && ( <> setBaseURL(e.target.value)} aria-label="Base URL" /> setApiKey(e.target.value)} aria-label="API Key" disabled={authStyle === "none"} /> {isLocalHttp && ( )} )}
); } 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(null); const [mcpOpen, setMcpOpen] = useState(false); const abortRef = useRef(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("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 (