372 lines
17 KiB
Vue
372 lines
17 KiB
Vue
<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>
|