diff --git a/src/api.ts b/src/api.ts index 4b323bb..5ee4594 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4,7 +4,7 @@ import { auth } from './auth' import type { Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse, AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule, - ActivityCard, + ActivityCard, SurfaceMeta, SurfaceSnapshot, } from './contracts' const BASE = '' @@ -35,11 +35,13 @@ export const api = { setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)), - surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json(r)), + /** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */ + surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json(r)), submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record }) => auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)), - projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), - surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), + projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), + /** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */ + surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => { diff --git a/src/components/ArtifactsPanel.tsx b/src/components/ArtifactsPanel.tsx index 3fdc9ac..d570cd8 100644 --- a/src/components/ArtifactsPanel.tsx +++ b/src/components/ArtifactsPanel.tsx @@ -7,6 +7,7 @@ import { Collapsible, Icon } from '@ui' import { artifactIcon, humanSize } from '../messageparts' import type { Artifact } from '../contracts' import { groupArtifactsByName } from '../artifactGrouping' +import { surfaceKey } from '../surfaceKey' export function ArtifactsPanel() { const artifacts = useStore(s => s.artifacts) @@ -42,16 +43,19 @@ export function ArtifactsPanel() { {surfaces.length > 0 && (
生成页面
- {surfaces.map(sv => ( -
store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}> + {surfaces.filter(sv => surfaceKey(sv) != null).map(sv => { + const id = surfaceKey(sv)! + return ( +
store.openSurface(id)} title={`打开页面 ${sv.spec?.title ?? ''}`}> {sv.spec?.title || '(未命名页面)'} - 页面 · 修订 {sv.revision} · {sv.identity.pluginId} + 页面 · 修订 {sv.revision} · {sv.identity?.pluginId}
- ))} + ) + })}
)} {artifacts.length === 0 diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index 8bfe7bc..da8e27f 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -7,6 +7,7 @@ import type { Artifact } from '../contracts' import { humanSize } from '../messageparts' import { previewTabsKey } from '../previewtabs' import { store, useStore } from '../store' +import { surfaceKey } from '../surfaceKey' export function PreviewPane() { // 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。 @@ -17,7 +18,10 @@ export function PreviewPane() { const active = tabs.find(t => t.id === activeId) ?? tabs[0] const surfaces = useStore(s => s.surfaces) const surfaceSnap = active?.id.startsWith('surface:') - ? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id) + ? surfaces.find(x => { + const id = surfaceKey(x) + return id != null && 'surface:' + id === active.id + }) : undefined const art = active ? artifacts.find(a => a.id === active.id) : undefined const textKind = !!art && isTextKind(art) @@ -56,7 +60,7 @@ export function PreviewPane() {
{surfaceSnap - ? store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => { + ? store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => { const art = artifacts.find(a => a.id === docId) if (art) store.requestPreview(art) } }} /> diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index f6ba34f..60fbd78 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -6,6 +6,7 @@ import type { Run } from '../contracts' import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui' import { Icon, SurfaceView } from '@ui' import { previewTabsKey } from '../previewtabs' +import { surfaceKey } from '../surfaceKey' import { ArtifactsPanel } from './ArtifactsPanel' import { PreviewPane } from './PreviewPane' @@ -186,9 +187,11 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) { function WorkspaceSurfaces() { const surfaces = useStore(s => s.surfaces) const pin = useStore(s => s.ui.workspaceSurfacePin) - if (surfaces.length === 0) return null - const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1] + const usable = surfaces.filter(x => surfaceKey(x) != null) + if (usable.length === 0) return null + const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1] if (!shown) return null + const shownId = surfaceKey(shown)! return (
@@ -196,10 +199,10 @@ function WorkspaceSurfaces() { {shown.spec?.title || '(未命名)'} · 修订 {shown.revision} -
diff --git a/src/store.ts b/src/store.ts index 28fd0dc..32e89b8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,6 +8,7 @@ import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessag import { canFlushOutbox, deriveShellPhase } from './runstate' import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' +import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' import { rdmAuditReady } from './workspaces' import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement' @@ -404,23 +405,31 @@ export const store = { }) } // U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。 + // surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。 api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {}) - api.surfaceList(convId).then(list => { + api.surfaceList(convId).then(async list => { if (state.currentConvId !== convId) return - set({ surfaces: list ?? [] }) + const metas = list ?? [] // 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切 - // 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。 + // 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。 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] + if (!hasSurfaceTab && metas.length > 0) { + const latest = metas[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 }, }) } + const fetched = await Promise.all(metas.map(m => + api.surfaceGet(String(m.surfaceId)).catch(() => null), + )) + if (state.currentConvId !== convId) return + const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null) + // 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。 + set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) }) }).catch(() => {}) const resumeSeq = await store.reconcile() store.connectLive(resumeSeq ?? state.lastSeq) @@ -547,10 +556,10 @@ export const store = { // KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫: // 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。 const p = e.payload as SurfaceSnapshot - if (!p?.identity?.surfaceId) break - const id = String(p.identity.surfaceId) - const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id) - const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id) + const id = surfaceKey(p) + if (!id) break + const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id) + const existing = state.surfaces.find(x => surfaceKey(x) === id) if (!existing || p.revision > existing.revision) { surfaces.push(p) set({ surfaces }) @@ -948,7 +957,7 @@ export const store = { }, openSurface(surfaceId: string) { // generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。 - const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId) + const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId) const conv = previewTabsKey(state.currentConvId) const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' }) set({ @@ -962,7 +971,7 @@ export const store = { async handleSurfaceAction(surfaceId: string, actionId: string, data: Record) { const inflight = surfaceActionsInflight[surfaceId + ':' + actionId] if (inflight) return - const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId) + const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId) if (!snap) { store.toast('页面快照不可用,无法提交'); return } const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2)) surfaceActionsInflight[surfaceId + ':' + actionId] = true diff --git a/src/surfaceKey.ts b/src/surfaceKey.ts new file mode 100644 index 0000000..9c9b03f --- /dev/null +++ b/src/surfaceKey.ts @@ -0,0 +1,19 @@ +// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。 +import type { SurfaceSnapshot } from './contracts' + +export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null { + const id = x?.identity?.surfaceId + return id == null || id === '' ? null : String(id) +} + +/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */ +export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] { + const byId = new Map() + for (const s of [...a, ...b]) { + const id = surfaceKey(s) + if (!id) continue + const existing = byId.get(id) + if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s) + } + return [...byId.values()] +} diff --git a/test/surfaceKey.test.ts b/test/surfaceKey.test.ts new file mode 100644 index 0000000..25e92f0 --- /dev/null +++ b/test/surfaceKey.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { SurfaceSnapshot } from '../src/contracts' +import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey' + +function snap(id: string, revision: number): SurfaceSnapshot { + return { + identity: { + surfaceId: id, + owner: 'o', + pluginId: 'p', + }, + compat: { schemaVersion: 1, catalogVersion: '1' }, + revision, + activationEpoch: 0, + spec: { title: id, nodes: [] }, + } +} + +describe('surfaceKey', () => { + it('reads nested identity and skips missing', () => { + expect(surfaceKey(snap('s1', 1))).toBe('s1') + expect(surfaceKey(undefined)).toBeNull() + expect(surfaceKey({} as SurfaceSnapshot)).toBeNull() + expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull() + }) +}) + +describe('mergeSurfacesByRevision', () => { + it('keeps higher revision and drops dirty entries', () => { + const dirty = { revision: 99 } as SurfaceSnapshot + const merged = mergeSurfacesByRevision( + [snap('a', 1), dirty], + [snap('a', 3), snap('b', 1)], + ) + expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([ + ['a', 3], + ['b', 1], + ]) + }) +})