feat(ai): 阶段 2 Ask|Agent 共用 aitools Runtime 真查库

- 侧栏 Ask|Agent 分段;Ask 仍走 /api/ai/chat,Agent 走 /api/ai/agent
- RunAgent 多轮 tool call → 进程内 aitools(与 MCP 同源只读/白名单/审计)
- 回复下步骤时间线;openai-chat tools(DeepSeek);agent-accept 冒烟
This commit is contained in:
logan committed 2026-10-15 11:51:39 +08:00
1 parent b8134c4503
commit cd319ce7bd
14 files changed
+1270 -182

No files matched your search

+200 -103
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from "react";
import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
import { useWorkspaceStore } from "./store";
import { callAi, callAiAgent, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
import { AI_PRESETS, findAiPreset, type AiAuthStyle, type AiProtocol } from "./aiPresets";
import { useWorkspaceStore, type AiAgentStepView, type AiChatMode } 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: "dialect", label: "方言" },
{ id: "sample", label: "样例" },
];
@@ -92,45 +93,120 @@ function MessageContent({ content }: { content: string }) {
);
}
/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
function AiSettings({ onClose }: { onClose: () => void }) {
/** 阶段 2:Agent 步骤时间线 — 挂在回复下,默认可折叠,展示最近步骤 */
function AgentSteps({ steps }: { steps: AiAgentStepView[] }) {
const [open, setOpen] = useState(false); // 默认最近 3 步
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"
style={{ background: 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` : ""}
</span>
</li>
))}
</ul>
</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("openai");
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(() => {});
.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(服务端掩码不回传明文)
// 留空表示不改动已保存的 key(服务端掩码不回传明文,保存时保留旧值)
apiKey: apiKey.trim() || undefined,
model: model.trim(),
protocol,
authStyle,
allowInsecureHttp,
});
if (model.trim()) set("aiModel", model.trim());
setHasKey(hasKey || !!apiKey.trim());
setHasKey(hasKey || !!apiKey.trim() || authStyle === "none");
setApiKey("");
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
return true;
} catch (e) {
@@ -160,41 +236,68 @@ function AiSettings({ onClose }: { onClose: () => void }) {
}
};
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 (
<div className="space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<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) => setProvider(e.target.value)}
aria-label="Provider"
onChange={(e) => applyPreset(e.target.value)}
aria-label="Provider 预设"
>
<option value="stub">stub(离线占位)</option>
<option value="openai">openai(OpenAI 兼容)</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="模型,如 gpt-4o-mini"
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>
<input
className="ops-input w-full py-0.5 text-[11px]"
placeholder="Base URL,如 https://api.openai.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 ? "API Key(已配置,留空不改动)" : "API Key"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
aria-label="API Key"
/>
{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()}>
保存
@@ -206,7 +309,7 @@ function AiSettings({ onClose }: { onClose: () => void }) {
收起
</button>
</div>
</div>
</fieldset>
);
}
@@ -215,6 +318,7 @@ export function AiPanel() {
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);
@@ -222,18 +326,14 @@ export function AiPanel() {
const result = useWorkspaceStore((s) => s.result);
const set = useWorkspaceStore((s) => s.set);
const pushAi = useWorkspaceStore((s) => s.pushAi);
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
const updateTabSqlIfUnchanged = useWorkspaceStore((s) => s.updateTabSqlIfUnchanged);
const [showSettings, setShowSettings] = useState(false);
/** C4:真实配置的模型清单(来自 /api/ai/config) */
const [cfgModel, setCfgModel] = useState<string>("");
useEffect(() => {
let alive = true;
getAiConfig()
.then((cfg) => {
if (!alive) return;
if (cfg.model) {
setCfgModel(cfg.model);
const cur = useWorkspaceStore.getState().aiModel;
if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model);
}
@@ -247,10 +347,14 @@ export function AiPanel() {
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 run = async (action: AiAction, prompt?: string) => {
const setMode = (mode: AiChatMode) => set("aiMode", mode);
const runAsk = async (action: AiAction, prompt?: string) => {
const text = (prompt ?? ask).trim();
if (action === "ask" && !text) return;
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);
@@ -258,20 +362,19 @@ export function AiPanel() {
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
}
try {
// C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts)
const schema = schemaContextFor(conn?.id, conn?.database || tab?.database);
const schema = schemaContextFor(conn?.id, database);
const res = await callAi({
action,
prompt: text,
sql,
driver: conn?.driver,
database: conn?.database || tab?.database,
database: database,
error: result?.error,
schema: schema || undefined,
model: model && model !== "opsxdb-stub" ? model : undefined,
});
pushAi("assistant", res.content);
if (res.sql) updateActiveSql(res.sql);
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)}`);
@@ -280,25 +383,39 @@ export function AiPanel() {
}
};
const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean)));
const runAgent = async () => {
const text = ask.trim();
if (busy || !text) return;
set("aiBusy", true);
pushAi("user", text);
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", "");
} catch (e) {
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
} finally {
set("aiBusy", false);
}
};
const send = () => {
if (aiMode === "agent") void runAgent();
else void runAsk(action);
};
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>AI</span>
<span>新对话</span>
<div className="flex items-center gap-1">
<select
className="ops-input h-6 py-0 text-[10px] normal-case tracking-normal"
value={model}
onChange={(e) => set("aiModel", e.target.value)}
title="模型(来自 /api/ai/config,可在设置中修改)"
>
{modelOptions.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<button
type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
@@ -325,52 +442,15 @@ export function AiPanel() {
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
<div className="grid grid-cols-2 gap-1 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
<select
className="ops-input py-0.5 text-[11px]"
value={activeConnectionId ?? ""}
onChange={(e) => set("activeConnectionId", e.target.value || null)}
>
<option value="">连接</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<input
className="ops-input py-0.5 text-[11px]"
placeholder="库"
value={conn?.database ?? ""}
readOnly
/>
</div>
<div
className="flex flex-wrap gap-1 border-b px-1.5 py-1"
style={{ borderColor: "var(--ops-border)" }}
>
{ACTIONS.map((a) => (
<button
key={a.id}
type="button"
disabled={busy}
onClick={() => void run(a.id, ask)}
className="ops-btn-ai"
title={a.label}
>
{a.label}
</button>
))}
</div>
<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) => (
@@ -396,29 +476,46 @@ export function AiPanel() {
{m.role === "user" ? "你" : "AI"} · {m.at}
</div>
<MessageContent content={m.content} />
{m.role === "assistant" && m.steps && m.steps.length > 0 ? <AgentSteps steps={m.steps} /> : null}
</div>
))}
</div>
<div className="border-t p-1.5" style={{ borderColor: "var(--ops-border)" }}>
<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" ? " · 只读工具" : ""}</div>
<div className="flex gap-1">
<input
className="ops-input flex-1"
placeholder="描述查询…"
<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) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void run("ask");
send();
}
}}
/>
<button type="button" className="ops-btn-primary" disabled={busy} onClick={() => void run("ask")}>
<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 经 aitools 只读查库(与 MCP 同源);写操作一律拒绝。连接须在 ~/.opsxdb/mcp.json 白名单。
</div>
)}
</div>
</aside>
);