diff --git a/docs/16-AI-Session-Harness.md b/docs/16-AI-Session-Harness.md index 00d4f8d..9b66b6b 100644 --- a/docs/16-AI-Session-Harness.md +++ b/docs/16-AI-Session-Harness.md @@ -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`(现为复制命令;避免桌面代跑需授权) | diff --git a/src/AiPanel.tsx b/src/AiPanel.tsx index 7002909..1291fbe 100644 --- a/src/AiPanel.tsx +++ b/src/AiPanel.tsx @@ -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(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 listRef = useRef(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,39 +498,76 @@ 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({ - action, - prompt: text, - sql, - driver: conn?.driver, - database: database, - error: result?.error, - schema: schema || undefined, - model: model && model !== "opsxdb-stub" ? model : undefined, - }); + const res = await callAi( + { + action, + prompt: text, + sql, + driver: conn?.driver, + database: database, + 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) { - 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 { - 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 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 { - 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() }); + if (runGenRef.current === gen) { + abortRef.current = null; + set("aiBusy", false); + patchAiMessage(asstId, (m) => ({ ...m, streaming: false })); + flushQueuedAfterIdle(); } } }; + /** 发送或入队(对标 opsxshell / aiops 契约 14) */ const send = () => { + const text = ask.trim(); if (aiMode === "agent") { - const text = ask.trim(); 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 (