From df4ca1e4702ebf62e3cca339de4a78d56879a3c5 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 11:39:57 +0000 Subject: [PATCH] Render saved surfaces in the preview pane with refresh recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: U4 read-only experience - surface.updated events project into a surfaces slice with a strict revision guard (stale/duplicate events never overwrite; server snapshot is the single owner), preview tabs gain a surface: kind that renders SurfaceView (doc-refs bridge into artifact previews), a new page auto-opens a tab without stealing the user's active non-surface tab, and opening a conversation reloads the authoritative surface list so refresh/断线 restores pages. Evidence: tsc clean; vitest 87/87 including new surface projection tests (stale/duplicate/multi-surface); UI SurfaceView suite green in its repo. Impact: Frontend-only; no backend or contract changes. Refs: Core PRs #27/#28; UI PRs #42/#43/#44; Dev task feature/generative-ui-u4 --- src/api.ts | 4 ++- src/components/PreviewPane.tsx | 32 +++++++++++++++++- src/contracts.ts | 45 ++++++++++++++++++++++++- src/store.ts | 61 +++++++++++++++++++++++++++++++--- src/surface.test.ts | 39 ++++++++++++++++++++++ 5 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/surface.test.ts diff --git a/src/api.ts b/src/api.ts index 1b5bb77..8534172 100644 --- a/src/api.ts +++ b/src/api.ts @@ -30,6 +30,8 @@ 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)), + 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; 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 => { @@ -160,7 +162,7 @@ const streamEventListeners: Record = { 'card.created': true, 'card.updated': true, 'card.status': true, - 'card.resolved': true, + 'card.resolved': true, 'surface.updated': true, } export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } { diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index 19d9e02..89828ae 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -1,7 +1,7 @@ // 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。 // 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。 import { useEffect, useState } from 'react' -import { Btn, FilePreview, Icon, TabStrip } from '@ui' +import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui' import { api } from '../api' import type { Artifact } from '../contracts' import { humanSize } from '../messageparts' @@ -15,6 +15,10 @@ export function PreviewPane() { const artifacts = useStore(s => s.artifacts) const expanded = useStore(s => s.ui.previewExpanded) 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) + : undefined const art = active ? artifacts.find(a => a.id === active.id) : undefined const textKind = !!art && isTextKind(art) const [content, setContent] = useState(null) @@ -35,6 +39,32 @@ export function PreviewPane() { }, [art?.id]) if (!active) return null + if (active.id.startsWith('surface:')) { + return ( +
+ ({ id: t.id, label: t.name }))} + activeId={active.id} + onActivate={id => store.activatePreview(id)} + onClose={id => store.closePreview(id)} + /> +
+ + {active.name} +
+
+ {surfaceSnap + ? { + const art = artifacts.find(a => a.id === docId) + if (art) store.requestPreview(art) + } }} /> + :

