Why: AssistantMessage reordered a message's parts by the process flag, so tool calls floated above the text that surrounded them instead of appearing where they actually ran; live streams had the same defect via a fixed template order and never showed artifacts mid-run. Generated files (e.g. meeting-minutes docx) had no visible download path, the artifacts panel was a bare list, DocViewer nested a modal inside the overlay, and settings rows used a legacy grid with a floating save button. Evidence: messageparts.ts adds groupParts (order-preserving grouping of consecutive process parts, unit-tested) plus humanSize/artifactIcon helpers; Conversation renders blocks in original order and the artifact chip becomes icon + open + download; LiveState.parts accumulates events in arrival order including artifact_ref so live view matches the persisted message; ArtifactsPanel gains search/kind filter/upload and per-row preview/download/delete-confirm; DocViewer is a single-layer OverlayHost with pdf iframe, csv/json monospace, and a binary info card with a download CTA; SettingsModal migrates to @ui SettingRow with the save button pinned in the shell actions bar; inbox cards receive the docBridge so result cards can open artifacts. tsc clean, vitest 20 passed, production build succeeds. Impact: message timelines read in true execution order during and after runs; every artifact is previewable and downloadable from the message chip, the workspace panel, and the viewer; settings render on the fixed two-column shell. Refs: task feature/chat-order-artifact-files (ui# c3e1f3b backend# 44b51fa).
66 lines
3.2 KiB
TypeScript
66 lines
3.2 KiB
TypeScript
// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
|
|
import { describe, it, expect } from 'vitest'
|
|
import { groupParts, humanSize, artifactIcon } from '../src/messageparts'
|
|
import type { Part } from '../src/contracts'
|
|
|
|
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
|
return partial as Part
|
|
}
|
|
|
|
describe('groupParts(顺序保真)', () => {
|
|
it('interleaves tool blocks at their original positions', () => {
|
|
// 后端 llm.go 的真实交错序:text → tool → tool_result → artifact_ref → text
|
|
const parts: Part[] = [
|
|
p({ type: 'text', text: '先找资料' }),
|
|
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
|
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
|
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
|
p({ type: 'text', text: '文档已生成,请查收' }),
|
|
]
|
|
const blocks = groupParts(parts)
|
|
expect(blocks.map(b => b.kind)).toEqual(['content', 'process', 'content', 'content'])
|
|
expect((blocks[0] as { part: Part }).part.text).toBe('先找资料')
|
|
expect((blocks[1] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
|
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
|
|
expect((blocks[3] as { part: Part }).part.text).toBe('文档已生成,请查收')
|
|
})
|
|
|
|
it('merges only consecutive process parts into one block', () => {
|
|
const parts: Part[] = [
|
|
p({ type: 'thinking', process: true, text: '思考' }),
|
|
p({ type: 'text', text: '第一段' }),
|
|
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
|
|
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
|
p({ type: 'text', text: '第二段' }),
|
|
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }),
|
|
]
|
|
const blocks = groupParts(parts)
|
|
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process'])
|
|
expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2)
|
|
expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1)
|
|
})
|
|
|
|
it('handles all-process and all-content messages', () => {
|
|
expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })]))
|
|
.toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }])
|
|
expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content'])
|
|
expect(groupParts([])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('humanSize / artifactIcon', () => {
|
|
it('formats sizes compactly', () => {
|
|
expect(humanSize(undefined)).toBe('')
|
|
expect(humanSize(0)).toBe('')
|
|
expect(humanSize(512)).toBe('512 B')
|
|
expect(humanSize(2048)).toBe('2 KB')
|
|
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
|
|
})
|
|
it('picks icons by extension then kind', () => {
|
|
expect(artifactIcon('会议纪要.docx')).toBe('file')
|
|
expect(artifactIcon('截图.png')).toBe('image')
|
|
expect(artifactIcon('blob', 'image')).toBe('image')
|
|
expect(artifactIcon('打包.zip')).toBe('package')
|
|
})
|
|
})
|