From c2878c04eaa4ec444acadd69099f838ce778757a Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 14:24:01 +0000 Subject: [PATCH] Submit page actions from the preview pane (U5) Why: U5 closes the interactive loop - the surface view's action click carries the user's typed drafts (and nothing else - unsubmitted input has no upload path), the store builds the idempotent request (client-generated requestId, current revision), guards double-clicks in flight, and maps server outcomes to toasts (awaiting-confirm card, revision conflict refresh, disabled rejection). Evidence: tsc clean; vitest 95/95 including the request-shape test (explicit data only, unique requestId per submission); knip unchanged. Impact: Frontend-only; backend endpoint from the U5 backend branch. Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task feature/generative-ui-u5 --- src/api.ts | 2 ++ src/components/PreviewPane.tsx | 2 +- src/store.ts | 28 ++++++++++++++++++++++++++++ src/surface_action.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/surface_action.test.ts diff --git a/src/api.ts b/src/api.ts index d734a2d..760bad3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -31,6 +31,8 @@ export const api = { 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)), + 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)), 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)---- diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index 89828ae..0c60d05 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -56,7 +56,7 @@ export function PreviewPane() {
{surfaceSnap - ? { + ? store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), actionId, payload as Record)} docBridge={{ onOpenDoc: docId => { const art = artifacts.find(a => a.id === docId) if (art) store.requestPreview(art) } }} /> diff --git a/src/store.ts b/src/store.ts index 3279f1f..fb8945c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -162,6 +162,8 @@ function loadUI(): UIState { return { ...DEFAULT_UI } } +const surfaceActionsInflight: Record = {} + let state: AppState = { contextObs: {}, projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, @@ -899,6 +901,32 @@ export const store = { }) syncFilesEntry(true) }, + // U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻 + // 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。 + 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) + if (!snap) { store.toast('页面快照不可用,无法提交'); return } + const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2)) + surfaceActionsInflight[surfaceId + ':' + actionId] = true + try { + const res = await api.submitSurfaceAction(surfaceId, { + actionId, revision: snap.revision, requestId, data, + }) + if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行') + else if (res?.state === 'committed') store.toast('已提交') + else store.toast('动作状态:' + (res?.state ?? 'unknown')) + } catch (e: any) { + const msg = String(e?.message ?? e) + if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试') + else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用') + else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝') + else store.toast('动作失败:' + msg) + } finally { + delete surfaceActionsInflight[surfaceId + ':' + actionId] + } + }, togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) }, activatePreview(id: string) { const conv = previewTabsKey(state.currentConvId) diff --git a/src/surface_action.test.ts b/src/surface_action.test.ts new file mode 100644 index 0000000..8445b4f --- /dev/null +++ b/src/surface_action.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿 +// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。 + +function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record) { + const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now()) + return { + actionId, + revision: snapshot.revision, + requestId, + data: drafts, + } +} + +describe('surface action request', () => { + it('携带当前修订与显式草稿;requestId 唯一', () => { + const snap = { revision: 3 } + const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } }) + const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } }) + expect(a.revision).toBe(3) + expect(a.actionId).toBe('generative-ui.submit') + expect(a.data).toEqual({ f1: { years: '5' } }) + expect(a.requestId).not.toBe(b.requestId) + }) +}) -- 2.54.0