From 43a08624f8ccf326aa9126514f3be8a23f189745 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 8 Jul 2026 00:53:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20S7c=20=E9=87=87=E7=BA=B3?= =?UTF-8?q?=E6=81=92=E5=9B=9E=E4=BC=A0=E5=AE=8C=E6=95=B4=E6=AD=A3=E6=96=87?= =?UTF-8?q?(=E6=89=80=E8=A7=81=E5=8D=B3=E6=89=80=E5=86=99)+=20=E6=B5=81?= =?UTF-8?q?=E5=81=A5=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分支①第三步(纯前端,无需改后端):采纳恒以 merge_after_edit + modify_then_merge 语义上送用户在 候选面板审定的完整正文作 finalContent(改没改过都送、原样发),使 Canonical 由客户端回传正文写入, 而非后端存的 60/80 字摘要——修掉「原样采纳(accept_as_is)把摘要写进 Canonical」的潜伏缺陷。 后端契约已亲验:ContentSourceServiceImpl.resolveMergedContent 的 merge_after_edit 分支直接落 finalContent、只校验非空、不要求"必须改过";accept_as_is 才用摘要。 流健壮性:done 仅带 taskId/suggestionId/可选 summary、无完整性信号,故按前端可得信号三分——①流非空 (原子 SSE chunk 已收全)→happy;②流非空但短于 done.summary→疑似断连截断,提示重生成、绝不静默采纳; ③流为空→回源只得脱敏摘要,标 degraded 由候选面板明确警告、不当完整正文静默采纳。 验证:tsc -b / vitest 32 files 121 passed / vite build / eslint 均 EXIT=0;MSW-off e2e accept-suggestion.spec 真后端48080+真 New-API 3 passed——采纳后 acceptMode=merge_after_edit、 revision 180→181、Canonical content_text===客户端回传 finalContent(所见即所写端到端证实)。 改动严格 8 文件全在 muse-studio。 Co-Authored-By: Claude Opus 4.8 (1M context) --- muse-studio/e2e/accept-suggestion.spec.ts | 32 ++++- .../components/AIPanel.contract.test.tsx | 56 ++++++++- .../features/editor/components/AIPanel.tsx | 45 ++++--- .../editor/components/CandidatePanel.tsx | 16 +++ .../editor/hooks/useAcceptSuggestion.test.tsx | 116 ++++++++++++++++++ .../editor/hooks/useAcceptSuggestion.ts | 42 ++++--- muse-studio/src/lib/sse.ts | 4 +- muse-studio/src/pages/WorkspacePage.tsx | 17 ++- 8 files changed, 287 insertions(+), 41 deletions(-) create mode 100644 muse-studio/src/features/editor/hooks/useAcceptSuggestion.test.tsx diff --git a/muse-studio/e2e/accept-suggestion.spec.ts b/muse-studio/e2e/accept-suggestion.spec.ts index 01661283..c60a8a55 100644 --- a/muse-studio/e2e/accept-suggestion.spec.ts +++ b/muse-studio/e2e/accept-suggestion.spec.ts @@ -159,6 +159,22 @@ async function readBlockRevision(): Promise { } } +/** 实读目标 Block 当前 Canonical 正文(muse_content_block.content_text),用于「所见即所写」核验。 */ +async function readBlockContentText(): Promise { + const c = createPgClient(); + await c.connect(); + try { + const r = await c.query( + `SELECT content_text FROM muse_content_block WHERE tenant_id=1 AND work_id=$1 AND id=$2`, + [WORK_ID, BLOCK_ID] + ); + if (r.rowCount === 0) throw new Error(`block id=${BLOCK_ID} 不存在(基础种子缺失)`); + return r.rows[0].content_text == null ? '' : String(r.rows[0].content_text); + } finally { + await c.end(); + } +} + /** 读 work1/block1 最新一条 pending 候选 id(供负路乐观锁用例;版本冲突在 authz 校验前先判,故任意 pending 即可)。 */ async function readLatestPendingSuggestionId(): Promise { const c = createPgClient(); @@ -260,7 +276,21 @@ test.describe('AI 候选处置闭环(真实后端 + 真生成,不 stub)', expect(String(mergeBody?.data?.sourceAttribution?.lineage?.parentSuggestionId)).toBe( String(generatedSuggestionId) ); - expect(mergeBody?.data?.sourceAttribution?.lineage?.acceptMode).toBe('accept_as_is'); + // (d2) 所见即所写(S7c 裁决②):前端采纳恒以 merge_after_edit + modify_then_merge 上送用户审定的完整正文, + // 故 acceptMode 现为 merge_after_edit(此前为 accept_as_is——后端会改用自己存的摘要写正文,已被本步修掉)。 + expect(mergeBody?.data?.sourceAttribution?.lineage?.acceptMode).toBe('merge_after_edit'); + // (d3) 采纳请求体核验:真打的 suggestion-merges 携带 merge_after_edit + 非空 finalContent(客户端回传正文)。 + const mergeReqBody = JSON.parse(mergeResponse.request().postData() ?? '{}'); + expect(mergeReqBody.mergeMode, '采纳恒走 merge_after_edit').toBe('merge_after_edit'); + expect(mergeReqBody.decisionType, '与 mergeMode 成对,恒 modify_then_merge').toBe('modify_then_merge'); + expect(typeof mergeReqBody.finalContent, 'finalContent 必为客户端回传的完整正文字符串').toBe('string'); + expect(String(mergeReqBody.finalContent).length, 'finalContent 非空').toBeGreaterThan(0); + // (d4) 所见即所写 Canonical 证明:写入 muse_content_block.content_text 的正文严格等于客户端回传的 finalContent, + // 证 Canonical 由用户审定的回传正文写入(而非后端存的 60/80 字脱敏摘要)。 + const canonicalContent = await readBlockContentText(); + expect(canonicalContent, 'Canonical 正文应等于客户端回传的 finalContent(所见即所写)').toBe( + String(mergeReqBody.finalContent) + ); // (e) AI owner 必须把被采纳候选标为 accepted 并写 decision archive;否则候选仍 pending,后续可被重复处置。 const acceptedDecision = await readSuggestionDecision(generatedSuggestionId); diff --git a/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx b/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx index 25bc0f4d..b07ec04c 100644 --- a/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx +++ b/muse-studio/src/features/editor/components/AIPanel.contract.test.tsx @@ -60,7 +60,10 @@ describe('AIPanel task stream contract', () => { fireEvent.click(screen.getByRole('button', { name: /发送指令/ })); // done 事件须把累计正文与后端回传的 suggestionId 一并传给上层,suggestionId 是采纳写入 Canonical 的唯一入参。 - await waitFor(() => expect(onCandidateGenerated).toHaveBeenCalledWith('星海', '6001')); + // 第三参 degraded=false:完整正文经 SSE chunk 送达(非回源摘要),可作所见即所写的采纳基准。 + await waitFor(() => + expect(onCandidateGenerated).toHaveBeenCalledWith('星海', '6001', { degraded: false }) + ); expect(createRequests).toHaveLength(1); expect(createRequests[0]?.url).not.toContain('秘密提示词'); // 收紧契约断言:除 intent/contextScope,还须证明上下文引用真传了、传对了(防"前端不下传上下文仍假绿")。 @@ -136,10 +139,57 @@ describe('AIPanel task stream contract', () => { }); fireEvent.click(screen.getByRole('button', { name: /发送指令/ })); - // done 无 chunk 时必须回源 suggestion 详情取正文;否则上层会打开空候选面板,采纳按钮永久禁用。 + // done 无 chunk(流为空)时必须回源 suggestion 详情取正文;否则上层会打开空候选面板,采纳按钮永久禁用。 + // 第三参 degraded=true:回源只能取到脱敏摘要(非完整正文),须标记降级让候选面板明确提示,不当完整正文静默采纳。 await waitFor(() => - expect(onCandidateGenerated).toHaveBeenCalledWith('从详情接口读取的候选正文', '7002') + expect(onCandidateGenerated).toHaveBeenCalledWith('从详情接口读取的候选正文', '7002', { degraded: true }) ); expect(detailRequests).toEqual(['http://localhost:3000/app-api/muse/suggestions/7002']); }); + + it('does not silently accept a stream whose content is shorter than the done summary', async () => { + server.use( + http.post('/app-api/muse/ai/tasks', () => { + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { taskId: 'task-789' }, + }, { status: 202 }); + }), + http.get('/app-api/muse/ai/tasks/:taskId/stream', () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // 复现中途断连:只收到一小段流正文,随后 done 回带的候选摘要长度远大于已收到的流正文。 + controller.enqueue(encoder.encode('id: 1\nevent: chunk\ndata: {"content":"开头","sequenceNo":1}\n\n')); + controller.enqueue( + encoder.encode( + 'id: 2\nevent: done\ndata: {"taskId":789,"suggestionId":8003,"summary":"这是一段明显更长的候选摘要用于触发截断守卫判据"}\n\n' + ) + ); + controller.close(); + }, + }); + return new HttpResponse(stream, { headers: { 'Content-Type': 'text/event-stream' } }); + }) + ); + + const onCandidateGenerated = vi.fn(); + render( + React.createElement(AIPanel, { + onCandidateGenerated, + workId: 'work-abc', + blockId: 'block-xyz', + }) + ); + + fireEvent.change(screen.getByPlaceholderText(/在此处输入创作描述/), { + target: { value: '继续写' }, + }); + fireEvent.click(screen.getByRole('button', { name: /发送指令/ })); + + // 流正文比摘要还短 → 疑似截断:必须提示重生成,且绝不打开候选面板把截断正文静默当候选采纳。 + await waitFor(() => expect(screen.getByText(/疑似不完整/)).toBeInTheDocument()); + expect(onCandidateGenerated).not.toHaveBeenCalled(); + }); }); diff --git a/muse-studio/src/features/editor/components/AIPanel.tsx b/muse-studio/src/features/editor/components/AIPanel.tsx index c31aa807..ca9cf6e4 100644 --- a/muse-studio/src/features/editor/components/AIPanel.tsx +++ b/muse-studio/src/features/editor/components/AIPanel.tsx @@ -18,10 +18,12 @@ function hasCandidateContent(content: string | null | undefined): content is str interface AIPanelProps { /** * 触发生成新候选大纲/文本时的回调。 - * @param content AI 流式累计生成的完整文本 + * @param content AI 流式累计生成的完整文本(degraded=true 时退化为候选脱敏摘要) * @param suggestionId 后端 done 事件回传的候选 ID;用户采纳写入 Canonical 时必须携带,缺失时为 null(无法采纳,仅可预览) + * @param meta.degraded 内容完整性标记:false=经 SSE 送达的完整正文;true=流为空回源取到的脱敏摘要(非完整正文), + * 候选面板须据此明确提示用户,避免把摘要当完整正文静默采纳(S7c 流健壮性裁决 §11)。 */ - onCandidateGenerated: (content: string, suggestionId: string | null) => void; + onCandidateGenerated: (content: string, suggestionId: string | null, meta: { degraded: boolean }) => void; /** 目标作品 ID:服务端据此作为上下文与权限边界,必传(缺失时后端按权限拒绝)。 */ workId: string; /** 目标 Block 引用:续写/正文生成场景所需,服务端校验其归属 workId。 */ @@ -60,27 +62,40 @@ export default function AIPanel({ return hasCandidateContent(detail.content) ? detail.content : null; }; - const handleStreamDone = async (data: { suggestionId?: string }) => { + const handleStreamDone = async (data: { suggestionId?: string; summary?: string }) => { setIsGenerating(false); // 捕获后端 done 事件回传的候选 ID;它是后续采纳写入 Canonical 的唯一合法入参。 const suggestionId = data?.suggestionId ?? null; + const streamText = streamContentRef.current; try { - // 真链路中 dispatcher 可能只落库 suggestion 并发送 done,不保证 chunk 到达浏览器。 - // 当累计流正文为空时,回源读取 AI owner 的 suggestion 详情,避免打开空候选面板导致采纳按钮永久禁用。 - const candidateContent = hasCandidateContent(streamContentRef.current) - ? streamContentRef.current - : suggestionId - ? await loadCandidateContent(suggestionId) - : null; - - if (!hasCandidateContent(candidateContent)) { - setErrorMsg('AI 候选正文为空,请重试'); + // 情况一:流已送来非空正文。单个大 chunk 由 SSE 原子帧保证完整性——事件必待终止空行(\n\n)才被 dispatch, + // 半个被截断的事件不会进入 onChunk,故 streamText 非空即意味着至少完整收到了承载完整正文的 chunk 事件。 + if (hasCandidateContent(streamText)) { + // 二次防线(裁决 §11「不静默采信截断」):done 若回带候选摘要,而流正文比摘要还短,则完整正文不可能短于 + // 其自身摘要片段 → 疑似中途断连(如多分片场景丢尾) → 提示重生成,不打开候选面板把截断正文静默当候选采纳。 + const summary = typeof data?.summary === 'string' ? data.summary.trim() : ''; + if (summary.length > 0 && streamText.trim().length < summary.length) { + setErrorMsg('AI 生成的正文疑似不完整(可能中途断连),请重新生成'); + return; + } + onCandidateGenerated(streamText, suggestionId, { degraded: false }); return; } - // 通过回调将生成正文与候选 ID 一并提交给上层,以渲染候选 Diff 视图并支持采纳。 - onCandidateGenerated(candidateContent, suggestionId); + // 情况二:流为空——完整正文未经流送达(chunk 未推、或瞬态全文缓存已 purge/TTL 过期导致回放取到空串)。 + // 回源 suggestion 详情只能拿到脱敏摘要(候选表恒存摘要,非完整正文)。仍回源以免打开空面板令采纳按钮永久禁用, + // 但标记 degraded=true,由候选面板明确提示"这是摘要而非完整正文",避免把摘要当完整正文静默采纳。 + if (!suggestionId) { + setErrorMsg('AI 候选正文为空,请重试'); + return; + } + const summaryContent = await loadCandidateContent(suggestionId); + if (!hasCandidateContent(summaryContent)) { + setErrorMsg('AI 候选正文为空,请重试'); + return; + } + onCandidateGenerated(summaryContent, suggestionId, { degraded: true }); } catch (err) { setErrorMsg(err instanceof Error ? err.message : '读取 AI 候选正文失败'); } finally { diff --git a/muse-studio/src/features/editor/components/CandidatePanel.tsx b/muse-studio/src/features/editor/components/CandidatePanel.tsx index f9f79dd2..10767d02 100644 --- a/muse-studio/src/features/editor/components/CandidatePanel.tsx +++ b/muse-studio/src/features/editor/components/CandidatePanel.tsx @@ -15,6 +15,11 @@ interface CandidatePanelProps { isSubmitting?: boolean; /** 当前候选是否具备合法采纳入口;无 suggestionId 时只能预览,不能写入 Canonical。 */ canAccept?: boolean; + /** + * 内容降级标记:为 true 时当前展示的是候选脱敏摘要(完整正文未经 SSE 送达,已回源取摘要), + * 而非本次生成的完整正文。此时须明确提示用户,避免把摘要当完整正文采纳(S7c 流健壮性裁决 §11)。 + */ + degraded?: boolean; } /** @@ -28,6 +33,7 @@ export default function CandidatePanel({ onReject, isSubmitting = false, canAccept = true, + degraded = false, }: CandidatePanelProps) { const [reviewedText, setReviewedText] = useState(candidateText); const [trackedCandidateText, setTrackedCandidateText] = useState(candidateText); @@ -60,6 +66,16 @@ export default function CandidatePanel({ {/* 差异混排主体展现视口 */}
+ {/* 内容降级警告:完整正文未经 SSE 送达,当前仅为脱敏摘要,明确提示用户避免误采纳截断/摘要内容。 */} + {degraded && ( +
+ ⚠️ 当前展示的是候选摘要,而非本次生成的完整正文 +

+ 完整正文未经流式通道送达(可能中途断连或已过期)。建议放弃后重新生成以获取完整正文,避免将摘要作为正文采纳。 +

+
+ )} +
{diffChunks.map((chunk, idx) => { diff --git a/muse-studio/src/features/editor/hooks/useAcceptSuggestion.test.tsx b/muse-studio/src/features/editor/hooks/useAcceptSuggestion.test.tsx new file mode 100644 index 00000000..e6116164 --- /dev/null +++ b/muse-studio/src/features/editor/hooks/useAcceptSuggestion.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { server } from '@/test/setup'; +import { useAcceptSuggestion } from './useAcceptSuggestion'; + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + +let testQueryClient = createTestQueryClient(); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +/** 拦截 suggestion-merges 端点并回传成功结果,返回捕获的请求体数组供断言。 */ +function captureMergeRequests() { + const bodies: Array> = []; + server.use( + http.post('/app-api/muse/works/:workId/blocks/:blockId/suggestion-merges', async ({ request }) => { + bodies.push((await request.json()) as Record); + return HttpResponse.json({ + code: 0, + msg: 'success', + data: { + blockId: 'block-xyz', + suggestionId: '6001', + newRevision: 8, + decisionType: 'modify_then_merge', + }, + }); + }) + ); + return bodies; +} + +describe('useAcceptSuggestion 所见即所写契约', () => { + beforeEach(() => { + testQueryClient = createTestQueryClient(); + }); + + it('采纳未改动的候选:仍恒走 merge_after_edit 并回传完整正文作 finalContent', async () => { + const bodies = captureMergeRequests(); + // 完整正文(远长于后端 60/80 字摘要),用户一字未改直接采纳。 + const fullContent = + '星海深处,探测器的信号穿过尘埃云,主角第一次看清那座漂浮的遗迹——它比任何星图记载都要庞大而古老。'; + + const { result } = renderHook(() => useAcceptSuggestion('work-abc', 'block-xyz'), { wrapper }); + result.current.mutate({ + suggestionId: '6001', + expectedBlockRevision: 7, + finalContent: fullContent, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(bodies).toHaveLength(1); + const body = bodies[0]!; + // 核心断言:即使未改动,也恒以「用户回传完整正文」语义写 Canonical,而非 accept_as_is(后者会让后端用摘要写正文)。 + expect(body).toMatchObject({ + suggestionId: '6001', + expectedBlockRevision: 7, + decisionType: 'modify_then_merge', + mergeMode: 'merge_after_edit', + finalContent: fullContent, + sourceSnapshot: { sourceType: 'ai_suggestion' }, + }); + // 后端必填契约字段齐备(commandId 幂等键)。 + expect(typeof body.commandId).toBe('string'); + expect((body.commandId as string).length).toBeGreaterThan(0); + // 反向断言:绝不退化为 accept_as_is(会把 60/80 字摘要当正文写入 Canonical)。 + expect(body.mergeMode).not.toBe('accept_as_is'); + expect(body.decisionType).not.toBe('accept'); + }); + + it('采纳改动过的候选:上送用户编辑后的最终正文', async () => { + const bodies = captureMergeRequests(); + const editedContent = '用户在候选面板改写后的最终正文,同样经 merge_after_edit 回传写入 Canonical。'; + + const { result } = renderHook(() => useAcceptSuggestion('work-abc', 'block-xyz'), { wrapper }); + result.current.mutate({ + suggestionId: '6001', + expectedBlockRevision: 7, + finalContent: editedContent, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(bodies[0]).toMatchObject({ + mergeMode: 'merge_after_edit', + decisionType: 'modify_then_merge', + finalContent: editedContent, + }); + }); + + it('空正文兜底:不发起写入请求,直接失败', async () => { + const bodies = captureMergeRequests(); + + const { result } = renderHook(() => useAcceptSuggestion('work-abc', 'block-xyz'), { wrapper }); + result.current.mutate({ + suggestionId: '6001', + expectedBlockRevision: 7, + finalContent: ' ', + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + // 空正文在前端即被拦截,绝不发出非法的 suggestion-merges 请求。 + expect(bodies).toHaveLength(0); + }); +}); diff --git a/muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts b/muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts index bde51e11..eb6fd24d 100644 --- a/muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts +++ b/muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts @@ -2,16 +2,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/api/client'; import type { MergeBlockSuggestionRequest, MergeBlockSuggestionResult } from '@/types/openapi'; -/** 采纳候选 mutation 的入参:候选 ID、目标 Block 当前 revision,以及可选的用户修改后最终正文。 */ +/** 采纳候选 mutation 的入参:候选 ID、目标 Block 当前 revision,以及用户审定的最终完整正文。 */ interface AcceptSuggestionVars { /** AI Orchestration 产生的候选 ID(后端 done 事件回传) */ suggestionId: string; /** 目标 Block 当前 revision;与后端不一致会返回 409,需刷新后重试 */ expectedBlockRevision: number; - /** 用户在候选面板中确认的最终正文;为空或与候选一致时走原样采纳 */ - finalContent?: string; - /** AI 原始候选正文;用于判断是否需要走 merge_after_edit */ - candidateText?: string; + /** 用户在候选面板中审定的最终完整正文;采纳恒回传(所见即所写),即使一字未改。 */ + finalContent: string; } /** @@ -20,7 +18,12 @@ interface AcceptSuggestionVars { * WHY: * - POST /works/{workId}/blocks/{blockId}/suggestion-merges 是 AI 候选写入正文 Canonical 的唯一合法入口, * 绝不能用编辑器 setContent 在前端绕过 Shadow→Canonical 主权。 - * - 用户未修改候选时走「原样接受」;候选面板编辑过正文时走「修改后合并」并传 finalContent。 + * - 所见即所写(S7c 裁决②):采纳**恒**以 merge_after_edit + modify_then_merge 语义上送用户在候选面板审定的 + * 完整正文作为 finalContent(改没改过都送)。根因是完整正文自始不落长期库——AI 原始产出只在「内存/瞬态加密缓存/ + * SSE/客户端」存在,候选表 content_snapshot.content 仅存 60/80 字脱敏摘要;能写进 Canonical 的完整正文只能来自 + * 客户端回传。若走 accept_as_is,后端 resolveMergedContent 会用它自己存的摘要写 Canonical(把 60/80 字摘要当正文), + * 这正是本步要修掉的潜伏缺陷。已核验后端 merge_after_edit 分支直接落 finalContent、且只校验非空、不要求"必须改过", + * 故恒传合法。 * - sourceSnapshot 只发最小快照 { sourceType: 'ai_suggestion' }:后端仅对调用方传入的非空字段与候选自身快照比对, * 前端不掌握候选的 authorizationSnapshotId / sourceVersion,发最小快照可避免误触发 409; * ContentSourceSnapshotVO 各字段均无 @NotBlank/@NotNull,最小快照合法。 @@ -33,27 +36,28 @@ export function useAcceptSuggestion(workId: string, blockId: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ suggestionId, expectedBlockRevision, finalContent, candidateText }: AcceptSuggestionVars) => { - const reviewedContent = finalContent?.trim(); - const originalCandidate = candidateText?.trim(); - const shouldMergeAfterEdit = - reviewedContent !== undefined && - reviewedContent.length > 0 && - originalCandidate !== undefined && - reviewedContent !== originalCandidate; - // 严格按后端 MergeBlockSuggestionReqVO 契约组装请求体 + mutationFn: ({ suggestionId, expectedBlockRevision, finalContent }: AcceptSuggestionVars) => { + // 空正文兜底:后端 merge_after_edit 要求 finalContent 非空,且空正文采纳无意义。 + // 调用方(CandidatePanel)在 reviewedText 为空时已禁用采纳按钮,这里再兜一层,避免非法请求。 + if (finalContent.trim().length === 0) { + return Promise.reject(new Error('采纳正文不能为空')); + } + // 严格按后端 MergeBlockSuggestionReqVO 契约组装请求体。 + // finalContent 发原样(非 trim)以严守「所见即所写」:用户在面板看到/编辑的每个字符(含有意的首尾空白/换行) + // 都原样写入 Canonical;trim 仅用于上方非空校验,不改变实际写入内容。 const payload: MergeBlockSuggestionRequest = { commandId: crypto.randomUUID(), // 幂等键,防止重复写入 suggestionId, expectedBlockRevision, - decisionType: shouldMergeAfterEdit ? 'modify_then_merge' : 'accept', - mergeMode: shouldMergeAfterEdit ? 'merge_after_edit' : 'accept_as_is', - ...(shouldMergeAfterEdit ? { finalContent: reviewedContent } : {}), + // 恒走「修改后合并」语义:decisionType 与 mergeMode 必须成对,否则后端 requireDecisionType 判"不匹配"报错。 + decisionType: 'modify_then_merge', + mergeMode: 'merge_after_edit', + finalContent, sourceSnapshot: { // 最小来源快照:仅声明来源类型为 AI 候选,其余字段交由后端按候选自身快照补齐 sourceType: 'ai_suggestion', }, - auditReason: shouldMergeAfterEdit ? 'studio merge suggestion after edit' : 'studio accept suggestion', + auditReason: 'studio accept reviewed suggestion', }; return api.post( `/works/${workId}/blocks/${blockId}/suggestion-merges`, diff --git a/muse-studio/src/lib/sse.ts b/muse-studio/src/lib/sse.ts index b491cb21..8503de5a 100644 --- a/muse-studio/src/lib/sse.ts +++ b/muse-studio/src/lib/sse.ts @@ -15,8 +15,10 @@ export interface SSEEventHandler { * WHY 类型为 string:后端 taskId/suggestionId 实为 Long、会序列化为 JSON 数字,而前端全链路(采纳请求体、 * 生成的 OpenAPI 类型 src/types/*.ts)按 string 处理;故在 connectAIStream 的 done 分支用 String() 归一后再回调, * 此处类型即归一后的前端契约。注:OpenAPI 把 suggestionId 标注为 uuid 字符串,与后端 Long 实现存在不一致,待后端统一。 + * WHY summary 可选:后端 doneData 在 payload 含 summary 时会带上候选脱敏摘要(60/80 字)。dispatch 用 spread 透传, + * 前端据此做流健壮性判据(流正文若比摘要还短即疑似截断)。缺省时不影响主链路。 */ - onDone?: (data: { taskId: string; suggestionId: string }) => void; + onDone?: (data: { taskId: string; suggestionId: string; summary?: string }) => void; /** 连接或解析发生错误 */ onError?: (data: { code: string; message: string }) => void; } diff --git a/muse-studio/src/pages/WorkspacePage.tsx b/muse-studio/src/pages/WorkspacePage.tsx index ab515196..4ae951ac 100644 --- a/muse-studio/src/pages/WorkspacePage.tsx +++ b/muse-studio/src/pages/WorkspacePage.tsx @@ -70,6 +70,8 @@ export default function WorkspacePage() { // candidateText:AI 流式生成的待采纳正文;candidateSuggestionId:采纳写入 Canonical 的唯一入参(为 null 时只能预览)。 const [candidateText, setCandidateText] = useState(''); const [candidateSuggestionId, setCandidateSuggestionId] = useState(null); + // 候选内容降级标记:true 表示 candidateText 是回源取到的脱敏摘要(完整正文未经 SSE 送达),需在候选面板明确提示。 + const [candidateDegraded, setCandidateDegraded] = useState(false); const [originalText, setOriginalText] = useState(''); const [showCompare, setShowCompare] = useState(false); // 右侧面板 Tab:AI 创作助手 / 作品规划(planning 为 work 级,不依赖 activeBlock,故面板始终挂载)。 @@ -96,6 +98,7 @@ export default function WorkspacePage() { setShowCompare(false); setCandidateText(''); setCandidateSuggestionId(null); + setCandidateDegraded(false); setOriginalText(''); setRevisionOverride(null); } @@ -109,11 +112,17 @@ export default function WorkspacePage() { const rejectSuggestion = useRejectSuggestion(workId || '', activeBlock?.id || ''); // AI 生成完毕回调:缓存候选文本与 suggestionId,并切到对比视图。 - const handleCandidateGenerated = (text: string, suggestionId: string | null) => { + // meta.degraded 透传内容完整性:true 表示回源摘要(非完整正文),候选面板据此提示用户。 + const handleCandidateGenerated = ( + text: string, + suggestionId: string | null, + meta: { degraded: boolean } + ) => { // 在事件回调中读取编辑器 ref,避免渲染期访问 ref 触发 React Hooks 静态规则。 setOriginalText(editorRef.current ? editorRef.current.getText() : ''); setCandidateText(text); setCandidateSuggestionId(suggestionId); + setCandidateDegraded(meta.degraded); setShowCompare(true); }; @@ -127,8 +136,8 @@ export default function WorkspacePage() { const result = await acceptSuggestion.mutateAsync({ suggestionId: candidateSuggestionId, expectedBlockRevision: effectiveRevision, + // 所见即所写:恒上送用户在候选面板审定的完整正文(改没改过都送),由客户端回传写入 Canonical。 finalContent, - candidateText, }); // 用后端返回的 newRevision 即时校准本地乐观锁版本。 if (result?.newRevision != null) { @@ -140,6 +149,7 @@ export default function WorkspacePage() { setShowCompare(false); setCandidateText(''); setCandidateSuggestionId(null); + setCandidateDegraded(false); setOriginalText(''); } catch (err) { // 失败(如 409 revision 冲突)保留对比视图,让用户感知并可重试;错误细节由 mutation.error 暴露。 @@ -152,6 +162,7 @@ export default function WorkspacePage() { if (!candidateSuggestionId) { setShowCompare(false); setCandidateText(''); + setCandidateDegraded(false); setOriginalText(''); return; } @@ -163,6 +174,7 @@ export default function WorkspacePage() { setShowCompare(false); setCandidateText(''); setCandidateSuggestionId(null); + setCandidateDegraded(false); setOriginalText(''); } catch (err) { console.error('拒绝 AI 候选失败:', err); @@ -474,6 +486,7 @@ export default function WorkspacePage() { onReject={handleRejectCandidate} isSubmitting={acceptSuggestion.isPending || rejectSuggestion.isPending} canAccept={Boolean(candidateSuggestionId)} + degraded={candidateDegraded} /> ) : (