Why: Process output folded on every text delta, successful tool results disappeared after refresh, and generated files stayed plain text instead of a card that opens a closable preview. Evidence: npx tsc -b --noEmit (clean); npx vitest run (51 passed). Impact: Live work stays expanded until the run ends, then folds to one worked-time line. File cards open a per-conversation preview pane. docx renders page by page, with mammoth if that fails. Refs: feature/chat-timeline-preview Co-authored-by: Cursor <cursoragent@cursor.com>
68 lines
2.8 KiB
TypeScript
68 lines
2.8 KiB
TypeScript
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
|
|
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
|
|
import type { Part } from './contracts'
|
|
|
|
export interface TimelineSplit {
|
|
timeline: Part[]
|
|
final: Part[]
|
|
}
|
|
|
|
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。 */
|
|
export function splitTimeline(parts: Part[]): 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 }
|
|
return { timeline: parts.slice(0, last + 1), final: parts.slice(last + 1) }
|
|
}
|
|
|
|
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'
|
|
}
|