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:
1 parent
b8134c4503
commit
cd319ce7bd
14 files changed
+1270
-182
No files matched your search
+200
-103
@@ -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>
|
||||
);
|
||||
|
||||
+27
@@ -525,6 +525,33 @@ export async function callAi(payload: {
|
||||
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
|
||||
}
|
||||
|
||||
/** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */
|
||||
export type AiAgentStep = {
|
||||
tool: string;
|
||||
args?: string;
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
error?: string;
|
||||
ms: number;
|
||||
};
|
||||
|
||||
export type AiAgentRes = {
|
||||
content: string;
|
||||
sql?: string;
|
||||
steps: AiAgentStep[];
|
||||
rounds: number;
|
||||
};
|
||||
|
||||
export async function callAiAgent(payload: {
|
||||
prompt: string;
|
||||
connectionId?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
return post<AiAgentRes>("/api/ai/agent", payload);
|
||||
}
|
||||
|
||||
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */
|
||||
export type AiConfig = {
|
||||
provider: string;
|
||||
|
||||
+19
-3
@@ -189,13 +189,26 @@ export type ConfirmRequest = {
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type AiAgentStepView = {
|
||||
tool: string;
|
||||
args?: string;
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
error?: string;
|
||||
ms: number;
|
||||
};
|
||||
|
||||
export type AiMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
at: string;
|
||||
/** Agent 工具步骤时间线(仅 assistant) */
|
||||
steps?: AiAgentStepView[];
|
||||
};
|
||||
|
||||
export type AiChatMode = "ask" | "agent";
|
||||
|
||||
export type ViewMode = "workbench" | "picker";
|
||||
export type PickerMode = "list" | "drivers";
|
||||
|
||||
@@ -236,6 +249,8 @@ type State = {
|
||||
aiAsk: string;
|
||||
aiModel: string;
|
||||
aiBusy: boolean;
|
||||
/** Ask=不调工具;Agent=多轮 aitools */
|
||||
aiMode: AiChatMode;
|
||||
status: string;
|
||||
notifyModal: NotifyPayload | null;
|
||||
notifyToasts: NotifyPayload[];
|
||||
@@ -305,7 +320,7 @@ type State = {
|
||||
updateActiveSql: (sql: string) => void;
|
||||
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
|
||||
updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean;
|
||||
pushAi: (role: AiMessage["role"], content: string) => void;
|
||||
pushAi: (role: AiMessage["role"], content: string, steps?: AiAgentStepView[]) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||
clearHistory: () => void;
|
||||
@@ -465,6 +480,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
aiAsk: "",
|
||||
aiModel: "opsxdb-stub",
|
||||
aiBusy: false,
|
||||
aiMode: "ask",
|
||||
status: "就绪",
|
||||
notifyModal: null,
|
||||
notifyToasts: [],
|
||||
@@ -878,11 +894,11 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
pushAi: (role, content) =>
|
||||
pushAi: (role, content, steps) =>
|
||||
set((s) => ({
|
||||
aiMessages: [
|
||||
...s.aiMessages,
|
||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() },
|
||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString(), steps },
|
||||
],
|
||||
})),
|
||||
|
||||
|
||||
+154
-3
@@ -90,6 +90,7 @@
|
||||
.ops-shell {
|
||||
background: var(--ops-bg);
|
||||
color: var(--ops-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ops-input {
|
||||
@@ -162,11 +163,37 @@
|
||||
.ops-btn-ai:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.ops-tabbar {
|
||||
@apply flex shrink-0 items-stretch gap-0.5 overflow-x-auto border-b px-1;
|
||||
height: var(--ops-tab-h);
|
||||
@apply flex shrink-0 items-end gap-1 overflow-x-auto border-b px-2;
|
||||
height: 38px;
|
||||
border-color: var(--ops-border);
|
||||
background: var(--ops-surface-2);
|
||||
}
|
||||
.ops-worktab {
|
||||
@apply flex h-[32px] min-w-0 max-w-[210px] flex-none items-center rounded-t-md border border-b-0 text-[12px] transition-colors;
|
||||
width: clamp(108px, 13vw, 210px);
|
||||
border-color: transparent;
|
||||
color: var(--ops-muted);
|
||||
}
|
||||
.ops-worktab:hover { background: var(--ops-bg-active); color: var(--ops-text); }
|
||||
.ops-worktab-active {
|
||||
border-color: var(--ops-border);
|
||||
background: var(--ops-surface);
|
||||
color: var(--ops-text);
|
||||
box-shadow: inset 0 2px 0 var(--ops-accent);
|
||||
}
|
||||
.ops-worktab-label {
|
||||
@apply flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden pl-2 text-left;
|
||||
}
|
||||
.ops-worktab-label svg, .ops-worktab-label img { @apply shrink-0; }
|
||||
.ops-worktab-unsaved { @apply h-1.5 w-1.5 shrink-0 rounded-full; background: var(--ops-accent); }
|
||||
.ops-worktab-close {
|
||||
@apply mx-1 grid h-5 w-5 shrink-0 place-items-center rounded text-[13px] opacity-0 transition-opacity;
|
||||
color: var(--ops-muted);
|
||||
}
|
||||
.ops-worktab:hover .ops-worktab-close, .ops-worktab-active .ops-worktab-close { opacity: 0.75; }
|
||||
.ops-worktab-close:hover { background: var(--ops-bg-active); color: var(--ops-text); opacity: 1 !important; }
|
||||
.ops-worktab-add { @apply mb-1 grid h-6 w-6 shrink-0 place-items-center rounded text-[15px]; color: var(--ops-muted); }
|
||||
.ops-worktab-add:hover { background: var(--ops-bg-active); color: var(--ops-accent); }
|
||||
.ops-empty {
|
||||
@apply flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center;
|
||||
color: var(--ops-muted);
|
||||
@@ -293,9 +320,20 @@
|
||||
background: var(--ops-bg-active) !important;
|
||||
}
|
||||
.ops-grid th {
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
.ops-grid .ops-number { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.ops-grid .ops-empty-value { color: var(--ops-muted); font-style: italic; opacity: .7; }
|
||||
.ops-grid .ops-boolean { color: var(--ops-accent); }
|
||||
.ops-result-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--ops-border); font-size: 11px; color: var(--ops-muted); background: var(--ops-surface); }
|
||||
.ops-result-tools input { width: 190px; height: 26px; font-size: 12px; }
|
||||
.ops-result-location { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ops-text); }
|
||||
.ops-result-notice { padding: 4px 10px; font-size: 11px; color: var(--ops-warning); border-bottom: 1px solid var(--ops-border-soft); }
|
||||
.ops-load-more { display: flex; gap: 12px; justify-content: center; align-items: center; padding: 12px; font-size: 12px; color: var(--ops-muted); }
|
||||
.ops-value-detail { width: 100%; height: min(380px, 55vh); resize: vertical; font-family: monospace; white-space: pre; }
|
||||
.ops-grid th .ops-col-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -365,6 +403,32 @@
|
||||
box-shadow: var(--ops-shadow-modal);
|
||||
font-size: var(--ops-text-md);
|
||||
}
|
||||
.ops-modal-spacious {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
.ops-modal-spacious .ops-modal-body { min-height: 0; overflow: auto; }
|
||||
.ops-modal-spacious .ops-modal-footer { flex: none; }
|
||||
.ops-designer-grid { border-collapse: separate; border-spacing: 0; font-size: 11.5px; }
|
||||
.ops-designer-grid th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
background: var(--ops-surface-2);
|
||||
color: var(--ops-muted);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.ops-designer-grid th, .ops-designer-grid td {
|
||||
border-bottom: 1px solid var(--ops-border-soft);
|
||||
padding: 5px 6px;
|
||||
}
|
||||
.ops-designer-grid tbody tr:hover { background: var(--ops-bg-active); }
|
||||
.ops-designer-compact th, .ops-designer-compact td { padding-top: 2px; padding-bottom: 2px; }
|
||||
.ops-designer-compact .ops-input { height: 24px; }
|
||||
.ops-modal-header {
|
||||
@apply border-b px-3.5 py-2 font-semibold;
|
||||
font-size: var(--ops-text-md);
|
||||
@@ -380,6 +444,11 @@
|
||||
@apply flex justify-end gap-2 border-t px-3.5 py-2;
|
||||
border-color: var(--ops-border);
|
||||
}
|
||||
.ops-ctx.ops-action-menu { min-width: 232px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; }
|
||||
.ops-ctx.ops-action-menu button { display: flex; justify-content: space-between; gap: 20px; width: 100%; }
|
||||
.ops-action-menu button:focus-visible { outline: 1px solid var(--ops-accent); outline-offset: -2px; background: var(--ops-surface-2); }
|
||||
.ops-action-menu kbd { color: var(--ops-muted); font-size: 10px; }
|
||||
.ops-menu-caption { padding: 5px 9px 7px; color: var(--ops-muted); font-size: 11px; border-bottom: 1px solid var(--ops-border-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 危险确认语句清单(前 3 条 + 折叠,修 C2) */
|
||||
.ops-modal-items {
|
||||
@apply mt-2 space-y-1 rounded border px-2.5 py-2 font-mono whitespace-pre-wrap break-all;
|
||||
@@ -517,6 +586,8 @@
|
||||
position: fixed;
|
||||
z-index: var(--ops-z-ctx);
|
||||
min-width: 140px;
|
||||
max-height: calc(100vh - 16px);
|
||||
overflow-y: auto;
|
||||
padding: var(--ops-space-1);
|
||||
border-radius: var(--ops-radius-md);
|
||||
border: 1px solid var(--ops-border);
|
||||
@@ -540,6 +611,20 @@
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ops-ctx-group summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--ops-radius-sm);
|
||||
font-size: var(--ops-text-sm);
|
||||
}
|
||||
.ops-ctx-group summary::-webkit-details-marker { display: none; }
|
||||
.ops-ctx-group summary:hover { background: var(--ops-bg-active); }
|
||||
.ops-ctx-group[open] summary span { transform: rotate(90deg); }
|
||||
.ops-ctx-group button { padding-left: 22px; }
|
||||
.ops-ctx-sep {
|
||||
height: 1px;
|
||||
margin: 4px 6px;
|
||||
@@ -551,3 +636,69 @@
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ops-muted);
|
||||
}
|
||||
|
||||
/* Redis 键详情 · 行内四钮(悬停/选中才显;28×28 / 图标 16px) */
|
||||
.ops-redis-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
tr:hover .ops-redis-row-actions,
|
||||
tr.ops-redis-row-active .ops-redis-row-actions,
|
||||
.ops-redis-row-actions:focus-within {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ops-redis-row-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ops-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ops-redis-row-btn-copy:hover {
|
||||
color: var(--ops-accent);
|
||||
background: color-mix(in srgb, var(--ops-accent) 10%, transparent);
|
||||
}
|
||||
.ops-redis-row-btn-cmd {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.ops-redis-row-btn-del:hover {
|
||||
color: var(--ops-danger);
|
||||
background: color-mix(in srgb, var(--ops-danger) 10%, transparent);
|
||||
}
|
||||
.ops-redis-row-btn-flash {
|
||||
color: var(--ops-success, #22c55e);
|
||||
}
|
||||
|
||||
.ops-tree-meta-note { padding: 4px 8px; font-size: 11px; color: var(--ops-muted); }
|
||||
.ops-tree-meta-note button { color: var(--ops-accent); margin-left: 6px; }
|
||||
|
||||
.ops-ai-composer { margin: 8px; padding: 10px; border: 1px solid var(--ops-border); border-radius: 10px; background: var(--ops-surface); }
|
||||
.ops-ai-context { display: flex; gap: 8px; padding-bottom: 10px; font-size: 12px; color: var(--ops-muted); overflow-wrap: anywhere; }
|
||||
.ops-ai-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.ops-ai-actions button { border: 1px solid var(--ops-border); border-radius: 6px; padding: 3px 7px; font-size: 11px; color: var(--ops-muted); }
|
||||
.ops-ai-actions button:hover, .ops-ai-actions .ops-ai-action-active { color: var(--ops-accent); border-color: var(--ops-accent); background: var(--ops-accent-soft); }
|
||||
|
||||
/* Ask | Agent 分段(阶段 2) */
|
||||
.ops-ai-mode { display: flex; gap: 0; margin-bottom: 8px; border: 1px solid var(--ops-border); border-radius: 6px; overflow: hidden; width: fit-content; }
|
||||
.ops-ai-mode button { border: 0; background: transparent; padding: 3px 12px; font-size: 11px; color: var(--ops-muted); min-width: 52px; }
|
||||
.ops-ai-mode button:hover { color: var(--ops-text); }
|
||||
.ops-ai-mode .ops-ai-mode-active { color: var(--ops-accent); background: var(--ops-accent-soft); font-weight: 600; }
|
||||
.ops-ai-steps { font-variant-numeric: tabular-nums; }
|
||||
|
||||
Reference in new issue
Block a user