feat(ai): 新会话/清屏 + 工作中转圈 + 可编辑单槽排队

对标 opsxshell/aiops 契约 14 与附图 UX:顶栏新会话与清屏;
忙时「工作中 N 秒」;排队卡支持立即/编辑/删除;Ask 亦单槽排队;
run 世代防停止后旧 finally 清 busy。
This commit is contained in:
logan committed 2026-10-15 14:28:57 +08:00
1 parent d543f3b924
commit 9c2b623ce4
4 files changed
+318 -77

No files matched your search

+13 -1
View File
@@ -267,7 +267,19 @@ confirm_bind = sha256(connection_id + "\n" + normalize(sql_or_command))
| ✅ | 会话落盘 + HTTP 续跑(`GET/DELETE /api/ai/sessions`、`POST …/continue`)+ UI「加预算继续」 | | ✅ | 会话落盘 + HTTP 续跑(`GET/DELETE /api/ai/sessions`、`POST …/continue`)+ UI「加预算继续」 |
| ✅ | NeedConfirm 结束 run 为 `awaiting_confirm` outcome | | ✅ | NeedConfirm 结束 run 为 `awaiting_confirm` outcome |
| ✅ | 同参循环拒 + 中段检查点(第 4 / 6 轮)接入主循环 | | ✅ | 同参循环拒 + 中段检查点(第 4 / 6 轮)接入主循环 |
| ✅ | 忙时单槽排队 UI(覆盖 / 撤销 / 立即 / 结束后自动续发) | | ✅ | 忙时单槽排队 UI(覆盖 / 编辑 / 撤销 / 立即 / 工作中计时 / 结束后自动续发;Ask+Agent) |
| ✅ | 新会话 / 清屏(侧栏顶栏;清屏丢 sessionId 防隐式续跑) |
### 侧栏 UX(对标 opsxshell / 附图)
| 控件 | 行为 |
|------|------|
| **新会话** | 停止当前 → 清空消息 / sessionId / 排队 / 输入 |
| **清屏** | 停止当前 → 清空消息与排队(草稿保留);丢掉 sessionId |
| **工作中 N 秒** | `aiBusy` 时列表底转圈计时 |
| **排队卡** | 单槽:`立即`(中断开跑)/ `编辑`(拉回输入)/ `删除`(撤销) |
| **忙时输入** | placeholder「继续输入以排队…」;发送=入队(再提交覆盖) |
| ⬜ | Ask 真 token 流式(非假打字)— 成本高:需 openai-chat / anthropic / responses 三套 SSE;本轮记缺口,Ask 仍一次性 Complete | | ⬜ | Ask 真 token 流式(非假打字)— 成本高:需 openai-chat / anthropic / responses 三套 SSE;本轮记缺口,Ask 仍一次性 Complete |
| ⬜ | MCP「断开」结束外部 Cursor 会话(当前仅 UI 提示) | | ⬜ | MCP「断开」结束外部 Cursor 会话(当前仅 UI 提示) |
| ⬜ | 设置页一键 `npm install -g`(现为复制命令;避免桌面代跑需授权) | | ⬜ | 设置页一键 `npm install -g`(现为复制命令;避免桌面代跑需授权) |
+238 -64
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ArrowUp, Eraser, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { import {
callAi, callAi,
callAiAgentStream, callAiAgentStream,
@@ -424,7 +425,13 @@ export function AiPanel() {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [mcp, setMcp] = useState<McpStatus | null>(null); const [mcp, setMcp] = useState<McpStatus | null>(null);
const [mcpOpen, setMcpOpen] = useState(false); const [mcpOpen, setMcpOpen] = useState(false);
const [elapsedSec, setElapsedSec] = useState(0);
const [editingQueue, setEditingQueue] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
/** 世代号:停止/立即发送时 bump,避免旧 finally 清 busy 或误续发排队 */
const runGenRef = useRef(0);
const skipAutoQueueRef = useRef(false);
const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -461,6 +468,27 @@ export function AiPanel() {
}; };
}, []); }, []);
// 工作中计时(对标 shell / 附图「工作中 N 秒」)
useEffect(() => {
if (!busy) {
setElapsedSec(0);
return;
}
const started = Date.now();
setElapsedSec(0);
const id = window.setInterval(() => {
setElapsedSec(Math.max(0, Math.floor((Date.now() - started) / 1000)));
}, 500);
return () => window.clearInterval(id);
}, [busy]);
// 新消息滚到底
useEffect(() => {
const el = listRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, busy, aiQueuedPrompt]);
const conn = connections.find((c) => c.id === activeConnectionId); const conn = connections.find((c) => c.id === activeConnectionId);
const tab = tabs.find((t) => t.id === activeTabId); const tab = tabs.find((t) => t.id === activeTabId);
const sql = tab?.sql ?? ""; const sql = tab?.sql ?? "";
@@ -470,39 +498,76 @@ export function AiPanel() {
const setMode = (mode: AiChatMode) => set("aiMode", mode); const setMode = (mode: AiChatMode) => set("aiMode", mode);
const stopAgent = () => { const stopAgent = () => {
runGenRef.current += 1;
skipAutoQueueRef.current = true;
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = null; abortRef.current = null;
set("aiBusy", false); set("aiBusy", false);
// 下一 tick 恢复自动续发开关(给「立即」microtask 用)
queueMicrotask(() => {
skipAutoQueueRef.current = false;
});
};
const flushQueuedAfterIdle = () => {
if (skipAutoQueueRef.current) return;
const queued = useWorkspaceStore.getState().aiQueuedPrompt;
if (!queued?.trim()) return;
set("aiQueuedPrompt", null);
const mode = useWorkspaceStore.getState().aiMode;
queueMicrotask(() => {
if (mode === "agent") void runAgent({ prompt: queued.trim() });
else void runAsk(action, queued.trim());
});
}; };
const runAsk = async (action: AiAction, prompt?: string) => { const runAsk = async (action: AiAction, prompt?: string) => {
const text = (prompt ?? ask).trim(); const text = (prompt ?? ask).trim();
if (busy || !conn || ((action === "ask" || action === "generate") && !text)) return; if (!conn || ((action === "ask" || action === "generate") && !text)) return;
if (useWorkspaceStore.getState().aiBusy) return;
const gen = ++runGenRef.current;
set("aiBusy", true); set("aiBusy", true);
if (action === "ask" || action === "generate") { if (action === "ask" || action === "generate") {
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action); pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
} else { } else {
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`); pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
} }
if (prompt !== undefined || action === "ask" || action === "generate") {
if (!prompt) set("aiAsk", "");
}
const ac = new AbortController();
abortRef.current = ac;
try { try {
const schema = schemaContextFor(conn?.id, database); const schema = schemaContextFor(conn?.id, database);
const res = await callAi({ const res = await callAi(
action, {
prompt: text, action,
sql, prompt: text,
driver: conn?.driver, sql,
database: database, driver: conn?.driver,
error: result?.error, database: database,
schema: schema || undefined, error: result?.error,
model: model && model !== "opsxdb-stub" ? model : undefined, schema: schema || undefined,
}); model: model && model !== "opsxdb-stub" ? model : undefined,
},
ac.signal,
);
if (runGenRef.current !== gen) return;
pushAi("assistant", res.content); pushAi("assistant", res.content);
if (res.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, 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) { } catch (e) {
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`); if (runGenRef.current !== gen) return;
if ((e as Error)?.name === "AbortError") {
pushAi("assistant", "已停止");
} else {
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
}
} finally { } finally {
set("aiBusy", false); if (runGenRef.current === gen) {
abortRef.current = null;
set("aiBusy", false);
flushQueuedAfterIdle();
}
} }
}; };
@@ -514,8 +579,9 @@ export function AiPanel() {
}) => { }) => {
const text = (opts?.prompt ?? ask).trim(); const text = (opts?.prompt ?? ask).trim();
const isContinue = !!opts?.continue; const isContinue = !!opts?.continue;
if (busy) return; if (useWorkspaceStore.getState().aiBusy) return;
if (!isContinue && !text) return; if (!isContinue && !text) return;
const gen = ++runGenRef.current;
set("aiBusy", true); set("aiBusy", true);
if (!opts?.skipUserBubble) { if (!opts?.skipUserBubble) {
pushAi("user", isContinue && !text ? "加预算继续" : text); pushAi("user", isContinue && !text ? "加预算继续" : text);
@@ -539,21 +605,25 @@ export function AiPanel() {
}, },
{ {
onStepStart: (step) => { onStepStart: (step) => {
if (runGenRef.current !== gen) return;
patchAiMessage(asstId, (m) => { patchAiMessage(asstId, (m) => {
const withoutPending = (m.steps || []).filter((s) => !s.pending); const withoutPending = (m.steps || []).filter((s) => !s.pending);
return { ...m, steps: [...withoutPending, { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }] }; return { ...m, steps: [...withoutPending, { ...step, pending: true, ok: false, summary: "执行中…", ms: 0 }] };
}); });
}, },
onStep: (step) => { onStep: (step) => {
if (runGenRef.current !== gen) return;
patchAiMessage(asstId, (m) => { patchAiMessage(asstId, (m) => {
const withoutPending = (m.steps || []).filter((s) => !s.pending); const withoutPending = (m.steps || []).filter((s) => !s.pending);
return { ...m, steps: [...withoutPending, step] }; return { ...m, steps: [...withoutPending, step] };
}); });
}, },
onDelta: (delta) => { onDelta: (delta) => {
if (runGenRef.current !== gen) return;
patchAiMessage(asstId, (m) => ({ ...m, content: (m.content || "") + delta, streaming: true })); patchAiMessage(asstId, (m) => ({ ...m, content: (m.content || "") + delta, streaming: true }));
}, },
onDone: (final) => { onDone: (final) => {
if (runGenRef.current !== gen) return;
patchAiMessage(asstId, { patchAiMessage(asstId, {
content: final.content, content: final.content,
steps: final.steps || [], steps: final.steps || [],
@@ -569,6 +639,7 @@ export function AiPanel() {
if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql); if (final.sql && tab) updateTabSqlIfUnchanged(tab.id, sql, tab.connectionId, tab.database, final.sql);
}, },
onIncomplete: (final) => { onIncomplete: (final) => {
if (runGenRef.current !== gen) return;
patchAiMessage(asstId, { patchAiMessage(asstId, {
content: final.content, content: final.content,
steps: final.steps || [], steps: final.steps || [],
@@ -583,6 +654,7 @@ export function AiPanel() {
if (final.sessionId) set("aiSessionId", final.sessionId); if (final.sessionId) set("aiSessionId", final.sessionId);
}, },
onSession: (meta) => { onSession: (meta) => {
if (runGenRef.current !== gen) return;
if (meta.sessionId) set("aiSessionId", meta.sessionId); if (meta.sessionId) set("aiSessionId", meta.sessionId);
patchAiMessage(asstId, (m) => ({ patchAiMessage(asstId, (m) => ({
...m, ...m,
@@ -593,6 +665,7 @@ export function AiPanel() {
})); }));
}, },
onError: (err, partial) => { onError: (err, partial) => {
if (runGenRef.current !== gen) return;
patchAiMessage(asstId, { patchAiMessage(asstId, {
content: partial?.content || `失败:${err}`, content: partial?.content || `失败:${err}`,
steps: (partial?.steps as AiAgentStepView[]) || [], steps: (partial?.steps as AiAgentStepView[]) || [],
@@ -605,6 +678,7 @@ export function AiPanel() {
); );
void res; void res;
} catch (e) { } catch (e) {
if (runGenRef.current !== gen) return;
if ((e as Error)?.name === "AbortError") { if ((e as Error)?.name === "AbortError") {
patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" })); patchAiMessage(asstId, (m) => ({ ...m, content: m.content || "已停止", streaming: false, runOutcome: "cancelled" }));
} else { } else {
@@ -615,31 +689,37 @@ export function AiPanel() {
}); });
} }
} finally { } finally {
abortRef.current = null; if (runGenRef.current === gen) {
set("aiBusy", false); abortRef.current = null;
patchAiMessage(asstId, (m) => ({ ...m, streaming: false })); set("aiBusy", false);
// 忙时单槽:结束后自动续发排队 patchAiMessage(asstId, (m) => ({ ...m, streaming: false }));
const queued = useWorkspaceStore.getState().aiQueuedPrompt; flushQueuedAfterIdle();
if (queued && queued.trim()) {
set("aiQueuedPrompt", null);
void runAgent({ prompt: queued.trim() });
} }
} }
}; };
/** 发送或入队(对标 opsxshell / aiops 契约 14) */
const send = () => { const send = () => {
const text = ask.trim();
if (aiMode === "agent") { if (aiMode === "agent") {
const text = ask.trim();
if (!text) return; if (!text) return;
if (busy) { if (busy) {
// 单槽排队:再提交覆盖
set("aiQueuedPrompt", text); set("aiQueuedPrompt", text);
set("aiAsk", ""); set("aiAsk", "");
setEditingQueue(false);
return; return;
} }
void runAgent(); void runAgent();
return; return;
} }
// Ask:忙时同样单槽排队
if (busy) {
if (!text) return;
set("aiQueuedPrompt", text);
set("aiAsk", "");
setEditingQueue(false);
return;
}
void runAsk(action); void runAsk(action);
}; };
@@ -655,18 +735,53 @@ export function AiPanel() {
}); });
}; };
/** 立即:中断当前 → 立刻跑排队内容 */
const flushQueueNow = () => { const flushQueueNow = () => {
const q = useWorkspaceStore.getState().aiQueuedPrompt; const q = useWorkspaceStore.getState().aiQueuedPrompt;
if (!q?.trim()) return; if (!q?.trim()) return;
set("aiQueuedPrompt", null); set("aiQueuedPrompt", null);
abortRef.current?.abort(); setEditingQueue(false);
// 下一 tick 跑排队(等 abort finally 清 busy) stopAgent();
window.setTimeout(() => { const mode = useWorkspaceStore.getState().aiMode;
void runAgent({ prompt: q.trim() }); queueMicrotask(() => {
}, 50); if (mode === "agent") void runAgent({ prompt: q.trim() });
else void runAsk(action, q.trim());
});
}; };
const discardQueue = () => set("aiQueuedPrompt", null); /** 编辑:拉回输入框,清空排队槽 */
const editQueue = () => {
const q = useWorkspaceStore.getState().aiQueuedPrompt;
if (!q) return;
set("aiAsk", q);
set("aiQueuedPrompt", null);
setEditingQueue(true);
};
/** 撤销 / 删除排队 */
const discardQueue = () => {
set("aiQueuedPrompt", null);
setEditingQueue(false);
};
/** 新会话:丢弃轨迹与 sessionId,可重新开跑 */
const newSession = () => {
stopAgent();
set("aiMessages", []);
set("aiSessionId", null);
set("aiQueuedPrompt", null);
set("aiAsk", "");
setEditingQueue(false);
};
/** 清屏:只清可见消息与排队,草稿保留;sessionId 一并丢掉以免隐式续跑旧轨迹 */
const clearScreen = () => {
if (busy) stopAgent();
set("aiMessages", []);
set("aiSessionId", null);
set("aiQueuedPrompt", null);
setEditingQueue(false);
};
const pendingConfirm = (() => { const pendingConfirm = (() => {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
@@ -678,11 +793,19 @@ export function AiPanel() {
return null; return null;
})(); })();
const placeholder = busy
? aiQueuedPrompt
? "继续输入以覆盖排队内容…"
: "继续输入以排队后续修改"
: aiMode === "agent"
? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行"
: "描述你想查询什么…";
return ( return (
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l"> <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"> <div className="ops-section-label justify-between border-b gap-1">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5 min-w-0">
新对话 <span className="truncate">AI 会话</span>
{mcp?.active ? ( {mcp?.active ? (
<button <button
type="button" type="button"
@@ -696,7 +819,28 @@ export function AiPanel() {
</button> </button>
) : null} ) : null}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
className="ops-btn-ghost inline-flex h-5 items-center gap-0.5 px-1.5 text-[10px]"
title="新会话"
aria-label="新会话"
onClick={newSession}
>
<Plus className="h-3 w-3" aria-hidden />
新会话
</button>
<button
type="button"
className="ops-btn-ghost inline-flex h-5 items-center gap-0.5 px-1.5 text-[10px]"
title="清屏"
aria-label="清屏"
disabled={messages.length === 0 && !aiQueuedPrompt}
onClick={clearScreen}
>
<Eraser className="h-3 w-3" aria-hidden />
清屏
</button>
<button <button
type="button" type="button"
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]" className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
@@ -744,8 +888,8 @@ export function AiPanel() {
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />} {showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5"> <div ref={listRef} className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
{messages.length === 0 && ( {messages.length === 0 && !busy && (
<div className="grid h-full place-items-center px-2 text-center text-[12px]" style={{ color: "var(--ops-muted)" }}> <div className="grid h-full place-items-center px-2 text-center text-[12px]" style={{ color: "var(--ops-muted)" }}>
{aiMode === "agent" {aiMode === "agent"
? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表" ? "Agent 会真查白名单里的库。试试:列出 demo 有哪些表"
@@ -826,45 +970,58 @@ export function AiPanel() {
) : null} ) : null}
</div> </div>
))} ))}
</div>
{aiQueuedPrompt ? ( {busy ? (
<div <div className="ops-ai-working" role="status" aria-live="polite">
className="flex items-center gap-1.5 border-t px-2 py-1 text-[11px]" <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" style={{ color: "var(--ops-accent)" }} aria-hidden />
style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)", background: "var(--ops-surface-2)" }} <span>工作中 {elapsedSec} 秒</span>
> </div>
<span className="min-w-0 flex-1 truncate" title={aiQueuedPrompt}> ) : null}
已排队:{aiQueuedPrompt}
</span> {aiQueuedPrompt ? (
<button type="button" className="ops-btn-ghost h-5 px-1.5 text-[10px]" onClick={discardQueue}> <div className="ops-ai-queue-card" role="status">
撤销 <div className="ops-ai-queue-card-body" title={aiQueuedPrompt}>
</button> <span className="ops-ai-queue-grip" aria-hidden>
<button type="button" className="ops-btn-secondary h-5 px-1.5 text-[10px]" onClick={flushQueueNow} disabled={!busy}> ⠿
立即 </span>
</button> <span className="ops-ai-queue-text">{aiQueuedPrompt}</span>
</div> </div>
) : null} <div className="ops-ai-queue-actions">
<button type="button" className="ops-btn-secondary h-6 px-2 text-[10px] inline-flex items-center gap-1" onClick={flushQueueNow} title="中断当前并立即发送">
<ArrowUp className="h-3 w-3" aria-hidden />
立即
</button>
<button type="button" className="ops-btn-ghost h-6 w-6 grid place-items-center p-0" onClick={editQueue} title="编辑排队内容" aria-label="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button type="button" className="ops-btn-ghost h-6 w-6 grid place-items-center p-0" onClick={discardQueue} title="撤销排队" aria-label="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : null}
</div>
<div className="ops-ai-composer"> <div className="ops-ai-composer">
<div className="ops-ai-mode" role="group" aria-label="Ask 或 Agent"> <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}> <button type="button" aria-pressed={aiMode === "ask"} className={aiMode === "ask" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("ask")}>
Ask Ask
</button> </button>
<button type="button" aria-pressed={aiMode === "agent"} className={aiMode === "agent" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("agent")} disabled={busy}> <button type="button" aria-pressed={aiMode === "agent"} className={aiMode === "agent" ? "ops-ai-mode-active" : undefined} onClick={() => setMode("agent")}>
Agent Agent
</button> </button>
</div> </div>
<div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库"> <div className="ops-ai-context" title="跟随 SQL 编辑器当前数据库">
{conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"} {conn?.name || "未选择连接"} › {conn?.driver.toLowerCase() === "redis" ? `DB${database}` : database || "未选择数据库"}
{aiMode === "agent" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""} {aiMode === "agent" ? (mcp?.writesEnabled ? " · 可写需确认" : " · 只读工具") : ""}
{aiSessionId ? ` · ${aiSessionId.slice(0, 12)}…` : ""}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<textarea <textarea
rows={3} rows={3}
className="ops-input min-w-0 flex-1 resize-none" className="ops-input min-w-0 flex-1 resize-none"
placeholder={aiMode === "agent" ? "让 Agent 查库…例如:demo 有哪些表?users 前 5 行" : "描述你想查询什么…"} placeholder={placeholder}
value={ask} value={ask}
disabled={busy && aiMode !== "agent"}
onChange={(e) => set("aiAsk", e.target.value)} onChange={(e) => set("aiAsk", e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
@@ -873,13 +1030,25 @@ export function AiPanel() {
} }
}} }}
/> />
{busy && aiMode === "agent" ? ( {busy ? (
<button type="button" className="ops-btn-secondary" onClick={stopAgent}> <div className="flex flex-col gap-1">
停止 <button type="button" className="ops-btn-secondary inline-flex items-center justify-center gap-1" onClick={stopAgent} title="停止当前运行">
</button> 停止
</button>
<button
type="button"
className="ops-btn-primary inline-flex items-center justify-center gap-1"
disabled={!ask.trim()}
onClick={send}
title={ask.trim() ? "加入排队(单槽,覆盖)" : "输入后可排队"}
>
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
排队
</button>
</div>
) : ( ) : (
<button type="button" className="ops-btn-primary" disabled={busy || (aiMode === "agent" && !ask.trim())} onClick={send}> <button type="button" className="ops-btn-primary" disabled={aiMode === "agent" && !ask.trim()} onClick={send}>
{busy ? "…" : "发送"} 发送
</button> </button>
)} )}
</div> </div>
@@ -892,6 +1061,7 @@ export function AiPanel() {
aria-pressed={action === a.id} aria-pressed={action === a.id}
className={action === a.id ? "ops-ai-action-active" : undefined} className={action === a.id ? "ops-ai-action-active" : undefined}
onClick={() => setAction(a.id)} onClick={() => setAction(a.id)}
disabled={busy}
> >
{a.id === "generate" ? "生成 SQL" : a.label} {a.id === "generate" ? "生成 SQL" : a.label}
</button> </button>
@@ -899,7 +1069,11 @@ export function AiPanel() {
</div> </div>
) : ( ) : (
<div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}> <div className="mt-1.5 text-[10px]" style={{ color: "var(--ops-muted)" }}>
Agent 与 MCP 同源工具;默认只读。写操作需确认。忙时可再输入排队(单槽);触顶可「加预算继续」。 {busy
? editingQueue
? "编辑中:改完回车或点「排队」覆盖单槽。"
: "工作中可继续输入排队(单槽);点排队卡「立即」可改方向。"
: "Agent 与 MCP 同源工具;默认只读。写操作需确认。触顶可「加预算继续」。"}
</div> </div>
)} )}
</div> </div>
+15 -12
View File
@@ -511,18 +511,21 @@ export type AiAction =
| "dialect" | "dialect"
| "sample"; | "sample";
export async function callAi(payload: { export async function callAi(
action: AiAction; payload: {
prompt?: string; action: AiAction;
sql?: string; prompt?: string;
driver?: string; sql?: string;
database?: string; driver?: string;
error?: string; database?: string;
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */ error?: string;
schema?: string; /** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
model?: string; schema?: string;
}) { model?: string;
return post<{ content: string; sql?: string }>("/api/ai/chat", payload); },
signal?: AbortSignal,
) {
return post<{ content: string; sql?: string }>("/api/ai/chat", payload, signal);
} }
/** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */ /** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */
+52
View File
@@ -708,3 +708,55 @@
@keyframes ops-ai-blink { 50% { opacity: 0; } } @keyframes ops-ai-blink { 50% { opacity: 0; } }
.ops-ai-step-spin { animation: ops-ai-pulse 0.9s ease-in-out infinite; } .ops-ai-step-spin { animation: ops-ai-pulse 0.9s ease-in-out infinite; }
@keyframes ops-ai-pulse { 50% { opacity: 0.35; transform: scale(0.85); } } @keyframes ops-ai-pulse { 50% { opacity: 0.35; transform: scale(0.85); } }
/* AI 会话:工作中 / 排队卡(对标 opsxshell · aiops 契约 14) */
.ops-ai-working {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
font-size: 11px;
color: var(--ops-muted);
}
.ops-ai-queue-card {
display: flex;
align-items: center;
gap: 8px;
margin: 2px 0 4px;
padding: 8px 10px;
border: 1px solid color-mix(in srgb, var(--ops-accent) 28%, var(--ops-border));
border-radius: 10px;
background: color-mix(in srgb, var(--ops-accent) 8%, var(--ops-surface-2));
}
.ops-ai-queue-card-body {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
flex: 1;
}
.ops-ai-queue-grip {
color: var(--ops-muted);
font-size: 12px;
line-height: 1.2;
user-select: none;
}
.ops-ai-queue-text {
min-width: 0;
flex: 1;
font-size: 12px;
color: var(--ops-text);
white-space: pre-wrap;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ops-ai-queue-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}