Files
Agent-System-Frontend/src/messageparts.ts
T
weichenandCursor eca1665d91 Show tool progress and keep the final reply visible
Why: Streaming tool arguments looked like a frozen cursor, and a failed run folded the only narration into the collapsed process block.
Evidence: npx vitest run in modules/frontend, 13 files, 66 passed. npx tsc -b reports only a pre-existing missing docx-preview module.
Impact: Live tools move composing to running to done with arguments and output; finished runs lift the last narration; same-run errors and duplicate files collapse into the reply.
Refs: feature/stream-display-overhaul
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 02:26:54 +00:00

146 lines
6.3 KiB
TypeScript

// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
import type { Part } from './contracts'
export interface TimelineSplit {
timeline: Part[]
final: Part[]
}
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
const timeline = parts.slice(0, last + 1)
const final = parts.slice(last + 1)
if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final }
let textAt = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break }
}
if (textAt < 0) return { timeline, final }
const lifted = { ...timeline[textAt], process: undefined }
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
const order: string[] = []
const byName = new Map<string, { artifact: NonNullable<Part['artifact']>; count: number }>()
for (const p of parts) {
if (p.type !== 'artifact_ref' || !p.artifact) continue
const name = p.artifact.name || p.artifact.artifactId
const cur = byName.get(name)
if (!cur) {
order.push(name)
byName.set(name, { artifact: p.artifact, count: 1 })
} else {
cur.artifact = p.artifact
cur.count++
}
}
return order.map(name => {
const cur = byName.get(name)!
return { artifact: cur.artifact, updates: cur.count - 1 }
})
}
/** 把单独成条的错误消息并回同一 run 的助手消息。没有正文的错误条保留原位。 */
export function foldRunErrors<T extends { id: string; runId?: string; role: string; parts: Part[] }>(messages: T[]): T[] {
const extras = new Map<string, Part[]>()
for (const m of messages) {
if (m.role !== 'assistant' || !m.runId) continue
if (m.parts.length > 0 && m.parts.every(p => p.type === 'error')) {
extras.set(m.runId, [...(extras.get(m.runId) ?? []), ...m.parts])
}
}
const used = new Set<string>()
const out: T[] = []
for (const m of messages) {
if (m.role === 'assistant' && m.runId && extras.has(m.runId) && m.parts.every(p => p.type === 'error')) {
const hasBody = messages.some(o => o !== m && o.runId === m.runId && o.role === 'assistant' && o.parts.some(p => p.type !== 'error'))
if (hasBody) continue
}
if (m.role === 'assistant' && m.runId && extras.has(m.runId) && m.parts.some(p => p.type !== 'error') && !used.has(m.runId)) {
used.add(m.runId)
out.push({ ...m, parts: [...m.parts, ...extras.get(m.runId)!] })
continue
}
out.push(m)
}
return out
}
/** 把运行错误说成人话。detail 保留原始码,供展开查看。 */
export function humanError(code?: string, message?: string): { title: string; detail: string } {
const msg = (message ?? '').trim()
const detail = [code, msg].filter(Boolean).join(': ')
if (code === 'run.cancelled' || msg === '已取消') return { title: '已取消', detail }
const stripped = msg.replace(/^budget:\s*/, '')
if (msg.startsWith('budget:') || /max turns|maxTurns|tool calls/i.test(msg)) {
return { title: `已停止:${stripped}`, detail }
}
return { title: msg ? `已停止:${msg}` : '已停止', detail }
}
/** 终态摘要:已工作时长,以及工具次数和文件数。 */
export function processSummary(toolCalls: number, files: number, createdAt?: string, endedAt?: string): string {
const bits = [workedLabel(createdAt, endedAt)]
if (toolCalls > 0) bits.push(`${toolCalls} 次工具调用`)
if (files > 0) bits.push(`${files} 个文件`)
return bits.join(' · ')
}
const ART_ID = /art_[0-9a-f]{12}/g
/** 正文里出现的工件 ID,去重且保持出现顺序。 */
export function artifactIdsIn(text: string): string[] {
return [...new Set(text.match(ART_ID) ?? [])]
}
/**
* 把正文里项目中不存在的工件 ID 换成弱化标记。存在的 ID 原样保留,由调用方补卡片。
* ID 只有十六进制,替换片段不含用户可控的 HTML。
*/
export function markMissingArtifacts(text: string, exists: (id: string) => boolean): string {
return text.replace(/`?(art_[0-9a-f]{12})`?/g, (raw, id: string) => (
exists(id) ? raw : '<span class="missing-art">未找到该文件</span>'
))
}
/** 毫秒转「X 分 Y 秒」;不足 1 秒按 1 秒,无效值返回空串。 */
export function formatDuration(ms: number | undefined): string {
if (ms == null || !Number.isFinite(ms) || ms <= 0) return ''
const s = Math.max(1, Math.round(ms / 1000))
const m = Math.floor(s / 60)
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
}
/** run 起止时间 → 「已工作 X」。缺时间时只给「已工作」。 */
export function workedLabel(createdAt?: string, endedAt?: string): string {
if (!createdAt || !endedAt) return '已工作'
const ms = new Date(endedAt).getTime() - new Date(createdAt).getTime()
const text = formatDuration(ms)
return text ? `已工作 ${text}` : '已工作'
}
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
export function humanSize(n: number | undefined): string {
if (!n || n <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
const s = v >= 100 || i === 0 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, '')
return `${s} ${units[i]}`
}
/** 工件文件图标名(@ui Icon 名):按扩展名优先,回落 kind。 */
export function artifactIcon(name: string, kind?: string): 'file' | 'image' | 'package' {
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name) || kind === 'image') return 'image'
if (/\.(zip|tar|gz|7z|rar)$/i.test(name)) return 'package'
return 'file'
}