diff --git a/docs/02-运行中消息排队.md b/docs/02-运行中消息排队.md new file mode 100644 index 0000000..a9dc48c --- /dev/null +++ b/docs/02-运行中消息排队.md @@ -0,0 +1,30 @@ +# 运行中消息排队(三产品统一) + +> // Logan.Li +> 更新:2026-09-19 +> 对标常见 Agent UX(Zcode / Cursor):当前任务仍在跑时,允许预写下一条。 + +## 语义 + +| 动作 | 含义 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| **排队** | 运行中提交的新输入进入 `pending`(默认一条;再提交则覆盖或需先撤销) | +| **自动续发** | 当前任务 `completed` / `incomplete` / `failed` / `cancelled` 结束后,若仍有 pending,自动开始该条(continue/追问语义) | +| **立即** | 中断当前任务,立刻用 pending 内容开跑 | +| **编辑** | 把 pending 拉回输入框,清空排队槽 | +| **撤销** | 丢弃 pending,不发送 | + +与 [运行结果状态与可续跑契约]((见 opsxcli/aiops docs/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/docs/README.md b/docs/README.md index f041b34..897f493 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,3 +5,5 @@ | [01-AI-Agent步数预算与incomplete](01-AI-Agent步数预算与incomplete.md) | MAX_AGENT_STEPS=12 与 incomplete/续跑目标 | 跨产品契约以 aiops/opsxcli 的 `13-运行结果状态与可续跑契约.md` 为准。 + +- [02-运行中消息排队](02-运行中消息排队.md) — 忙时排队 / 立即 / 编辑 / 撤销 diff --git a/src/components/ai/AiTerminalPanel.tsx b/src/components/ai/AiTerminalPanel.tsx index fd6cfb0..c96c09a 100644 --- a/src/components/ai/AiTerminalPanel.tsx +++ b/src/components/ai/AiTerminalPanel.tsx @@ -5,10 +5,12 @@ import { CheckCircle2, Clipboard, Loader2, + Pencil, Plus, Send, Sparkles, Square, + ArrowUp, TerminalSquare, TextSelect, X, @@ -182,6 +184,12 @@ export default function AiTerminalPanel({ const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands) const setSetting = useSettingsStore((state) => state.setSetting) const [prompt, setPrompt] = useState('') + const [queuedPrompt, setQueuedPrompt] = useState(null) + const queuedRef = useRef(null) + const setQueued = (v: string | null) => { + queuedRef.current = v + setQueuedPrompt(v) + } const [messages, setMessages] = useState([]) const [liveSteps, setLiveSteps] = useState([]) const [error, setError] = useState('') @@ -220,12 +228,13 @@ export default function AiTerminalPanel({ setLiveSteps([]) setError('') setPrompt('') + setQueued(null) setCopiedId('') sessionAllowRef.current.clear() initialSelectionRef.current = '' } - const runAgent = async () => { + const runAgent = async (taskOverride?: string) => { setError('') if (!provider || !model) { setError(t('error.noProvider')) @@ -235,7 +244,7 @@ export default function AiTerminalPanel({ setError(t('error.noApiKey', { provider: provider.name })) return } - const task = prompt.trim() + const task = (taskOverride ?? prompt).trim() if (!task) { setError(t('error.emptyTask')) return @@ -431,10 +440,54 @@ export default function AiTerminalPanel({ if (runIdRef.current === runId) { runConnectionIdRef.current = null setRunning(false) + // 自动续发排队消息(契约 14) // Logan.Li + const pending = queuedRef.current + if (pending && pending.trim()) { + const next = pending.trim() + setQueued(null) + queueMicrotask(() => { + void runAgent(next) + }) + } } } } + const enqueueOrSend = () => { + const task = prompt.trim() + if (!task) { + setError(t('error.emptyTask')) + return + } + if (running) { + setQueued(task) + setPrompt('') + setError('') + return + } + void runAgent(task) + } + + const sendQueuedNow = () => { + const task = queuedPrompt?.trim() + if (!task) return + setQueued(null) + stopAgent() + queueMicrotask(() => { + void runAgent(task) + }) + } + + const editQueued = () => { + if (!queuedPrompt) return + setPrompt(queuedPrompt) + setQueued(null) + } + + const cancelQueued = () => { + setQueued(null) + } + const copyMessage = async (id: string, content: string) => { await writeClipboardText(content) setCopiedId(id) @@ -642,32 +695,61 @@ export default function AiTerminalPanel({
+ {queuedPrompt ? ( +
+
{t('panel.queueHint')}
+
{queuedPrompt}
+
+ + + +
+
+ ) : null}