feat(agentcli): Web 端运行中消息排队
忙时输入入队,支持立即(abort 后发)、编辑、撤销; 任务结束后自动 continue 排队内容。配套 docs/14。
This commit is contained in:
1 parent
1c98c25c74
commit
95066083bd
3 files changed
+133
-9
No files matched your search
@@ -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/可续跑统一语义 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 长队列(首版单槽)
|
||||
- 跨会话排队
|
||||
@@ -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<string>('balanced');
|
||||
const [items, setItems] = useState<ChatItem[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [queuedInput, setQueuedInput] = useState<string | null>(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<WebSocket | null>(null);
|
||||
const idSeq = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const queuedRef = useRef<string | null>(null);
|
||||
const sendNowAfterAbortRef = useRef<string | null>(null);
|
||||
|
||||
const nextId = () => ++idSeq.current;
|
||||
const setQueued = (v: string | null) => {
|
||||
queuedRef.current = v;
|
||||
setQueuedInput(v);
|
||||
};
|
||||
|
||||
const push = useCallback((it: Omit<ChatItem, 'id'>) => {
|
||||
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 */}
|
||||
<div className="border-t border-[var(--dg-border)] p-3 space-y-2 shrink-0">
|
||||
{queuedInput ? (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2 space-y-2">
|
||||
<div className="text-[10px] dg-text-muted">排队中(当前任务结束后自动发送)</div>
|
||||
<div className="text-xs whitespace-pre-wrap line-clamp-2 dg-text-secondary">{queuedInput}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={sendQueuedNow} className="bg-blue-600 hover:bg-blue-700 h-7 text-xs">
|
||||
<ArrowUp className="w-3.5 h-3.5 mr-1" /> 立即
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={editQueued} className="h-7 text-xs">
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelQueued} className="h-7 text-xs">
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
placeholder={
|
||||
viewMode ? t('agentcli.s1i2j3k4l')
|
||||
: running ? '继续输入以排队…'
|
||||
: canContinue ? t('agentcli.s2j3k4l5m')
|
||||
: wsReady ? t('agentcli.s3k4l5m6n')
|
||||
: (cliMode === 'kubernetes' ? t('agentcli.s1y2z3a4b') : t('agentcli.s2z3a4b5c'))
|
||||
@@ -848,11 +932,19 @@ export default function AgentCLI() {
|
||||
className="w-full resize-none dg-input min-h-[5rem] max-h-40 text-sm leading-relaxed disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] dg-text-muted">{t('agentcli.s4l5m6n7o')}</span>
|
||||
<span className="text-[10px] dg-text-muted">
|
||||
{running ? '任务进行中:发送将进入排队' : t('agentcli.s4l5m6n7o')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{running ? (
|
||||
<>
|
||||
<Button size="sm" onClick={send} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-3.5 h-3.5 mr-1" /> 排队
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={stop} className="bg-red-600 hover:bg-red-700">
|
||||
<Square className="w-3.5 h-3.5 mr-1" /> {t('agentcli.s9g0h1i2j')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onClick={send} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="w-3.5 h-3.5 mr-1" /> {t('agentcli.s0h1i2j3k')}
|
||||
@@ -860,6 +952,7 @@ export default function AgentCLI() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
Reference in new issue
Block a user