Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ebc7fb84 | ||
|
|
367aeefff4 | ||
|
|
65d3b8ef02 | ||
|
|
ed7e3126e7 |
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupArtifactsByName } from './artifactGrouping'
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
|
||||
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
|
||||
|
||||
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
|
||||
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
|
||||
}
|
||||
|
||||
describe('groupArtifactsByName', () => {
|
||||
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
|
||||
const g = groupArtifactsByName([
|
||||
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
|
||||
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
|
||||
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
|
||||
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
|
||||
])
|
||||
expect(g).toHaveLength(2)
|
||||
const grp = g.find(x => x.latest.name === '报告.docx')!
|
||||
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
|
||||
expect(grp.versions).toHaveLength(3)
|
||||
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
|
||||
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('不同名互不影响;空列表返回空', () => {
|
||||
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
|
||||
expect(g).toHaveLength(2)
|
||||
expect(groupArtifactsByName([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
|
||||
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
export interface ArtifactGroup {
|
||||
latest: Artifact
|
||||
versions: Artifact[]
|
||||
}
|
||||
|
||||
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
|
||||
const byName = new Map<string, Artifact[]>()
|
||||
for (const a of list) {
|
||||
const arr = byName.get(a.name) ?? []
|
||||
arr.push(a)
|
||||
byName.set(a.name, arr)
|
||||
}
|
||||
const out: ArtifactGroup[] = []
|
||||
for (const versions of byName.values()) {
|
||||
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
|
||||
out.push({ latest: versions[0], versions })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,17 +3,24 @@
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { groupArtifactsByName } from '../artifactGrouping'
|
||||
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
|
||||
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
|
||||
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
|
||||
const groups = groupArtifactsByName(visible)
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
@@ -32,26 +39,47 @@ export function ArtifactsPanel() {
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{surfaces.length > 0 && (
|
||||
<div className="ap-surfaces" data-ap-surfaces>
|
||||
<div className="ap-section-title">生成页面</div>
|
||||
{surfaces.map(sv => (
|
||||
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
|
||||
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
: groups.map(g => g.versions.length === 1
|
||||
? <ArtifactRow key={g.latest.id} a={g.latest} />
|
||||
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
|
||||
trigger={
|
||||
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
|
||||
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
|
||||
</div>
|
||||
}>
|
||||
{g.versions.slice(1).map(v => (
|
||||
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
|
||||
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
|
||||
<span className="ar-icon"><Icon name="clock" size={13} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,3 +93,32 @@ function shortTime(iso: string): string {
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function ArtifactRow({ a }: { a: Artifact }) {
|
||||
return (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<ArtifactRowInner a={a} />
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
+23
-3
@@ -385,7 +385,23 @@ export const store = {
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
set({ surfaces: list ?? [] })
|
||||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
|
||||
const latest = list[0]
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
}
|
||||
}).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -518,8 +534,9 @@ export const store = {
|
||||
if (!existing || p.revision > existing.revision) {
|
||||
surfaces.push(p)
|
||||
set({ surfaces })
|
||||
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
|
||||
// 已打开的其他对象;KTD「新结果不抢占」)。
|
||||
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
|
||||
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
|
||||
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const activeId = state.previewActive[conv] ?? null
|
||||
@@ -530,6 +547,9 @@ export const store = {
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
} else {
|
||||
store.toast(`页面已更新:${p.spec?.title || id}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { store } from './store'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||||
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
@@ -37,3 +38,22 @@ describe('surface.updated projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('surface.updated 可见性联动', () => {
|
||||
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||||
const ui = store.getState().ui
|
||||
expect(ui.rightTab).toBe('files')
|
||||
expect(ui.rightCollapsed).toBe(false)
|
||||
expect(ui.filesView).toBe('preview')
|
||||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||||
})
|
||||
|
||||
it('抑制开标签时留 toast 痕迹', () => {
|
||||
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
|
||||
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
|
||||
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
|
||||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user