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
+292 -51

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