diff --git a/docs/ai-agent/00-README.md b/docs/ai-agent/00-README.md index 208abe7..9d0b3f3 100644 --- a/docs/ai-agent/00-README.md +++ b/docs/ai-agent/00-README.md @@ -35,6 +35,7 @@ AI Agent / Agent CLI 是 AIOps 的智能执行入口。用户在浏览器中选 | 08 | [代码审计与平台智能化优化](08-代码审计与平台智能化优化.md) | Agent CLI 代码差距、AIOps 上下文工具、告警分析和预测分析升级路线 | | 09 | [三产品定位与执行边界](09-三产品定位与执行边界.md) | OpsXCLI、OpsXShell、AIOps 的独立目标、凭证、执行和审计边界 | | 08b | [代码审计与挂点索引](08-代码审计与挂点索引.md) | incomplete/续跑挂点速查(配套平台智能化优化长文) | +| 14 | [运行中消息排队](14-运行中消息排队.md) | 忙时排队 / 立即 / 编辑 / 撤销 | | 13 | [运行结果状态与可续跑契约](13-运行结果状态与可续跑契约.md) | **先 docs 后代码**:completed/incomplete/可续跑统一语义 | --- diff --git a/docs/ai-agent/14-运行中消息排队.md b/docs/ai-agent/14-运行中消息排队.md new file mode 100644 index 0000000..b27b688 --- /dev/null +++ b/docs/ai-agent/14-运行中消息排队.md @@ -0,0 +1,30 @@ +# 运行中消息排队(三产品统一) + +> // Logan.Li +> 更新:2026-09-19 +> 对标常见 Agent UX(Zcode / Cursor):当前任务仍在跑时,允许预写下一条。 + +## 语义 + +| 动作 | 含义 | +|------|------| +| **排队** | 运行中提交的新输入进入 `pending`(默认一条;再提交则覆盖或需先撤销) | +| **自动续发** | 当前任务 `completed` / `incomplete` / `failed` / `cancelled` 结束后,若仍有 pending,自动开始该条(continue/追问语义) | +| **立即** | 中断当前任务,立刻用 pending 内容开跑 | +| **编辑** | 把 pending 拉回输入框,清空排队槽 | +| **撤销** | 丢弃 pending,不发送 | + +与 [运行结果状态与可续跑契约](13-运行结果状态与可续跑契约.md) 的关系:排队是**同会话并发输入**;incomplete 续跑是**上一趟未完成再继续**。两者可叠加。 + +## 产品落点 + +| 产品 | UI | 中断手段 | +|------|-----|----------| +| opsxshell | Agent 面板排队条 | `stopAgent`(bump runId)后立刻 `runAgent` | +| aiops Web | AgentCLI 输入区排队条 | WS `abort` 后再 `continue`/`start` | +| opsxcli TUI | 忙时 Enter 入队;快捷键立即/编辑/撤销 | cancel 当前 RunStream ctx | + +## 不做 + +- 多条 FIFO 长队列(首版单槽) +- 跨会话排队 diff --git a/frontend/src/components/aiops/AgentCLI.tsx b/frontend/src/components/aiops/AgentCLI.tsx index c3b323f..370d1d1 100644 --- a/frontend/src/components/aiops/AgentCLI.tsx +++ b/frontend/src/components/aiops/AgentCLI.tsx @@ -16,7 +16,7 @@ import { buildAgentCliWsUrl, type AgentCliMode } from '@/api/aiops/agent-cli'; import { toast } from 'sonner'; import { Send, Square, Terminal, Shield, Brain, Cpu, Database, Loader2, Bot, Lightbulb, Server, - PanelLeftClose, History, ChevronUp, ChevronDown, Boxes, Cloud, + PanelLeftClose, History, ChevronUp, ChevronDown, Boxes, Cloud, Pencil, ArrowUp, } from 'lucide-react'; import { Input } from '@/components/ui/input'; import AgentCLIHistory from './AgentCLIHistory'; @@ -141,6 +141,7 @@ export default function AgentCLI() { const [safetyMode, setSafetyMode] = useState('balanced'); const [items, setItems] = useState([]); const [input, setInput] = useState(''); + const [queuedInput, setQueuedInput] = useState(null); const [running, setRunning] = useState(false); const [phase, setPhase] = useState('idle'); const [round, setRound] = useState(0); @@ -157,8 +158,15 @@ export default function AgentCLI() { const wsRef = useRef(null); const idSeq = useRef(0); const scrollRef = useRef(null); + const queuedRef = useRef(null); + const sendNowAfterAbortRef = useRef(null); const nextId = () => ++idSeq.current; + const setQueued = (v: string | null) => { + queuedRef.current = v; + setQueuedInput(v); + }; + const push = useCallback((it: Omit) => { setItems((prev) => [...prev, { ...it, id: nextId() }]); }, []); @@ -438,6 +446,17 @@ export default function AgentCLI() { setRunning(false); setCanContinue(true); setHistoryRefresh((k) => k + 1); + drainQueue(); + break; + } + case 'incomplete': { + // 契约 13:未完成也可续问 / 续发排队 // Logan.Li + setPhase('incomplete'); + push({ kind: 'info', content: ev.message || 'incomplete' }); + setRunning(false); + setCanContinue(true); + setHistoryRefresh((k) => k + 1); + drainQueue(); break; } case 'error': @@ -445,8 +464,9 @@ export default function AgentCLI() { setPhase(type === 'aborted' ? 'aborted' : 'error'); push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? t('agentcli.s1s2t3u4v') : t('agentcli.s2t3u4v5w')) }); setRunning(false); - setCanContinue(type !== 'aborted'); + setCanContinue(type !== 'aborted' || Boolean(sendNowAfterAbortRef.current)); setHistoryRefresh((k) => k + 1); + drainQueue(); break; } }; @@ -464,9 +484,9 @@ export default function AgentCLI() { if (!ok) push({ kind: 'info', content: t('agentcli.s7y8z9a0b', { name: ev.command || ev.tool_name }) }); }; - // 发送任务 // Logan.Li - const send = () => { - const q = input.trim(); + // 真正发到后端(不负责排队) // Logan.Li + const flushSend = (raw: string, opts?: { forceContinue?: boolean }) => { + const q = raw.trim(); if (!q) return; if (cliMode === 'host' && !serverId) { toast.error(t('agentcli.s8z9a0b1c')); return; } if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) { toast.error(t('agentcli.s9a0b1c2d')); return; } @@ -474,10 +494,9 @@ export default function AgentCLI() { toast.error(t('agentcli.s0b1c2d3e')); return; } - if (running) return; setViewMode(false); setViewSession(null); - const msgType = canContinue ? 'continue' : 'start'; + const msgType = (opts?.forceContinue || canContinue) ? 'continue' : 'start'; push({ kind: 'user', content: q }); wsRef.current.send(JSON.stringify({ type: msgType, query: q, safety_mode: safetyMode })); setInput(''); @@ -486,7 +505,54 @@ export default function AgentCLI() { if (msgType === 'start') setCanContinue(false); }; - const stop = () => wsRef.current?.send(JSON.stringify({ type: 'abort' })); + const drainQueue = () => { + const now = sendNowAfterAbortRef.current; + if (now) { + sendNowAfterAbortRef.current = null; + setQueued(null); + queueMicrotask(() => flushSend(now, { forceContinue: true })); + return; + } + const pending = queuedRef.current; + if (pending && pending.trim()) { + setQueued(null); + // 刚结束一轮任务后的排队续发,一律 continue // Logan.Li + queueMicrotask(() => flushSend(pending.trim(), { forceContinue: true })); + } + }; + + // 发送任务:忙时入队 // Logan.Li + const send = () => { + const q = input.trim(); + if (!q) return; + if (running) { + setQueued(q); + setInput(''); + return; + } + flushSend(q); + }; + + const sendQueuedNow = () => { + const q = queuedInput?.trim(); + if (!q) return; + sendNowAfterAbortRef.current = q; + setQueued(null); + wsRef.current?.send(JSON.stringify({ type: 'abort' })); + }; + + const editQueued = () => { + if (!queuedInput) return; + setInput(queuedInput); + setQueued(null); + }; + + const cancelQueued = () => setQueued(null); + + const stop = () => { + sendNowAfterAbortRef.current = null; + wsRef.current?.send(JSON.stringify({ type: 'abort' })); + }; // handleViewHistory 查看历史记录(只读,不执行命令) // Logan.Li const handleViewHistory = (replayItems: ChatItem[], sess: AgentCliSession) => { @@ -833,12 +899,30 @@ export default function AgentCLI() { {/* 加宽输入区:多行 + 独立操作栏 // Logan.Li */}
+ {queuedInput ? ( +
+
排队中(当前任务结束后自动发送)
+
{queuedInput}
+
+ + + +
+
+ ) : null}