页面快照不可用(可能已被移除或无权限)。

} +
+
+ ) + } return (
{ seq: number; runId?: ID; conversationId: ID; ts: string; v: number; @@ -355,3 +356,45 @@ export interface UploadInfo { artifactId: ID; filename: string; mime?: string; size: number; parsed?: ParsedDocument; } + +// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)---- + +export interface SurfaceCompat { + schemaVersion: number; + catalogVersion: string; + pluginVersion?: string; +} + +export interface SurfaceIdentity { + surfaceId: ID; + owner: string; + projectId?: ID; + conversationId?: ID; + sourceRunId?: ID; + pluginId: string; +} + +export interface SurfaceSpec { + title: string; + nodes: UINode[]; +} + +export interface SurfaceSnapshot { + identity: SurfaceIdentity; + compat: SurfaceCompat; + revision: number; + baseRevision?: number; + contextVersion?: string; + activationEpoch: number; + spec: SurfaceSpec; + semanticText?: string; +} + +export interface SurfaceMeta { + surfaceId: ID; + title: string; + pluginId: string; + revision: number; + conversationId?: ID; + updatedAt: string; +} diff --git a/src/store.ts b/src/store.ts index 915aa94..3279f1f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -16,8 +16,19 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice { return (FONT_SIZE_CHOICES as readonly number[]).includes(value) } import type { - Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, - AgentProfile, StreamEvent, PluginPanel, ActivityCard, + ActivityCard, + AgentProfile, + Artifact, + Conversation, + HITLCard, + Message, + PlanSnapshot, + PluginPanel, + Project, + Run, + StreamEvent, + SurfaceSnapshot, + TodoList } from './contracts' export interface ContextObs { @@ -86,6 +97,8 @@ interface AppState { plan: PlanSnapshot | null todos: TodoList | null artifacts: Artifact[] + /** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */ + surfaces: SurfaceSnapshot[] cards: ActivityCard[] projectCards: ActivityCard[] inboxScope: 'conv' | 'project' @@ -153,7 +166,7 @@ let state: AppState = { contextObs: {}, projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, - plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], + plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], ui: loadUI(), connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, @@ -359,7 +372,7 @@ export const store = { async openConversation(convId: string) { if (sse) { sse.close(); sse = null } - set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false }) + set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false }) persistConvId(convId) // 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。 if ((state.previewTabs['_'] ?? []).length > 0) { @@ -369,6 +382,8 @@ export const store = { previewActive: { ...state.previewActive, [convId]: active }, }) } + // U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。 + api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {}) const resumeSeq = await store.reconcile() store.connectLive(resumeSeq ?? state.lastSeq) }, @@ -490,6 +505,33 @@ export const store = { set({ todos: p.list }) break } + case 'surface.updated': { + // 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) + if (!existing || p.revision > existing.revision) { + surfaces.push(p) + set({ surfaces }) + // 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户 + // 已打开的其他对象;KTD「新结果不抢占」)。 + const conv = previewTabsKey(state.currentConvId) + const tabs = state.previewTabs[conv] ?? [] + const activeId = state.previewActive[conv] ?? null + const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:')) + if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) { + const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' }) + set({ + previewTabs: { ...state.previewTabs, [conv]: opened.tabs }, + previewActive: { ...state.previewActive, [conv]: opened.activeId }, + }) + } + } + break + } case 'card.created': case 'card.updated': { const p = e.payload as { card: ActivityCard } @@ -846,6 +888,17 @@ export const store = { }) syncFilesEntry(true) }, + openSurface(surfaceId: string) { + // generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。 + const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId) + const conv = previewTabsKey(state.currentConvId) + const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' }) + set({ + previewTabs: { ...state.previewTabs, [conv]: opened.tabs }, + previewActive: { ...state.previewActive, [conv]: opened.activeId }, + }) + syncFilesEntry(true) + }, togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) }, activatePreview(id: string) { const conv = previewTabsKey(state.currentConvId) diff --git a/src/surface.test.ts b/src/surface.test.ts new file mode 100644 index 0000000..bbaf876 --- /dev/null +++ b/src/surface.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { store } from './store' +import type { StreamEvent, SurfaceSnapshot } from './contracts' + +// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订; +// 服务端快照是唯一权威(KTD8 单一状态所有者)。 + +function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent { + return { + seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated', + payload: { + identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' }, + compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, + revision, activationEpoch: 1, + spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] }, + }, + } +} + +describe('surface.updated projection', () => { + it('新事件入库;旧修订与重复事件不覆盖', () => { + store.applyEvent(surfEvent(1, 's1', 1, '初版')) + store.applyEvent(surfEvent(2, 's1', 2, '修订二')) + store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订')) + store.applyEvent(surfEvent(4, 's1', 2, '重复事件')) + const list = store.getState().surfaces + expect(list).toHaveLength(1) + expect(list[0].revision).toBe(2) + expect(list[0].spec.title).toBe('修订二') + }) + + it('多页面并存,互不覆盖', () => { + store.applyEvent(surfEvent(5, 's2', 1, '页面二')) + const list = store.getState().surfaces + expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1) + expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1) + }) +}) +