This commit is contained in:
2026-08-29 13:32:57 +08:00
commit c56aa6e752
81 changed files with 14319 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
<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>