refactor: Q35 AgentCLI 巨型组件拆分 — 521L→399L (-23%)

提取 agent-cli/ 子目录 2 个文件:
- event-handler.ts (149L): createWsEventHandler 工厂函数封装 8 种 WS 事件分发
  (session/progress/thought_delta/thought/tool_call/tool_result/confirm/done/error/aborted)
  + 高危命令确认逻辑 (handleConfirm)
- ChatInputArea.tsx (44L): 受控输入区组件 (textarea + 发送/停止按钮)

附: 导出 ConfirmOptions 接口供跨模块引用

验证: tsc -b=0, npm build✓ (17.67s), ESLint=0/66/0, go build=0, 17/17 测试通过
This commit is contained in:
attacker committed 2026-07-10 07:53:25 +08:00
1 parent c7d050f1bc
commit 4283fb93c1
6 files changed
+221 -149

No files matched your search

+25 -147
View File
@@ -10,7 +10,7 @@ import { kubernetesApi } from '@/api/kubernetes';
import { buildAgentCliWsUrl, type AgentCliMode } from '@/api/aiops/agent-cli';
import { toast } from 'sonner';
import {
Send, Square, Bot,
Bot,
} from 'lucide-react';
import AgentCLIHistory from './AgentCLIHistory';
import { AgentChatTimeline, type ChatItem } from './AgentCLIMessages';
@@ -20,6 +20,8 @@ import ScrollButtons from '@/components/ScrollButtons';
import type { AgentCliSession } from '@/api/aiops/agent-cli';
import { PageShell } from '@/components/layout/PageShell';
import { POD_NONE, type WsEvent } from './agent-cli-constants';
import { createWsEventHandler } from './agent-cli/event-handler';
import ChatInputArea from './agent-cli/ChatInputArea';
export default function AgentCLI() {
const confirm = useConfirm();
@@ -58,6 +60,13 @@ export default function AgentCLI() {
setItems((prev) => [...prev, { ...it, id: nextId() }]);
}, []);
// WebSocket 事件处理(提取到 event-handler.ts) // Logan.Li
const { handleEvent } = createWsEventHandler({
push, nextId, setRound, setTokens, setRunning, setCanContinue,
setItems, setHistoryRefresh, setViewMode,
confirm, wsRef,
});
const selectedCluster = clusters.find((c) => c.code === clusterCode);
const wsReady = cliMode === 'host' ? Boolean(serverId) : Boolean(clusterCode && namespace);
@@ -214,123 +223,6 @@ export default function AgentCLI() {
return () => ro.disconnect();
}, [items.length, updateScrollState]);
// 处理后端事件 // Logan.Li
const handleEvent = (ev: WsEvent) => {
const type = ev.type;
switch (type) {
case 'session':
push({ kind: 'info', content: ev.message || '' });
// 仅「恢复历史会话」时进入可追问(新任务开始的 session 事件文案不同) // Logan.Li
if (ev.message?.includes('已恢复历史会话')) {
setViewMode(false);
setCanContinue(true);
}
break;
case 'progress':
if (ev.round) setRound(ev.round);
if (ev.tokens) setTokens(ev.tokens);
break;
case 'thought_delta':
setItems((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
next[next.length - 1] = { ...last, content: (last.content || '') + (ev.thought || '') };
} else {
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '', streaming: true });
}
return next;
});
break;
case 'thought':
setItems((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
next[next.length - 1] = { ...last, content: ev.thought || last.content, streaming: false };
} else {
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '' });
}
return next;
});
break;
case 'tool_call': {
const args = ev.tool_args || {};
push({
kind: 'tool', round: ev.round, toolName: ev.tool_name,
command: String(args.command || (args.action ? `${args.action} ${args.name || ''}` : '')), risk: ev.risk, pending: true,
});
break;
}
case 'tool_result':
setItems((prev) => {
const next = [...prev];
for (let i = next.length - 1; i >= 0; i--) {
if (next[i].kind === 'tool' && next[i].pending && next[i].toolName === ev.tool_name) {
next[i] = {
...next[i], output: ev.output, success: ev.success,
duration: ev.duration, pending: false,
};
break;
}
}
return next;
});
break;
case 'confirm':
handleConfirm(ev);
break;
case 'done': {
const text = (ev.answer || ev.message || '').trim();
setItems((prev) => {
const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' ');
// 移除与 done 同文的 thought,避免终局总结显示两遍 // Logan.Li
const next = prev.filter((it) => {
if (it.kind !== 'thought' || !text) return true;
return norm(it.content) !== norm(text);
});
const last = next[next.length - 1];
if (last?.kind === 'done') {
if (text) {
next[next.length - 1] = { ...last, content: text, round: ev.round ?? last.round };
}
return next;
}
return [...next, {
id: nextId(),
kind: 'done',
content: text || '任务已完成,请查看上方工具执行结果。',
round: ev.round,
}];
});
setRunning(false);
setCanContinue(true);
setHistoryRefresh((k) => k + 1);
break;
}
case 'error':
case 'aborted':
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') });
setRunning(false);
setCanContinue(type !== 'aborted');
setHistoryRefresh((k) => k + 1);
break;
}
};
// 高危命令确认(后端驱动 → 弹框 → 回传) // Logan.Li
const handleConfirm = async (ev: WsEvent) => {
const ok = await confirm({
title: '⚠️ 确认执行高危操作',
description: `工具:${ev.tool_name}\n风险:${ev.risk}\n命令:${ev.command || '(无)'}`,
confirmText: '确认执行',
cancelText: '拒绝',
variant: 'destructive',
});
wsRef.current?.send(JSON.stringify({ type: ok ? 'confirm' : 'deny', confirm_id: ev.confirm_id }));
if (!ok) push({ kind: 'info', content: `已拒绝执行:${ev.command || ev.tool_name}` });
};
// 发送任务 // Logan.Li
const send = () => {
const q = input.trim();
@@ -486,35 +378,21 @@ export default function AgentCLI() {
/>
</div>
{/* 加宽输入区:多行 + 独立操作栏 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] p-3 space-y-2 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
placeholder={
viewMode ? '查看模式中,点击「继续追问」或「新建任务」'
: canContinue ? '继续追问,回车发送(Shift+回车换行)'
: wsReady ? '输入任务描述,回车发送(Shift+回车换行)'
: (cliMode === 'kubernetes' ? '请先选择集群和命名空间' : '请先选择目标主机')
}
rows={3}
disabled={viewMode}
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">Shift+回车换行 · 回车发送</span>
{running ? (
<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" /> 停止
</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" /> 执行
</Button>
)}
</div>
</div>
{/* 输入区(提取到 ChatInputArea.tsx) // Logan.Li */}
<ChatInputArea
input={input}
setInput={setInput}
onSend={send}
onStop={stop}
running={running}
viewMode={viewMode}
placeholder={
viewMode ? '查看模式中,点击「继续追问」或「新建任务」'
: canContinue ? '继续追问,回车发送(Shift+回车换行)'
: wsReady ? '输入任务描述,回车发送(Shift+回车换行)'
: (cliMode === 'kubernetes' ? '请先选择集群和命名空间' : '请先选择目标主机')
}
/>
</Card>
</div>
</PageShell>
@@ -0,0 +1,44 @@
// ChatInputArea.tsx — AgentCLI 输入区(textarea + 发送/停止按钮)
// 从 AgentCLI.tsx 提取:受控展示组件,接收输入值 + 状态 + 回调
import { Send, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface ChatInputAreaProps {
input: string;
setInput: (v: string) => void;
onSend: () => void;
onStop: () => void;
running: boolean;
viewMode: boolean;
placeholder: string;
}
export default function ChatInputArea({
input, setInput, onSend, onStop, running, viewMode, placeholder,
}: ChatInputAreaProps) {
return (
<div className="border-t border-[var(--dg-border)] p-3 space-y-2 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSend(); } }}
placeholder={placeholder}
rows={3}
disabled={viewMode}
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">Shift+回车换行 · 回车发送</span>
{running ? (
<Button variant="destructive" size="sm" onClick={onStop} className="bg-red-600 hover:bg-red-700">
<Square className="w-3.5 h-3.5 mr-1" /> 停止
</Button>
) : (
<Button size="sm" onClick={onSend} disabled={!input.trim() || viewMode} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-3.5 h-3.5 mr-1" /> 执行
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,149 @@
// event-handler.ts — AgentCLI WebSocket 事件处理工厂函数
// 从 AgentCLI.tsx 提取:handleEvent(8 种事件分发)+ handleConfirm(高危命令确认)
// 纯函数工厂:接收 state setters + confirm 工具,返回事件处理函数
import type { Dispatch, SetStateAction, RefObject } from 'react';
import type { ChatItem } from '../AgentCLIMessages';
import type { WsEvent } from '../agent-cli-constants';
import type { ConfirmOptions } from '@/components/ui/confirm-dialog';
export interface WsEventHandlerDeps {
push: (it: Omit<ChatItem, 'id'>) => void;
nextId: () => number;
setRound: (v: number) => void;
setTokens: (v: number) => void;
setRunning: (v: boolean) => void;
setCanContinue: (v: boolean) => void;
setItems: Dispatch<SetStateAction<ChatItem[]>>;
setHistoryRefresh: Dispatch<SetStateAction<number>>;
setViewMode: (v: boolean) => void;
confirm: (opts: ConfirmOptions) => Promise<boolean>;
wsRef: RefObject<WebSocket | null>;
}
/** 创建 WebSocket 事件处理函数(含高危命令确认逻辑) */
export function createWsEventHandler(deps: WsEventHandlerDeps) {
const {
push, nextId, setRound, setTokens, setRunning, setCanContinue,
setItems, setHistoryRefresh, setViewMode,
confirm, wsRef,
} = deps;
// 高危命令确认(后端驱动 → 弹框 → 回传)
async function handleConfirm(ev: WsEvent) {
const ok = await confirm({
title: '⚠️ 确认执行高危操作',
description: `工具:${ev.tool_name}\n风险:${ev.risk}\n命令:${ev.command || '(无)'}`,
confirmText: '确认执行',
cancelText: '拒绝',
variant: 'destructive',
});
wsRef.current?.send(JSON.stringify({ type: ok ? 'confirm' : 'deny', confirm_id: ev.confirm_id }));
if (!ok) push({ kind: 'info', content: `已拒绝执行:${ev.command || ev.tool_name}` });
}
// 处理后端事件
function handleEvent(ev: WsEvent) {
const type = ev.type;
switch (type) {
case 'session':
push({ kind: 'info', content: ev.message || '' });
// 仅「恢复历史会话」时进入可追问(新任务开始的 session 事件文案不同)
if (ev.message?.includes('已恢复历史会话')) {
setViewMode(false);
setCanContinue(true);
}
break;
case 'progress':
if (ev.round) setRound(ev.round);
if (ev.tokens) setTokens(ev.tokens);
break;
case 'thought_delta':
setItems((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
next[next.length - 1] = { ...last, content: (last.content || '') + (ev.thought || '') };
} else {
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '', streaming: true });
}
return next;
});
break;
case 'thought':
setItems((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last?.kind === 'thought' && last.streaming && last.round === ev.round) {
next[next.length - 1] = { ...last, content: ev.thought || last.content, streaming: false };
} else {
next.push({ id: nextId(), kind: 'thought', round: ev.round, content: ev.thought || '' });
}
return next;
});
break;
case 'tool_call': {
const args = ev.tool_args || {};
push({
kind: 'tool', round: ev.round, toolName: ev.tool_name,
command: String(args.command || (args.action ? `${args.action} ${args.name || ''}` : '')), risk: ev.risk, pending: true,
});
break;
}
case 'tool_result':
setItems((prev) => {
const next = [...prev];
for (let i = next.length - 1; i >= 0; i--) {
if (next[i].kind === 'tool' && next[i].pending && next[i].toolName === ev.tool_name) {
next[i] = {
...next[i], output: ev.output, success: ev.success,
duration: ev.duration, pending: false,
};
break;
}
}
return next;
});
break;
case 'confirm':
handleConfirm(ev);
break;
case 'done': {
const text = (ev.answer || ev.message || '').trim();
setItems((prev) => {
const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' ');
// 移除与 done 同文的 thought,避免终局总结显示两遍
const next = prev.filter((it) => {
if (it.kind !== 'thought' || !text) return true;
return norm(it.content) !== norm(text);
});
const last = next[next.length - 1];
if (last?.kind === 'done') {
if (text) {
next[next.length - 1] = { ...last, content: text, round: ev.round ?? last.round };
}
return next;
}
return [...next, {
id: nextId(),
kind: 'done',
content: text || '任务已完成,请查看上方工具执行结果。',
round: ev.round,
}];
});
setRunning(false);
setCanContinue(true);
setHistoryRefresh((k) => k + 1);
break;
}
case 'error':
case 'aborted':
push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') });
setRunning(false);
setCanContinue(type !== 'aborted');
setHistoryRefresh((k) => k + 1);
break;
}
}
return { handleEvent, handleConfirm };
}
@@ -7,7 +7,7 @@ import {
import { Button } from '@/components/ui/button';
import i18n from '@/i18n';
interface ConfirmOptions {
export interface ConfirmOptions {
title?: string;
description?: string;
confirmText?: string;