chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)

This commit is contained in:
logan committed 2026-10-14 20:31:17 +08:00
1 parent a9510235bb
commit 456fa6954b
203 files changed
+18787 -48

No files matched your search

+425
View File
@@ -0,0 +1,425 @@
import { useEffect, useState } from "react";
import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
import { useWorkspaceStore } from "./store";
const ACTIONS: { id: AiAction; label: string }[] = [
{ id: "generate", label: "生成" },
{ id: "explain", label: "解释" },
{ id: "optimize", label: "优化" },
{ id: "fix", label: "修复" },
{ id: "dialect", 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 copy = async () => {
await navigator.clipboard.writeText(code);
notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 });
};
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()}
>
复制
</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>
);
}
/** R2-F7:AI 消息渲染 —— 代码块走等宽块,其余纯文本(无代码块时整体不变) */
function MessageContent({ content }: { content: string }) {
const segs = splitContent(content);
if (segs.length === 1 && segs[0].kind === "text") return <>{content}</>;
return (
<>
{segs.map((s, i) =>
s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>,
)}
</>
);
}
/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
function AiSettings({ onClose }: { onClose: () => void }) {
const notify = useWorkspaceStore((s) => s.notify);
const set = useWorkspaceStore((s) => s.set);
const [provider, setProvider] = useState("openai");
const [baseURL, setBaseURL] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [hasKey, setHasKey] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
getAiConfig()
.then((cfg) => {
if (!alive) return;
setProvider(cfg.provider || "stub");
setBaseURL(cfg.baseURL || "");
setModel(cfg.model || "");
setHasKey(!!cfg.hasKey);
})
.catch(() => {});
return () => {
alive = false;
};
}, []);
const save = async () => {
setBusy(true);
try {
await saveAiConfig({
provider,
baseURL: baseURL.trim(),
// 留空表示不改动已保存的 key(服务端掩码不回传明文)
apiKey: apiKey.trim() || undefined,
model: model.trim(),
});
if (model.trim()) set("aiModel", model.trim());
setHasKey(hasKey || !!apiKey.trim());
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);
}
};
return (
<div className="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"
>
<option value="stub">stub(离线占位)</option>
<option value="openai">openai(OpenAI 兼容)</option>
</select>
<input
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
placeholder="模型,如 gpt-4o-mini"
value={model}
onChange={(e) => setModel(e.target.value)}
aria-label="模型"
/>
</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"
/>
<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>
</div>
);
}
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 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 updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
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);
}
})
.catch(() => {});
return () => {
alive = false;
};
}, [set]);
const conn = connections.find((c) => c.id === activeConnectionId);
const tab = tabs.find((t) => t.id === activeTabId);
const sql = tab?.sql ?? "";
const run = async (action: AiAction, prompt?: string) => {
const text = (prompt ?? ask).trim();
if (action === "ask" && !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 {
// C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts)
const schema = schemaContextFor(conn?.id, conn?.database || tab?.database);
const res = await callAi({
action,
prompt: text,
sql,
driver: conn?.driver,
database: conn?.database || tab?.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 (action === "ask" || action === "generate") set("aiAsk", "");
} catch (e) {
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
} finally {
set("aiBusy", false);
}
};
const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean)));
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>
<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]"
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>
{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)" }}
>
描述需求,或点上方快捷操作
</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} />
</div>
))}
</div>
<div className="border-t p-1.5" style={{ borderColor: "var(--ops-border)" }}>
<div className="flex gap-1">
<input
className="ops-input flex-1"
placeholder="描述查询…"
value={ask}
disabled={busy}
onChange={(e) => set("aiAsk", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void run("ask");
}
}}
/>
<button type="button" className="ops-btn-primary" disabled={busy} onClick={() => void run("ask")}>
{busy ? "…" : "发送"}
</button>
</div>
</div>
</aside>
);
}