feat: add multi-window real-time agent execution display

Backend: execute_stream() now uses AgentRuntime.run_stream() per phase,
forwarding agent internal events (think/tool_call/tool_result/final) as
`agent_event` SSE type with phase metadata via asyncio.Queue for DAG
parallel batches.

Frontend: new agent-panels-grid with per-phase cards showing live
activity log, tool calls, iteration count, and collapsible full output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-06-18 22:06:16 +08:00
parent e0efa7e9b1
commit e0bcfe582b
2 changed files with 502 additions and 65 deletions

View File

@@ -159,7 +159,68 @@
<div v-if="executing || executingStream || executeResult || streamEvents.length" class="result-area">
<div class="result-header">
<h3>执行结果</h3>
<el-button v-if="executeResult || streamEvents.length" size="small" @click="executeResult = null; streamEvents = []; streamDeliverable = ''; streamProjectPath = ''; streamProjectFiles = []">清空</el-button>
<el-button v-if="executeResult || streamEvents.length || Object.keys(agentPanels).length" size="small" @click="clearResults">清空</el-button>
</div>
<!-- Agent 多窗口实时面板 -->
<div v-if="Object.keys(agentPanels).length" class="agent-panels-grid">
<div
v-for="panel in agentPanelsList"
:key="panel.phase"
class="agent-panel-card"
:class="{ 'panel-running': panel.status === 'running', 'panel-done': panel.status === 'done', 'panel-error': panel.status === 'error' }"
>
<div class="panel-header">
<div class="panel-title">
<el-tag :type="panel.status === 'done' ? 'success' : panel.status === 'error' ? 'danger' : 'warning'" size="small" effect="dark">
阶段{{ panel.phase }}
</el-tag>
<span class="panel-name">{{ panel.name }}</span>
</div>
<div class="panel-meta">
<span class="panel-role">{{ panel.role }}</span>
<span class="panel-agent">{{ panel.agent }}</span>
<el-icon v-if="panel.status === 'running'" class="is-loading" :size="14"><Loading /></el-icon>
<el-tag v-else-if="panel.status === 'done'" type="success" size="small" effect="plain">
{{ panel.iterations }}次迭代 · {{ panel.toolCalls }}次工具
</el-tag>
<el-tag v-else-if="panel.status === 'error'" type="danger" size="small" effect="plain">失败</el-tag>
</div>
</div>
<div class="panel-activity-log" ref="panelLogRefs">
<div v-for="(item, idx) in panel.activityLog" :key="idx" class="activity-item" :class="'act-' + item.eventType">
<template v-if="item.eventType === 'think'">
<span class="act-icon">💭</span>
<span class="act-text">{{ item.content || '思考中…' }}</span>
</template>
<template v-else-if="item.eventType === 'tool_call'">
<span class="act-icon">🔧</span>
<span class="act-tool">{{ item.toolName }}</span>
<span v-if="item.toolInput" class="act-input">{{ truncateJson(item.toolInput) }}</span>
</template>
<template v-else-if="item.eventType === 'tool_result'">
<span class="act-icon"></span>
<span class="act-tool">{{ item.toolName }}</span>
<span v-if="item.duration" class="act-duration">{{ item.duration }}ms</span>
</template>
<template v-else-if="item.eventType === 'final'">
<span class="act-icon">📤</span>
<span class="act-text">输出: {{ truncateText(item.content, 100) }}</span>
</template>
<template v-else-if="item.eventType === 'error'">
<span class="act-icon"></span>
<span class="act-text error-text">{{ item.content }}</span>
</template>
</div>
</div>
<div v-if="panel.output && panel.status === 'done'" class="panel-output">
<el-collapse>
<el-collapse-item title="查看完整输出">
<div class="output-content" v-html="renderMarkdown(panel.output)" />
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
<!-- 流式事件 -->
@@ -366,6 +427,66 @@ const streamProjectPath = ref<string>('')
const streamProjectFiles = ref<string[]>([])
const autoApproveFiles = ref(true)
// Agent 多窗口面板
interface ActivityItem {
eventType: string
toolName?: string
toolInput?: string
content?: string
duration?: number
}
interface AgentPanel {
phase: number
name: string
role: string
agent: string
status: 'running' | 'done' | 'error'
activityLog: ActivityItem[]
output: string
iterations: number
toolCalls: number
error: string | null
}
const agentPanels = ref<Record<number, AgentPanel>>({})
const agentPanelsList = computed(() => Object.values(agentPanels.value).sort((a, b) => a.phase - b.phase))
function getOrCreatePanel(phase: number, name: string, role: string, agent: string): AgentPanel {
if (!agentPanels.value[phase]) {
agentPanels.value[phase] = {
phase, name, role, agent,
status: 'running',
activityLog: [],
output: '',
iterations: 0,
toolCalls: 0,
error: null,
}
}
return agentPanels.value[phase]
}
function truncateJson(input: string | undefined): string {
if (!input) return ''
const s = typeof input === 'string' ? input : JSON.stringify(input)
return s.length > 60 ? s.slice(0, 60) + '…' : s
}
function truncateText(text: string | undefined, max: number): string {
if (!text) return ''
return text.length > max ? text.slice(0, max) + '…' : text
}
function clearResults() {
executeResult.value = null
streamEvents.value = []
streamDeliverable.value = ''
streamProjectPath.value = ''
streamProjectFiles.value = []
agentPanels.value = {}
}
const hasAnyRole = computed(() => Object.values(roleSlots.value).some(v => !!v))
// 过滤 Agent
@@ -774,6 +895,49 @@ async function handleExecute(mode: 'sync' | 'stream') {
if (line.startsWith('data: ')) {
try {
const evt = JSON.parse(line.slice(6))
// 路由 agent_event 到对应面板
if (evt.type === 'agent_event' && evt.phase != null) {
const panel = getOrCreatePanel(evt.phase, evt.name || '', evt.role || '', evt.agent || '')
panel.status = 'running'
const data = evt.data || {}
const eventType = data.type || 'think'
panel.activityLog.push({
eventType,
toolName: data.tool_name || data.tool || '',
toolInput: data.tool_input || data.input || '',
content: data.content || '',
duration: data.duration_ms || data.duration || 0,
})
if (eventType === 'tool_result') panel.toolCalls++
if (data.iteration) panel.iterations = data.iteration
if (eventType === 'final' && data.content) {
panel.output = (panel.output ? panel.output + '\n\n' : '') + data.content
}
// 不添加到 streamEvents 中(避免在旧布局重复显示)
continue
}
// phase_start / phase_done 更新面板状态
if (evt.type === 'phase_start' && evt.phase != null) {
const panel = getOrCreatePanel(evt.phase, evt.name || '', evt.role || '', evt.agent || '')
panel.status = 'running'
}
if (evt.type === 'phase_done' && evt.phase != null) {
const panel = getOrCreatePanel(evt.phase, evt.name || '', evt.role || '', evt.agent || '')
panel.status = evt.success ? 'done' : 'error'
panel.output = evt.output || panel.output
panel.iterations = evt.iterations || panel.iterations
panel.toolCalls = evt.tool_calls || panel.toolCalls
panel.error = evt.error || null
}
// parallel_batch_start: 预创建面板
if (evt.type === 'parallel_batch_start' && evt.phases) {
for (const ph of evt.phases) {
getOrCreatePanel(ph.phase, ph.name || '', ph.role || '', ph.agent || '')
}
}
streamEvents.value.push(evt)
if (evt.type === 'final') {
if (evt.deliverable) streamDeliverable.value = evt.deliverable
@@ -1096,4 +1260,147 @@ onMounted(() => {
font-size: 13px;
color: #409eff;
}
/* Agent 多窗口面板网格 */
.agent-panels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.agent-panel-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition: box-shadow 0.3s;
display: flex;
flex-direction: column;
max-height: 480px;
}
.agent-panel-card.panel-running {
border-left: 3px solid #e6a23c;
box-shadow: 0 0 8px rgba(230, 162, 60, 0.15);
}
.agent-panel-card.panel-done {
border-left: 3px solid #67c23a;
}
.agent-panel-card.panel-error {
border-left: 3px solid #f56c6c;
}
.panel-header {
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 6px;
}
.panel-name {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.panel-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #909399;
}
.panel-role {
background: #f0f2f5;
padding: 1px 6px;
border-radius: 3px;
}
.panel-agent {
color: #409eff;
font-weight: 500;
}
.panel-activity-log {
flex: 1;
overflow-y: auto;
padding: 8px 12px;
font-size: 12px;
line-height: 1.6;
background: #fafcff;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 3px 0;
border-bottom: 1px solid #f5f5f5;
}
.activity-item:last-child {
border-bottom: none;
}
.act-icon {
flex-shrink: 0;
font-size: 12px;
width: 18px;
text-align: center;
}
.act-text {
color: #606266;
flex: 1;
}
.act-text.error-text {
color: #f56c6c;
}
.act-tool {
color: #409eff;
font-weight: 500;
font-family: monospace;
}
.act-input {
color: #909399;
font-size: 11px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.act-duration {
color: #67c23a;
font-size: 11px;
margin-left: auto;
}
.panel-output {
border-top: 1px solid #ebeef5;
padding: 4px 12px;
flex-shrink: 0;
}
.panel-output :deep(.el-collapse-item__header) {
font-size: 12px;
height: 32px;
line-height: 32px;
}
</style>