chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)
This commit is contained in:
1 parent
a9510235bb
commit
456fa6954b
203 files changed
+18787
-48
No files matched your search
+425
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user