feat(studio): S7c 采纳恒回传完整正文(所见即所写)+ 流健壮性

分支①第三步(纯前端,无需改后端):采纳恒以 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) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-08 00:53:56 -07:00
parent fcd0623a18
commit 43a08624f8
8 changed files with 287 additions and 41 deletions

View File

@ -159,6 +159,22 @@ async function readBlockRevision(): Promise<number> {
}
}
/** 实读目标 Block 当前 Canonical 正文muse_content_block.content_text用于「所见即所写」核验。 */
async function readBlockContentText(): Promise<string> {
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<number | null> {
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);

View File

@ -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();
});
});

View File

@ -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 {

View File

@ -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({
{/* 差异混排主体展现视口 */}
<div className="flex-1 overflow-y-auto p-5 space-y-4 bg-gray-50/50">
{/* 内容降级警告:完整正文未经 SSE 送达,当前仅为脱敏摘要,明确提示用户避免误采纳截断/摘要内容。 */}
{degraded && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs leading-relaxed text-amber-700">
<strong> </strong>
<p className="mt-1">
</p>
</div>
)}
<div className="bg-white rounded-2xl p-5 border border-gray-100 shadow-sm min-h-[300px]">
<div className="text-sm leading-relaxed text-gray-800 break-words whitespace-pre-wrap font-sans">
{diffChunks.map((chunk, idx) => {

View File

@ -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 }) => (
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
);
/** 拦截 suggestion-merges 端点并回传成功结果,返回捕获的请求体数组供断言。 */
function captureMergeRequests() {
const bodies: Array<Record<string, unknown>> = [];
server.use(
http.post('/app-api/muse/works/:workId/blocks/:blockId/suggestion-merges', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
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);
});
});

View File

@ -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 ShadowCanonical
* - finalContent
* - S7c **** merge_after_edit + modify_then_merge
* finalContentAI //
* 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以严守「所见即所写」用户在面板看到/编辑的每个字符(含有意的首尾空白/换行)
// 都原样写入 Canonicaltrim 仅用于上方非空校验,不改变实际写入内容。
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<MergeBlockSuggestionResult>(
`/works/${workId}/blocks/${blockId}/suggestion-merges`,

View File

@ -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;
}

View File

@ -70,6 +70,8 @@ export default function WorkspacePage() {
// candidateTextAI 流式生成的待采纳正文candidateSuggestionId采纳写入 Canonical 的唯一入参(为 null 时只能预览)。
const [candidateText, setCandidateText] = useState('');
const [candidateSuggestionId, setCandidateSuggestionId] = useState<string | null>(null);
// 候选内容降级标记true 表示 candidateText 是回源取到的脱敏摘要(完整正文未经 SSE 送达),需在候选面板明确提示。
const [candidateDegraded, setCandidateDegraded] = useState(false);
const [originalText, setOriginalText] = useState('');
const [showCompare, setShowCompare] = useState(false);
// 右侧面板 TabAI 创作助手 / 作品规划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}
/>
) : (
<AIPanel