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>
);
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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; }