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:
1 parent
c7d050f1bc
commit
4283fb93c1
6 files changed
+221
-149
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user