From 0690285147702944e8d3a401946e0d8f831e1bf3 Mon Sep 17 00:00:00 2001 From: "hermes-$(hostname)" Date: Wed, 8 Jul 2026 07:03:30 +0000 Subject: [PATCH] agent-cli --- backend/controller/agent_cli.go | 43 ++++++----- backend/pkg/agentcli/core/agent.go | 75 +++++++++++++------ frontend/src/components/aiops/AgentCLI.tsx | 57 +++++++++++++- .../src/components/aiops/AgentCLIMessages.tsx | 68 +++++++++++++++-- 4 files changed, 192 insertions(+), 51 deletions(-) diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index 10b0a99..c24a11a 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -36,21 +36,24 @@ func NewAgentCLIHandler(db *gorm.DB) *AgentCLIHandler { return &AgentCLIHandler{ // wsEvent WebSocket 事件信封(后端→前端) // Logan.Li type wsEvent struct { - Type string `json:"type"` - Round int `json:"round,omitempty"` - SessionID uint `json:"session_id,omitempty"` - Thought string `json:"thought,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolArgs map[string]interface{} `json:"tool_args,omitempty"` - Risk string `json:"risk,omitempty"` - Output string `json:"output,omitempty"` - Success bool `json:"success,omitempty"` - Duration string `json:"duration,omitempty"` - Tokens int `json:"tokens,omitempty"` - Answer string `json:"answer,omitempty"` - Message string `json:"message,omitempty"` - ConfirmID string `json:"confirm_id,omitempty"` - Command string `json:"command,omitempty"` + Type string `json:"type"` + Phase string `json:"phase,omitempty"` + Summary string `json:"summary,omitempty"` + Round int `json:"round,omitempty"` + SessionID uint `json:"session_id,omitempty"` + Thought string `json:"thought,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs map[string]interface{} `json:"tool_args,omitempty"` + Risk string `json:"risk,omitempty"` + Output string `json:"output,omitempty"` + Success bool `json:"success,omitempty"` + Duration string `json:"duration,omitempty"` + OutputSize int `json:"output_size,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` + ConfirmID string `json:"confirm_id,omitempty"` + Command string `json:"command,omitempty"` } // wsControl 前端→后端控制消息 // Logan.Li @@ -456,9 +459,10 @@ func (ac *agentConn) onEvent(e core.Event) { } ev := wsEvent{ - Type: string(e.Type), Round: e.Round, Thought: e.Thought, + Type: string(e.Type), Phase: e.Phase, Summary: e.Summary, + Round: e.Round, Thought: e.Thought, ToolName: e.ToolName, ToolArgs: e.ToolArgs, Risk: e.Risk, - Output: e.Output, Success: e.Success, Duration: e.Duration, + Output: e.Output, Success: e.Success, Duration: e.Duration, OutputSize: e.OutputSize, Tokens: e.Tokens, Answer: e.Answer, Message: e.Message, } if ac.session != nil { @@ -472,6 +476,9 @@ func (ac *agentConn) onEvent(e core.Event) { } sid := ac.session.ID switch e.Type { + case core.EventStatus: + // status 事件不改 DB schema:duration 字段在 type=status 时复用为 phase。 + ac.persistMsg(sid, e.Round, "status", "system", e.Summary, e.ToolName, "", "", e.Risk, e.Phase != core.PhaseError && e.Phase != core.PhaseBlocked, e.Phase, e.Tokens) case core.EventThought: ac.persistMsg(sid, e.Round, "thought", "assistant", e.Thought, "", "", "", "", false, "", 0) case core.EventToolCall: @@ -503,7 +510,7 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") { cmd = fmt.Sprintf("%v %v", args["action"], args["name"]) } - ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args}) + ac.write(wsEvent{Type: "confirm", Phase: core.PhaseApproval, Summary: "等待用户确认", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args}) ch := make(chan bool, 1) ac.cfMu.Lock() diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go index b7ca228..035d9e7 100644 --- a/backend/pkg/agentcli/core/agent.go +++ b/backend/pkg/agentcli/core/agent.go @@ -23,29 +23,49 @@ import ( type EventType string const ( + EventStatus EventType = "status" // 生命周期状态(accepted/context/thinking/...) EventThought EventType = "thought" // AI 思考/推理文本(流式结束后的完整内容) EventThoughtDelta EventType = "thought_delta" // AI 思考流式增量 token EventToolCall EventType = "tool_call" // 即将执行的工具(名/参数/风险) - EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) - EventProgress EventType = "progress" // 轮次进度 + token - EventDone EventType = "done" // 任务完成(最终答案) - EventError EventType = "error" // 错误(含达到最大轮次) + EventToolResult EventType = "tool_result" // 工具执行结果(输出/成败/耗时) + EventProgress EventType = "progress" // 轮次进度 + token + EventDone EventType = "done" // 任务完成(最终答案) + EventError EventType = "error" // 错误(含达到最大轮次) +) + +const ( + PhaseAccepted = "accepted" + PhaseContext = "context" + PhaseThinking = "thinking" + PhasePlanning = "planning" + PhaseSafety = "safety_check" + PhaseApproval = "approval_required" + PhaseToolRunning = "tool_running" + PhaseObserving = "observing" + PhaseValidating = "validating" + PhaseSummarizing = "summarizing" + PhaseDone = "done" + PhaseBlocked = "blocked" + PhaseError = "error" ) // Event 引擎产生的事件(controller 序列化后通过 WebSocket 推送) // Logan.Li type Event struct { - Type EventType `json:"type"` - Round int `json:"round,omitempty"` - Thought string `json:"thought,omitempty"` - ToolName string `json:"tool_name,omitempty"` - ToolArgs map[string]interface{} `json:"tool_args,omitempty"` - Risk string `json:"risk,omitempty"` - Output string `json:"output,omitempty"` - Success bool `json:"success,omitempty"` - Duration string `json:"duration,omitempty"` - Tokens int `json:"tokens,omitempty"` - Answer string `json:"answer,omitempty"` - Message string `json:"message,omitempty"` + Type EventType `json:"type"` + Phase string `json:"phase,omitempty"` + Summary string `json:"summary,omitempty"` + Round int `json:"round,omitempty"` + Thought string `json:"thought,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs map[string]interface{} `json:"tool_args,omitempty"` + Risk string `json:"risk,omitempty"` + Output string `json:"output,omitempty"` + Success bool `json:"success,omitempty"` + Duration string `json:"duration,omitempty"` + OutputSize int `json:"output_size,omitempty"` + Tokens int `json:"tokens,omitempty"` + Answer string `json:"answer,omitempty"` + Message string `json:"message,omitempty"` } // EventCallback 事件回调(controller 注入) // Logan.Li @@ -127,6 +147,7 @@ func (a *Agent) emit(e Event) { func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*RunResult, error) { a.totalTokens = priorTokens start := time.Now() + a.emit(Event{Type: EventStatus, Phase: PhaseAccepted, Summary: "任务已接收"}) // Evolver PREDICT:获取历史经验注入 system prompt evolveCtx := "" @@ -137,6 +158,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 工具描述 toolDescs := a.buildToolDescriptions() systemPrompt := prompt.BuildSystemPrompt(a.config.Mode, serverInfo, toolDescs, evolveCtx) + a.emit(Event{Type: EventStatus, Phase: PhaseContext, Summary: fmt.Sprintf("mode=%s tools=%d prior_messages=%d", a.config.Mode, len(a.registry.List()), len(prior))}) messages := []llm.Message{{Role: "system", Content: systemPrompt}} if len(prior) > 0 { @@ -168,6 +190,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // ReAct 须用 Complete:Stream 无法组装 tool_calls/tool_use(GLM/Claude 等会只回文字不执行) // Logan.Li round := a.roundNum(iter) + a.emit(Event{Type: EventStatus, Phase: PhaseThinking, Round: round, Summary: "LLM 推理中"}) resp, err := a.llm.Complete(ctx, &llm.CompletionRequest{ Messages: trimmed, Tools: llmTools, @@ -175,7 +198,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M MaxTokens: a.config.MaxTokens, }) if err != nil { - a.emit(Event{Type: EventError, Message: "LLM 调用失败: " + err.Error()}) + a.emit(Event{Type: EventError, Phase: PhaseError, Message: "LLM 调用失败: " + err.Error()}) return nil, fmt.Errorf("LLM调用失败: %w", err) } @@ -208,7 +231,8 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M answer = "任务已完成。" } } - a.emit(Event{Type: EventDone, Answer: answer, Tokens: a.totalTokens}) + a.emit(Event{Type: EventStatus, Phase: PhaseSummarizing, Round: round, Summary: "汇总最终答案"}) + a.emit(Event{Type: EventDone, Phase: PhaseDone, Answer: answer, Tokens: a.totalTokens}) a.triggerEvolve(ctx, query, serverInfo, answer, toolRecords, true, start) return &RunResult{ Success: true, Output: answer, @@ -218,7 +242,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 中间轮思考(后续还有 tool_calls,与终局 done 分开,避免重复输出) // Logan.Li if thought := strings.TrimSpace(resp.Message.Content); thought != "" { - a.emit(Event{Type: EventThought, Round: round, Thought: thought}) + a.emit(Event{Type: EventThought, Phase: PhasePlanning, Round: round, Thought: thought}) } // 处理工具调用 @@ -231,7 +255,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M // 达到最大轮次 msg := fmt.Sprintf("达到最大迭代次数(%d),任务未能完成。可能任务过于复杂或需要更多步骤。", a.config.MaxIterations) - a.emit(Event{Type: EventError, Message: msg, Tokens: a.totalTokens}) + a.emit(Event{Type: EventError, Phase: PhaseError, Message: msg, Tokens: a.totalTokens}) a.triggerEvolve(ctx, query, serverInfo, "", toolRecords, false, start) return &RunResult{ Success: false, History: a.extractHistory(messages), TotalTokens: a.totalTokens, @@ -270,11 +294,12 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist } risk := a.toolRisk(tool, args) - a.emit(Event{Type: EventToolCall, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display()}) + a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "安全检查"}) // 安全检查(高危触发 confirmFn,由 controller 异步确认) approved, err := a.safetyCtl.Check(tool, args) if err != nil { + a.emit(Event{Type: EventStatus, Phase: PhaseError, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Message: err.Error()}) obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: "安全检查失败: " + err.Error()}) continue @@ -282,12 +307,14 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist if !approved { obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: "操作已被用户拒绝执行"}) + a.emit(Event{Type: EventStatus, Phase: PhaseBlocked, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "用户拒绝执行"}) a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, - Output: "(用户拒绝执行)", Success: false}) + Phase: PhaseObserving, Output: "(用户拒绝执行)", Success: false, OutputSize: len("(用户拒绝执行)")}) continue } // 执行 + a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "工具执行中"}) toolStart := time.Now() toolCtx, cancel := context.WithTimeout(ctx, a.config.ToolTimeout) result, execErr := tool.Execute(toolCtx, args) @@ -317,8 +344,8 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist fmt.Sprintf("\n[输出已截断,原始 %d 字符]", len(output)) } - a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, - Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String()}) + a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, + Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String(), OutputSize: len(output)}) records = append(records, evolver.ToolCallRecord{ ToolName: tc.Function.Name, Args: args, Output: output, diff --git a/frontend/src/components/aiops/AgentCLI.tsx b/frontend/src/components/aiops/AgentCLI.tsx index d1daeec..c90bf27 100644 --- a/frontend/src/components/aiops/AgentCLI.tsx +++ b/frontend/src/components/aiops/AgentCLI.tsx @@ -27,6 +27,8 @@ import { PageShell } from '@/components/layout/PageShell'; // WS 事件类型(后端 wsEvent 对应) // Logan.Li interface WsEvent { type: string; + phase?: string; + summary?: string; round?: number; session_id?: number; thought?: string; @@ -36,6 +38,7 @@ interface WsEvent { output?: string; success?: boolean; duration?: string; + output_size?: number; tokens?: number; answer?: string; message?: string; @@ -99,6 +102,27 @@ function formatServerLabel(s: { hostname: string; ip: string }) { return `${s.hostname}(${s.ip})`; } +function formatAgentPhase(phase: string) { + const map: Record = { + idle: '空闲', + accepted: '已接收', + context: '加载上下文', + thinking: '思考中', + planning: '规划中', + safety_check: '安全检查', + approval_required: '等待审批', + tool_running: '执行工具', + observing: '观察结果', + validating: '验证中', + summarizing: '总结中', + done: '已完成', + blocked: '已阻断', + error: '失败', + aborted: '已中断', + }; + return map[phase] || phase || '运行中'; +} + export default function AgentCLI() { const confirm = useConfirm(); @@ -116,6 +140,7 @@ export default function AgentCLI() { const [items, setItems] = useState([]); const [input, setInput] = useState(''); const [running, setRunning] = useState(false); + const [phase, setPhase] = useState('idle'); const [round, setRound] = useState(0); const [tokens, setTokens] = useState(0); const [connected, setConnected] = useState(false); @@ -209,7 +234,7 @@ export default function AgentCLI() { if (cliMode === 'host' && !serverId) return; if (cliMode === 'kubernetes' && (!clusterCode || !namespace)) return; setItems([]); - setRound(0); setTokens(0); setRunning(false); + setRound(0); setTokens(0); setRunning(false); setPhase('idle'); setConnected(false); setViewMode(false); setViewSession(null); @@ -242,6 +267,7 @@ export default function AgentCLI() { window.clearTimeout(connectTimer); setConnected(false); setRunning(false); + setPhase('idle'); if (!everConnected) { push({ kind: 'error', content: cliMode === 'kubernetes' ? '集群连接被拒绝:请检查集群 kubeconfig/token 配置与大模型是否启用。' @@ -310,11 +336,25 @@ export default function AgentCLI() { setCanContinue(true); } break; + case 'status': + if (ev.phase) setPhase(ev.phase); + if (ev.round) setRound(ev.round); + if (ev.tokens) setTokens(ev.tokens); + push({ + kind: 'status', + round: ev.round, + phase: ev.phase, + content: ev.summary || ev.message || ev.phase || '', + toolName: ev.tool_name, + risk: ev.risk, + }); + break; case 'progress': if (ev.round) setRound(ev.round); if (ev.tokens) setTokens(ev.tokens); break; case 'thought_delta': + setPhase('thinking'); setItems((prev) => { const next = [...prev]; const last = next[next.length - 1]; @@ -327,6 +367,7 @@ export default function AgentCLI() { }); break; case 'thought': + setPhase('planning'); setItems((prev) => { const next = [...prev]; const last = next[next.length - 1]; @@ -339,6 +380,7 @@ export default function AgentCLI() { }); break; case 'tool_call': { + setPhase(ev.phase || 'tool_running'); const args = ev.tool_args || {}; push({ kind: 'tool', round: ev.round, toolName: ev.tool_name, @@ -347,13 +389,14 @@ export default function AgentCLI() { break; } case 'tool_result': + setPhase(ev.phase || 'observing'); 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, + duration: ev.duration, outputSize: ev.output_size, pending: false, }; break; } @@ -362,9 +405,11 @@ export default function AgentCLI() { }); break; case 'confirm': + setPhase(ev.phase || 'approval_required'); handleConfirm(ev); break; case 'done': { + setPhase('done'); const text = (ev.answer || ev.message || '').trim(); setItems((prev) => { const norm = (s?: string) => (s || '').trim().replace(/\s+/g, ' '); @@ -394,6 +439,7 @@ export default function AgentCLI() { } case 'error': case 'aborted': + setPhase(type === 'aborted' ? 'aborted' : 'error'); push({ kind: type === 'aborted' ? 'info' : 'error', content: ev.message || (type === 'aborted' ? '任务已中断' : '发生错误') }); setRunning(false); setCanContinue(type !== 'aborted'); @@ -433,6 +479,7 @@ export default function AgentCLI() { wsRef.current.send(JSON.stringify({ type: msgType, query: q, safety_mode: safetyMode })); setInput(''); setRunning(true); + setPhase('accepted'); if (msgType === 'start') setCanContinue(false); }; @@ -451,6 +498,7 @@ export default function AgentCLI() { ...replayItems, ]); setRunning(false); + setPhase('idle'); }; // resumeHistorySession 恢复历史会话上下文,进入可追问状态 // Logan.Li @@ -471,6 +519,7 @@ export default function AgentCLI() { setItems([]); setRound(0); setTokens(0); + setPhase('idle'); }; const selectedServer = servers.find((s) => String(s.id) === serverId); @@ -658,7 +707,7 @@ export default function AgentCLI() { )} {running && ( - 第 {round} 轮 · {tokens}t + {formatAgentPhase(phase)} · 第 {round} 轮 · {tokens}t )} @@ -812,4 +861,4 @@ export default function AgentCLI() { ); -} \ No newline at end of file +} diff --git a/frontend/src/components/aiops/AgentCLIMessages.tsx b/frontend/src/components/aiops/AgentCLIMessages.tsx index bd715fe..e595045 100644 --- a/frontend/src/components/aiops/AgentCLIMessages.tsx +++ b/frontend/src/components/aiops/AgentCLIMessages.tsx @@ -12,7 +12,8 @@ import i18n from '@/i18n'; // 对话流条目模型 // Logan.Li export interface ChatItem { id: number; - kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info'; + kind: 'user' | 'thought' | 'tool' | 'done' | 'error' | 'info' | 'status'; + phase?: string; round?: number; content?: string; toolName?: string; @@ -21,6 +22,7 @@ export interface ChatItem { output?: string; success?: boolean; duration?: string; + outputSize?: number; pending?: boolean; streaming?: boolean; } @@ -51,6 +53,9 @@ export function messagesToChatItems(msgs: AgentCliMessage[]): ChatItem[] { case 'thought': items.push({ id: nextId(), kind: 'thought', round: m.round, content: m.content }); break; + case 'status': + items.push({ id: nextId(), kind: 'status', round: m.round, phase: m.duration || undefined, content: m.content, toolName: m.tool_name, risk: m.risk }); + break; case 'tool_call': { let command = ''; try { @@ -124,12 +129,12 @@ function groupChatItems(items: ChatItem[]): TimelineSegment[] { let i = 0; while (i < items.length) { const it = items[i]; - if (it.round != null && (it.kind === 'thought' || it.kind === 'tool')) { + if (it.round != null && (it.kind === 'thought' || it.kind === 'tool' || it.kind === 'status')) { const r = it.round; const group: ChatItem[] = []; while (i < items.length) { const cur = items[i]; - if (cur.round !== r || (cur.kind !== 'thought' && cur.kind !== 'tool')) break; + if (cur.round !== r || (cur.kind !== 'thought' && cur.kind !== 'tool' && cur.kind !== 'status')) break; group.push(cur); i++; } @@ -234,6 +239,9 @@ export function MessageView({ item, inRoundGroup = false }: { item: ChatItem; in if (item.kind === 'tool') { return ; } + if (item.kind === 'status') { + return ; + } if (item.kind === 'done') { const text = item.content?.trim() || i18n.t('aiops.taskDone'); return ( @@ -257,6 +265,44 @@ export function MessageView({ item, inRoundGroup = false }: { item: ChatItem; in ); } +function phaseLabel(phase?: string) { + const map: Record = { + accepted: '已接收', + context: '上下文', + thinking: '思考中', + planning: '规划', + safety_check: '安全检查', + approval_required: '等待审批', + tool_running: '工具执行中', + observing: '观察结果', + validating: '验证', + summarizing: '总结', + done: '完成', + blocked: '已阻断', + error: '失败', + }; + return map[phase || ''] || phase || '状态'; +} + +function StatusLine({ item }: { item: ChatItem }) { + const danger = item.phase === 'blocked' || item.phase === 'error'; + return ( +
+ {item.phase === 'thinking' || item.phase === 'tool_running' ? ( + + ) : danger ? ( + + ) : ( + + )} + {phaseLabel(item.phase)} + {item.toolName && tool={item.toolName}} + {item.content && {item.content}} + {item.risk && {item.risk}} +
+ ); +} + // ThoughtBlock AI 思考(可折叠,灰底斜体,支持流式) // Logan.Li function ThoughtBlock({ text, round, streaming, hideRoundLabel, @@ -305,7 +351,13 @@ function ToolBlock({ item, inRoundGroup }: { item: ChatItem; inRoundGroup?: bool )} {!item.pending && item.output != null && ( <> - {item.duration &&
{i18n.t('aiops.durationLabel', { duration: item.duration })}
} + {(item.duration || item.outputSize != null) && ( +
+ {item.duration && i18n.t('aiops.durationLabel', { duration: item.duration })} + {item.duration && item.outputSize != null ? ' · ' : ''} + {item.outputSize != null ? `输出 ${formatBytes(item.outputSize)}` : ''} +
+ )} {(item.toolName === 'analyze_output' || looksLikeMarkdown(item.output)) ? (
@@ -319,4 +371,10 @@ function ToolBlock({ item, inRoundGroup }: { item: ChatItem; inRoundGroup?: bool )}
); -} \ No newline at end of file +} + +function formatBytes(n: number) { + if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`; + if (n >= 1024) return `${(n / 1024).toFixed(1)}KB`; + return `${n}B`; +}