-
-
▌ : null;
if (segs.length === 1 && segs[0].kind === "text") {
@@ -100,7 +94,7 @@ function MessageContent({ content, streaming }: { content: string; streaming?: b
}
return (
<>
- {segs.map((s, i) => (s.kind === "code" ? : {s.text}))}
+ {segs.map((s, i) => (s.kind === "code" ? : {s.text}))}
{cursor}
>
);
@@ -169,24 +163,40 @@ function AgentSteps({ steps }: { steps: AiAgentStepView[] }) {
function DangerConfirmBar({
sql,
connectionId,
+ connectionName,
database,
kind,
confirmBind,
runId,
+ ready,
+ currentContext,
onDone,
}: {
sql: string;
connectionId?: string;
+ connectionName?: string;
database?: string;
kind: "query" | "redis";
confirmBind?: string;
runId?: string;
+ ready: boolean;
+ currentContext: boolean;
onDone: (ok: boolean, msg?: string) => void;
}) {
const [busy, setBusy] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [error, setError] = useState("");
+ const presented = useRef(false);
+ useEffect(() => {
+ if (ready && currentContext && !presented.current) {
+ presented.current = true;
+ setOpen(true);
+ }
+ }, [ready, currentContext]);
const run = async () => {
- if (!connectionId || busy) return;
+ if (!connectionId || busy || !ready || !currentContext) return;
setBusy(true);
+ setError("");
try {
await confirmAiAgentWrite({
connectionId,
@@ -199,36 +209,67 @@ function DangerConfirmBar({
});
onDone(true);
} catch (e) {
- onDone(false, e instanceof Error ? e.message : String(e));
+ setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
+ <>
-
- 写操作需确认(一次一刀)
+
+ 待确认写操作 · {connectionName || "当前连接"}{database ? ` / ${kind === "redis" ? "DB" : ""}${database}` : ""}
-
+
{sql}
onDone(false)}>
取消
- void run()}>
- {busy ? "…" : "执行"}
+ setOpen(true)}>
+ {busy ? "执行中…" : "确认操作"}
+ {!currentContext && 切回该连接与数据库后确认。
}
+ {open && currentContext &&
void run()}
+ onCancel={() => { if (!busy) { setOpen(false); onDone(false); } }}
+ >
+
+
+ 连接:{connectionName || "当前连接"}
+ 数据库:{kind === "redis" ? `DB${database || "0"}` : database || "连接默认库"}
+
+
{sql}
+
仅执行本次显示的命令。删除或结构变更可能无法撤销。
+ {error &&
{error}
}
+
+ }
+ >
);
}
/** C4:AI 设置(预设 Provider / Base URL / API Key / 模型 + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
-export function AiSettings({ onClose }: { onClose: () => void }) {
+export type AiSettingsHandle = { save: () => Promise
; test: () => Promise };
+export type AiSettingsStatus = {
+ busy: boolean;
+ testing: boolean;
+ canSave: boolean;
+ canTest: boolean;
+ result: { ok: boolean; text: string } | null;
+};
+export const AiSettings = forwardRef void }>(function AiSettings({ onStatus }, ref) {
const notify = useWorkspaceStore((s) => s.notify);
const set = useWorkspaceStore((s) => s.set);
const [provider, setProvider] = useState("stub");
@@ -243,6 +284,13 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [reload, setReload] = useState(0);
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
+ const [testing, setTesting] = useState(false);
+ useEffect(() => {
+ onStatus({ busy: busy || testing, testing, canSave: !loading && !loadError && !busy && !testing,
+ canTest: !loading && !loadError && !busy && !testing && provider !== "stub", result: testResult });
+ }, [onStatus, busy, testing, loading, loadError, provider, testResult]);
useEffect(() => {
let alive = true;
@@ -268,6 +316,7 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
/** 选预设:自动填 Base URL / 协议 / 鉴权 / 默认模型;已手填的非预设值会被预设覆盖 */
const applyPreset = (id: string) => {
+ setTestResult(null);
setProvider(id);
if (id === "stub") return;
const preset = findAiPreset(id);
@@ -280,6 +329,7 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
};
const save = async () => {
+ if (loading || loadError || busy) return false;
if (provider !== "stub" && !baseURL.trim()) {
notify({ kind: "error", title: "AI 配置", message: "请填写 Base URL,或选择一个预设。", mode: "modal" });
return false;
@@ -299,7 +349,6 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
if (model.trim()) set("aiModel", model.trim());
setHasKey(hasKey || !!apiKey.trim() || authStyle === "none");
setApiKey("");
- 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" });
@@ -310,23 +359,22 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
};
const test = async () => {
- if (!(await save())) return;
- setBusy(true);
+ if (busy || testing || loading || loadError || provider === "stub") return;
+ setTesting(true);
+ setTestResult(null);
try {
+ if (!(await save())) return;
+ setBusy(true);
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,
- });
+ setTestResult({ ok: true, text: res.content ? "连接正常,模型已响应" : "连接正常" });
} catch (e) {
- notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" });
+ setTestResult({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
+ setTesting(false);
}
};
+ useImperativeHandle(ref, () => ({ save, test }));
if (loading) return 正在读取 AI 配置…
;
if (loadError) return {loadError} setReload((n) => n + 1)}>重试
;
@@ -335,75 +383,42 @@ export function AiSettings({ onClose }: { onClose: () => void }) {
const isLocalHttp = baseURL.trim().startsWith("http://");
return (
-
);
-}
+});
export function AiPanel() {
const messages = useWorkspaceStore((s) => s.aiMessages);
@@ -421,17 +436,19 @@ export function AiPanel() {
const set = useWorkspaceStore((s) => s.set);
const pushAi = useWorkspaceStore((s) => s.pushAi);
const patchAiMessage = useWorkspaceStore((s) => s.patchAiMessage);
- const updateTabSqlIfUnchanged = useWorkspaceStore((s) => s.updateTabSqlIfUnchanged);
- const [showSettings, setShowSettings] = useState(false);
const [mcp, setMcp] = useState(null);
const [mcpOpen, setMcpOpen] = useState(false);
const [elapsedSec, setElapsedSec] = useState(0);
- const [editingQueue, setEditingQueue] = useState(false);
const abortRef = useRef(null);
/** 世代号:停止/立即发送时 bump,避免旧 finally 清 busy 或误续发排队 */
const runGenRef = useRef(0);
const skipAutoQueueRef = useRef(false);
+ const queuedIntentRef = useRef<{ mode: AiChatMode; action: AiAction } | null>(null);
+ const runQueuedRef = useRef<(text: string, intent: { mode: AiChatMode; action: AiAction }) => void>(() => {});
+ const streamingIdRef = useRef(null);
const listRef = useRef(null);
+ const composingRef = useRef(false);
+ const compositionEndedAtRef = useRef(0);
useEffect(() => {
let alive = true;
@@ -493,6 +510,21 @@ export function AiPanel() {
const tab = tabs.find((t) => t.id === activeTabId);
const sql = tab?.sql ?? "";
const database = editorDatabase(conn, tab);
+ const contextKey = `${conn?.id || ""}\u0000${database}`;
+ const previousSessionMessage = [...messages].reverse().find((m) => m.sessionId === aiSessionId && m.role === "assistant");
+ const sessionContextRef = useRef(aiSessionId && previousSessionMessage?.connectionId === conn?.id && previousSessionMessage?.database === database ? contextKey : null);
+ const isCurrentContext = (key: string) => {
+ const state = useWorkspaceStore.getState();
+ const currentConn = state.connections.find((c) => c.id === state.activeConnectionId);
+ const currentTab = state.tabs.find((t) => t.id === state.activeTabId);
+ return `${currentConn?.id || ""}\u0000${editorDatabase(currentConn, currentTab)}` === key;
+ };
+ useEffect(() => {
+ if (sessionContextRef.current && sessionContextRef.current !== contextKey) {
+ set("aiSessionId", null);
+ sessionContextRef.current = null;
+ }
+ }, [contextKey, set]);
const [action, setAction] = useState("generate");
const setMode = (mode: AiChatMode) => set("aiMode", mode);
@@ -502,6 +534,10 @@ export function AiPanel() {
skipAutoQueueRef.current = true;
abortRef.current?.abort();
abortRef.current = null;
+ if (streamingIdRef.current) {
+ patchAiMessage(streamingIdRef.current, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" }));
+ streamingIdRef.current = null;
+ } else if (useWorkspaceStore.getState().aiBusy) pushAi("assistant", "已停止");
set("aiBusy", false);
// 下一 tick 恢复自动续发开关(给「立即」microtask 用)
queueMicrotask(() => {
@@ -513,11 +549,11 @@ export function AiPanel() {
if (skipAutoQueueRef.current) return;
const queued = useWorkspaceStore.getState().aiQueuedPrompt;
if (!queued?.trim()) return;
+ const intent = queuedIntentRef.current ?? { mode: useWorkspaceStore.getState().aiMode, action };
+ queuedIntentRef.current = null;
set("aiQueuedPrompt", null);
- const mode = useWorkspaceStore.getState().aiMode;
queueMicrotask(() => {
- if (mode === "agent") void runAgent({ prompt: queued.trim() });
- else void runAsk(action, queued.trim());
+ runQueuedRef.current(queued.trim(), intent);
});
};
@@ -530,11 +566,9 @@ export function AiPanel() {
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}`);
- }
- if (prompt !== undefined || action === "ask" || action === "generate") {
- if (!prompt) set("aiAsk", "");
+ pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}${text ? ` · ${text}` : ""}`);
}
+ if (!prompt) set("aiAsk", "");
const ac = new AbortController();
abortRef.current = ac;
try {
@@ -553,8 +587,7 @@ export function AiPanel() {
ac.signal,
);
if (runGenRef.current !== gen) return;
- pushAi("assistant", res.content);
- if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, res.sql);
+ pushAi("assistant", res.content, [], { sqlSuggestion: res.sql || undefined, connectionId: conn.id, connectionName: conn.name, database });
} catch (e) {
if (runGenRef.current !== gen) return;
if ((e as Error)?.name === "AbortError") {
@@ -580,18 +613,23 @@ export function AiPanel() {
const text = (opts?.prompt ?? ask).trim();
const isContinue = !!opts?.continue;
if (useWorkspaceStore.getState().aiBusy) return;
- if (!isContinue && !text) return;
+ if (!conn || (!isContinue && !text)) return;
const gen = ++runGenRef.current;
set("aiBusy", true);
if (!opts?.skipUserBubble) {
pushAi("user", isContinue && !text ? "加预算继续" : text);
}
if (!opts?.prompt) set("aiAsk", "");
- const asstId = pushAi("assistant", "", [], { streaming: true });
+ const asstId = pushAi("assistant", "", [], { streaming: true, connectionId: conn.id, connectionName: conn.name, database });
+ streamingIdRef.current = asstId;
const ac = new AbortController();
abortRef.current = ac;
- const sessionId = useWorkspaceStore.getState().aiSessionId || undefined;
+ const sessionId = sessionContextRef.current === contextKey ? useWorkspaceStore.getState().aiSessionId || undefined : undefined;
try {
+ const connections = useWorkspaceStore.getState().connections;
+ if (!connections.some((c) => c.id === conn.id)) throw new Error("当前连接已删除,请重新选择连接");
+ await syncConnectionsToDisk(connections);
+ if (ac.signal.aborted) throw new DOMException("已停止", "AbortError");
const res = await callAiAgentStream(
{
prompt: text,
@@ -635,8 +673,11 @@ export function AiPanel() {
incompleteReason: final.incompleteReason,
stepsBudget: final.stepsBudget,
});
- if (final.sessionId) set("aiSessionId", final.sessionId);
- if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
+ if (final.sessionId && isCurrentContext(contextKey)) {
+ sessionContextRef.current = contextKey;
+ set("aiSessionId", final.sessionId);
+ }
+ if (final.sql) patchAiMessage(asstId, { sqlSuggestion: final.sql });
},
onIncomplete: (final) => {
if (runGenRef.current !== gen) return;
@@ -651,11 +692,17 @@ export function AiPanel() {
incompleteReason: final.incompleteReason || "max_steps",
stepsBudget: final.stepsBudget,
});
- if (final.sessionId) set("aiSessionId", final.sessionId);
+ if (final.sessionId && isCurrentContext(contextKey)) {
+ sessionContextRef.current = contextKey;
+ set("aiSessionId", final.sessionId);
+ }
},
onSession: (meta) => {
if (runGenRef.current !== gen) return;
- if (meta.sessionId) set("aiSessionId", meta.sessionId);
+ if (meta.sessionId && isCurrentContext(contextKey)) {
+ sessionContextRef.current = contextKey;
+ set("aiSessionId", meta.sessionId);
+ }
patchAiMessage(asstId, (m) => ({
...m,
sessionId: meta.sessionId || m.sessionId,
@@ -691,6 +738,7 @@ export function AiPanel() {
} finally {
if (runGenRef.current === gen) {
abortRef.current = null;
+ streamingIdRef.current = null;
set("aiBusy", false);
patchAiMessage(asstId, (m) => ({ ...m, streaming: false }));
flushQueuedAfterIdle();
@@ -698,15 +746,21 @@ export function AiPanel() {
}
};
+ // 续发取当前渲染的编辑器上下文,避免旧请求闭包把消息发到先前的连接。
+ runQueuedRef.current = (text, intent) => {
+ if (intent.mode === "agent") void runAgent({ prompt: text });
+ else void runAsk(intent.action, text);
+ };
+
/** 发送或入队(对标 opsxshell / aiops 契约 14) */
const send = () => {
const text = ask.trim();
if (aiMode === "agent") {
if (!text) return;
if (busy) {
+ queuedIntentRef.current = { mode: aiMode, action };
set("aiQueuedPrompt", text);
set("aiAsk", "");
- setEditingQueue(false);
return;
}
void runAgent();
@@ -715,18 +769,18 @@ export function AiPanel() {
// Ask:忙时同样单槽排队
if (busy) {
if (!text) return;
+ queuedIntentRef.current = { mode: aiMode, action };
set("aiQueuedPrompt", text);
set("aiAsk", "");
- setEditingQueue(false);
return;
}
void runAsk(action);
};
const continueIncomplete = (m: AiMessage) => {
- if (busy) return;
+ if (busy || m.connectionId !== conn?.id || m.database !== database) return;
const sid = m.sessionId || aiSessionId;
- if (sid) set("aiSessionId", sid);
+ if (sid) { sessionContextRef.current = contextKey; set("aiSessionId", sid); }
void runAgent({
prompt: "",
continue: true,
@@ -739,13 +793,12 @@ export function AiPanel() {
const flushQueueNow = () => {
const q = useWorkspaceStore.getState().aiQueuedPrompt;
if (!q?.trim()) return;
+ const intent = queuedIntentRef.current ?? { mode: useWorkspaceStore.getState().aiMode, action };
+ queuedIntentRef.current = null;
set("aiQueuedPrompt", null);
- setEditingQueue(false);
stopAgent();
- const mode = useWorkspaceStore.getState().aiMode;
queueMicrotask(() => {
- if (mode === "agent") void runAgent({ prompt: q.trim() });
- else void runAsk(action, q.trim());
+ runQueuedRef.current(q.trim(), intent);
});
};
@@ -755,13 +808,13 @@ export function AiPanel() {
if (!q) return;
set("aiAsk", q);
set("aiQueuedPrompt", null);
- setEditingQueue(true);
+ queuedIntentRef.current = null;
};
/** 撤销 / 删除排队 */
const discardQueue = () => {
set("aiQueuedPrompt", null);
- setEditingQueue(false);
+ queuedIntentRef.current = null;
};
/** 新会话:丢弃轨迹与 sessionId,可重新开跑 */
@@ -769,9 +822,10 @@ export function AiPanel() {
stopAgent();
set("aiMessages", []);
set("aiSessionId", null);
+ sessionContextRef.current = null;
set("aiQueuedPrompt", null);
+ queuedIntentRef.current = null;
set("aiAsk", "");
- setEditingQueue(false);
};
/** 清屏:只清可见消息与排队,草稿保留;sessionId 一并丢掉以免隐式续跑旧轨迹 */
@@ -779,16 +833,19 @@ export function AiPanel() {
if (busy) stopAgent();
set("aiMessages", []);
set("aiSessionId", null);
+ sessionContextRef.current = null;
set("aiQueuedPrompt", null);
- setEditingQueue(false);
+ queuedIntentRef.current = null;
};
const pendingConfirm = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== "assistant" || !m.steps) continue;
- const step = [...m.steps].reverse().find((s) => s.needConfirm && s.confirmSql);
- if (step) return { messageId: m.id, step };
+ for (let stepIndex = m.steps.length - 1; stepIndex >= 0; stepIndex--) {
+ const step = m.steps[stepIndex];
+ if (step.needConfirm && step.confirmSql) return { messageId: m.id, step, stepIndex };
+ }
}
return null;
})();
@@ -798,11 +855,11 @@ export function AiPanel() {
? "继续输入以覆盖排队内容…"
: "继续输入以排队后续修改"
: aiMode === "agent"
- ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行"
- : "描述你想查询什么…";
+ ? "让 Agent 查询或分析当前数据库…"
+ : action === "generate" ? "描述你想查询的数据,例如:统计本月订单…" : "输入问题,或结合当前 SQL 提问…";
return (
-