Files
ManuAgent/web-ui/src/components/AgentTimeline.vue
2026-08-29 13:32:57 +08:00

372 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, ref, watch } from 'vue'
import { ElImageViewer } from 'element-plus'
import { CircleCheck, Document, MagicStick } from '@element-plus/icons-vue'
import { Bot, Brain, Images, Wrench } from '@lucide/vue'
import type { AgentEvent } from '../api'
const MarkdownRender = defineAsyncComponent(() => import('markstream-vue'))
const props = defineProps<{ events: AgentEvent[]; running: boolean; projectId: string }>()
const visibleLimit = ref(180)
const previewIndex = ref<number | null>(null)
const showFinalizing = ref(false)
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' })
let finalizingTimer: number | undefined
type ActivityStatus = 'running' | 'done' | 'failed' | 'stopped'
type ViewImage = { index: number; path: string; sourcePath: string; label: string }
type FlowItem =
| { key: string; runId: string | null; kind: 'message'; text: string; time: string; showIdentity: boolean; final: boolean }
| { key: string; runId: string | null; kind: 'reasoning'; text: string; status: ActivityStatus; time: string }
| { key: string; runId: string | null; kind: 'tool'; name: string; args: string; detail: string; images: ViewImage[]; status: ActivityStatus; time: string }
| { key: string; kind: 'notice'; text: string; tone: 'success' | 'error' | 'info'; time: string }
const flow = computed<FlowItem[]>(() => {
const items: FlowItem[] = []
const messages = new Map<string, FlowItem & { kind: 'message' }>()
const reasoning = new Map<string, FlowItem & { kind: 'reasoning' }>()
const tools = new Map<string, FlowItem & { kind: 'tool' }>()
const asks = new Map<string | null, Record<string, any>>()
const terminalRuns = new Map<string | null, Exclude<ActivityStatus, 'running'>>()
for (const event of props.events) {
const payload = event.payload || {}
const eventKey = (id: unknown) => `${event.runId || 'none'}:${String(id)}`
if (event.type === 'TEXT_MESSAGE_START') {
const id = eventKey(payload.messageId || event.id)
const item: FlowItem & { kind: 'message' } = {
key: `m-${id}`, runId: event.runId, kind: 'message', text: '', time: formatTime(event.createdAt),
showIdentity: false, final: false
}
messages.set(id, item)
items.push(item)
} else if (event.type === 'TEXT_MESSAGE_CONTENT' || event.type === 'TEXT_MESSAGE_CHUNK') {
const id = eventKey(payload.messageId || 'current')
let item = messages.get(id)
if (!item) {
item = {
key: `m-${id}`, runId: event.runId, kind: 'message', text: '',
time: formatTime(event.createdAt), showIdentity: false, final: false
}
messages.set(id, item)
items.push(item)
}
item.text += String(payload.delta || payload.content || '')
} else if (event.type === 'TEXT_MESSAGE_END') {
const item = messages.get(eventKey(payload.messageId || 'current'))
if (item) item.final = true
} else if (event.type === 'REASONING_MESSAGE_START' || event.type === 'REASONING_START') {
const id = eventKey(payload.messageId || payload.reasoningId || event.id)
const item: FlowItem & { kind: 'reasoning' } = {
key: `r-${id}`, runId: event.runId, kind: 'reasoning', text: '', status: 'running', time: formatTime(event.createdAt)
}
reasoning.set(id, item)
items.push(item)
} else if (event.type === 'REASONING_MESSAGE_CONTENT' || event.type === 'REASONING_MESSAGE_CHUNK') {
const id = eventKey(payload.messageId || payload.reasoningId || event.id)
let item = reasoning.get(id)
if (!item) {
item = {
key: `r-${id}`, runId: event.runId, kind: 'reasoning', text: '', status: 'running', time: formatTime(event.createdAt)
}
reasoning.set(id, item)
items.push(item)
}
item.text += String(payload.delta || payload.content || '')
} else if (event.type === 'REASONING_MESSAGE_END' || event.type === 'REASONING_END') {
const item = reasoning.get(eventKey(payload.messageId || payload.reasoningId || event.id))
if (item) item.status = 'done'
} else if (event.type === 'TOOL_CALL_START') {
const id = eventKey(payload.toolCallId || event.id)
const item: FlowItem & { kind: 'tool' } = {
key: `t-${id}`, runId: event.runId, kind: 'tool',
name: String(payload.toolCallName || payload.name || '工具'), args: '', detail: '', images: [],
status: 'running', time: formatTime(event.createdAt)
}
tools.set(id, item)
items.push(item)
} else if (event.type === 'TOOL_CALL_ARGS' || event.type === 'TOOL_CALL_CHUNK') {
const item = tools.get(eventKey(payload.toolCallId || payload.id || ''))
if (item) item.args += String(payload.delta || payload.args || '')
} else if (event.type === 'TOOL_CALL_RESULT' || event.type === 'TOOL_CALL_END') {
const item = tools.get(eventKey(payload.toolCallId || payload.id || ''))
if (item) {
const result = String(payload.content || payload.result || '')
item.status = /(?:执行失败|(?:^|\n)"?(?:error:|failed\b|exit code:\s*[1-9]))/i.test(result.trim()) ? 'failed' : 'done'
if (result) {
item.detail = summarize(result, 360)
if (item.name === 'document_view') item.images = parseViewImages(result)
}
}
} else if (event.type === 'ASK_REQUESTED') {
asks.set(event.runId, payload)
} else if (event.type === 'ASK_RESPONDED') {
const ask = asks.get(event.runId)
items.push({
key: `ask-${event.id}`, kind: 'notice', text: askSummary(ask, payload),
tone: 'success', time: formatTime(event.createdAt)
})
} else if (event.type === 'RUN_ERROR') {
terminalRuns.set(event.runId, 'failed')
closeActive(event.runId, 'failed', reasoning, tools)
items.push({
key: `e-${event.id}`, kind: 'notice', text: '执行遇到问题,已保留现有进度',
tone: 'error', time: formatTime(event.createdAt)
})
} else if (event.type === 'MODEL_RETRY') {
items.push({
key: `retry-${event.id}`, kind: 'notice',
text: `模型连接中断,正在重连(${payload.attempt}/${payload.maxAttempts || 5}`,
tone: 'info', time: formatTime(event.createdAt)
})
} else if (event.type === 'RUN_FINISHED' && payload.outcome === 'CANCELLED') {
terminalRuns.set(event.runId, 'stopped')
closeActive(event.runId, 'stopped', reasoning, tools)
items.push({
key: `cancel-${event.id}`, kind: 'notice', text: 'Agent 已停止,当前上下文已保留',
tone: 'info', time: formatTime(event.createdAt)
})
} else if (event.type === 'ARTIFACT_PUBLISHED') {
items.push({
key: `a-${event.id}`, kind: 'notice', text: '申报书审阅稿已生成',
tone: 'success', time: formatTime(event.createdAt)
})
} else if (event.type === 'RUN_FINISHED') {
terminalRuns.set(event.runId, 'done')
closeActive(event.runId, 'done', reasoning, tools)
}
}
for (const [runId, status] of terminalRuns) closeActive(runId, status, reasoning, tools)
for (const item of messages.values()) if (terminalRuns.has(item.runId)) item.final = true
const filtered = items.filter(item => (item.kind !== 'message' && item.kind !== 'reasoning') || cleanText(item.text))
const identifiedRuns = new Set<string | null>()
for (const item of filtered) {
if (item.kind === 'message' && !identifiedRuns.has(item.runId)) {
item.showIdentity = true
identifiedRuns.add(item.runId)
}
}
return filtered
})
const visibleFlow = computed(() => flow.value.slice(-visibleLimit.value))
const hiddenCount = computed(() => Math.max(0, flow.value.length - visibleFlow.value.length))
const galleryImages = computed(() => flow.value.flatMap(item => item.kind === 'tool'
? item.images.map(image => ({ key: `${item.key}:${image.path}`, image, url: previewUrl(image.path) }))
: []))
const galleryUrls = computed(() => galleryImages.value.map(item => item.url))
watch(
() => [props.running, props.events.at(-1)?.id, props.events.at(-1)?.type] as const,
([running, , type]) => {
window.clearTimeout(finalizingTimer)
showFinalizing.value = false
if (running && type === 'TEXT_MESSAGE_END') {
finalizingTimer = window.setTimeout(() => { showFinalizing.value = true }, 500)
}
},
{ immediate: true }
)
onBeforeUnmount(() => window.clearTimeout(finalizingTimer))
function closeActive(
runId: string | null,
status: Exclude<ActivityStatus, 'running'>,
reasoning: Map<string, FlowItem & { kind: 'reasoning' }>,
tools: Map<string, FlowItem & { kind: 'tool' }>
) {
for (const item of reasoning.values()) if (item.runId === runId && item.status === 'running') item.status = status
for (const item of tools.values()) if (item.runId === runId && item.status === 'running') item.status = status
}
function formatTime(value: string) {
return timeFormatter.format(new Date(value))
}
function askSummary(ask: Record<string, any> | undefined, response: Record<string, any>) {
if (ask?.kind !== 'material_check') return '已确认建设规划'
const decisions = Array.isArray(response.decisions) ? response.decisions : []
const assumptions = decisions.filter((item: any) => item?.action === 'ASSUMPTION').length
const pending = decisions.filter((item: any) => item?.action === 'PENDING_COMMENT').length
const uploaded = decisions.filter((item: any) => item?.action === 'UPLOADED').length
const parts = ['已确认材料检验']
if (assumptions) parts.push(`${assumptions} 项按规划假设继续`)
if (pending) parts.push(`${pending} 项待确认`)
if (uploaded) parts.push(`${uploaded} 项已补充`)
return parts.join(' · ')
}
function summarize(value: unknown, maxLength = 160) {
const text = cleanText(typeof value === 'string' ? value : JSON.stringify(value))
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text
}
function cleanText(value: string) {
return redactText(value)
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/\*\*|__|`{1,3}/g, '')
.replace(/^\s*\|?\s*:?-{3,}.*$/gm, '')
.trim()
}
function redactText(value: string) {
return value
.replace(/\/Users\/[^\s"')]+/g, '工作区路径')
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, '内部任务')
.replace(/sk-[A-Za-z0-9_-]{8,}/g, '已隐藏凭证')
}
function parseArgs(item: FlowItem & { kind: 'tool' }) {
try { return JSON.parse(item.args) as Record<string, any> } catch { return {} }
}
function skillName(item: FlowItem & { kind: 'tool' }) {
return String(parseArgs(item).skillId || '').replace(/_(?:builtin|imported)$/, '') || 'Skill'
}
function baseToolName(item: FlowItem & { kind: 'tool' }) {
const labels: Record<string, string> = {
list_files: '查看文件', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件',
glob_files: '查找文件', grep_files: '检索文件', execute: '工作区',
memory_search: '项目记忆检索', memory_save: '项目记忆保存'
}
return labels[item.name] || item.name
}
function activityLabel(item: FlowItem & { kind: 'tool' }) {
if (item.name === 'document_view') {
const count = item.images.length || requestedViewCount(item)
if (item.status === 'running') return '正在查看图片'
if (item.status === 'stopped') return '已停止查看图片'
if (item.status === 'failed') return '查看图片失败'
return `已查看图片${count ? ` · ${count}` : ''}`
}
if (item.name === 'read_file') return fileActivityLabel(item, '读取')
if (item.name === 'write_file' || item.name === 'edit_file') return fileActivityLabel(item, '编辑')
const skill = isSkill(item)
const name = skill ? skillName(item) : baseToolName(item)
if (item.status === 'running') return skill ? `正在调用 ${name} Skill` : `正在调用${name}工具`
if (item.status === 'stopped') return skill ? `已停止调用 ${name} Skill` : `已停止调用${name}工具`
if (item.status === 'failed') return skill ? `${name} Skill 调用失败` : `${name}工具调用失败`
return skill ? `已调用 ${name} Skill` : `已调用${name}工具`
}
function fileActivityLabel(item: FlowItem & { kind: 'tool' }, action: '读取' | '编辑') {
const path = String(parseArgs(item).path || '').replace(/\\/g, '/')
const name = path.split('/').filter(Boolean).pop()
const target = name ? `${name} 文件` : '文件'
if (item.status === 'running') return `正在${action} ${target}`
if (item.status === 'stopped') return `已停止${action} ${target}`
if (item.status === 'failed') return `${action} ${target}失败`
return `${action} ${target}`
}
function isSkill(item: FlowItem & { kind: 'tool' }) {
return item.name === 'load_skill_through_path' || item.name.toLowerCase().includes('skill')
}
function requestedViewCount(item: FlowItem & { kind: 'tool' }) {
const views = parseArgs(item).views
return Array.isArray(views) ? views.length : 0
}
function parseViewImages(content: string): ViewImage[] {
const marker = 'document_view_result='
const start = content.indexOf(marker)
if (start < 0) return []
try {
const line = content.slice(start + marker.length).split('\n', 1)[0]
const images = JSON.parse(line).images
return Array.isArray(images) ? images : []
} catch { return [] }
}
function previewUrl(path: string) {
return `/api/projects/${props.projectId}/view-images?path=${encodeURIComponent(path)}`
}
function openPreview(itemKey: string, path: string) {
const index = galleryImages.value.findIndex(item => item.key === `${itemKey}:${path}`)
if (index >= 0) previewIndex.value = index
}
function reasoningLabel(item: FlowItem & { kind: 'reasoning' }) {
if (item.status === 'running') return '正在思考'
if (item.status === 'stopped') return '已停止思考'
if (item.status === 'failed') return '思考中断'
return '已思考'
}
</script>
<template>
<div class="timeline">
<span v-if="running" class="sr-only" aria-live="polite">Agent 正在执行</span>
<button v-if="hiddenCount" class="load-earlier" @click="visibleLimit += 180">查看更早记录{{ hiddenCount }}</button>
<article v-for="item in visibleFlow" :key="item.key" class="flow-row" :class="`flow-${item.kind}`">
<div v-if="item.kind === 'message' && item.showIdentity" class="agent-avatar" aria-label="Agent"><Bot /></div>
<div class="flow-content">
<div v-if="item.kind === 'message' && item.showIdentity" class="flow-meta"><strong>Agent</strong><time>{{ item.time }}</time></div>
<MarkdownRender
v-if="item.kind === 'message'"
class="agent-markdown"
mode="chat"
:content="redactText(item.text).trim()"
:final="item.final"
smooth-streaming="auto"
:fade="false"
html-policy="escape"
/>
<details v-else-if="item.kind === 'reasoning'" class="activity-row reasoning-row" :class="{ active: item.status === 'running', failed: item.status === 'failed' }">
<summary><Brain /><span>{{ reasoningLabel(item) }}</span></summary>
<p>{{ cleanText(item.text) }}</p>
</details>
<details
v-else-if="item.kind === 'tool'"
class="activity-row tool-row"
:class="{ active: item.status === 'running', failed: item.status === 'failed', visual: item.name === 'document_view' }"
>
<summary>
<Images v-if="item.name === 'document_view'" />
<MagicStick v-else-if="isSkill(item)" />
<Wrench v-else />
<span>{{ activityLabel(item) }}</span>
</summary>
<div v-if="item.images.length" class="view-image-strip">
<button
v-for="image in item.images"
:key="image.path"
type="button"
class="view-image-item"
:aria-label="`查看渲染图片 ${image.index}`"
@click="openPreview(item.key, image.path)"
>
<img :src="previewUrl(image.path)" :alt="`渲染图片 ${image.index}`" loading="lazy" />
<span>渲染图片 {{ image.index }} · {{ image.label }}</span>
</button>
</div>
<p v-else-if="item.detail">{{ item.detail }}</p>
</details>
<div v-else class="notice-row" :class="item.tone">
<CircleCheck v-if="item.tone === 'success'" /><Document v-else />
<span>{{ item.text }}</span>
</div>
</div>
</article>
<div v-if="showFinalizing" class="finalizing-row" aria-live="polite">
<span>正在整理结果</span>
</div>
<ElImageViewer
v-if="previewIndex !== null && galleryUrls.length"
:url-list="galleryUrls"
:initial-index="previewIndex"
:infinite="true"
:show-progress="true"
:close-on-press-escape="true"
:teleported="true"
@close="previewIndex = null"
/>
</div>
</template>