lili 43a08624f8 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>
2026-07-08 00:53:56 -07:00

258 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
import { connectAIStream } from '@/lib/sse';
import { api } from '@/api/client';
import type { CreateAiTaskRequest, CreateAiTaskResult, SuggestionDetail } from '@/types/openapi';
/** 续写场景默认绑定的智能体槽位 key由 Agent BC 解析为具体 Agent 版本)。 */
const DEFAULT_AGENT_SLOT_KEY = 'writing.continuation';
/**
* 判定候选正文是否具备可采纳价值。
* WHY真实 New-API 链路可能只在 done 事件回传 suggestionId而不推 chunk空正文不能打开可采纳面板。
*/
function hasCandidateContent(content: string | null | undefined): content is string {
return typeof content === 'string' && content.trim().length > 0;
}
/** AI 面板 Props 定义 */
interface AIPanelProps {
/**
* 触发生成新候选大纲/文本时的回调。
* @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, meta: { degraded: boolean }) => void;
/** 目标作品 ID服务端据此作为上下文与权限边界必传缺失时后端按权限拒绝。 */
workId: string;
/** 目标 Block 引用:续写/正文生成场景所需,服务端校验其归属 workId。 */
blockId?: string;
/** 目标章节引用:服务端校验其归属 workId。 */
chapterId?: string;
/** 作品内智能体槽位 key未传时默认续写槽位 writing.continuation。 */
agentSlotKey?: string;
}
/**
* AI 辅助创作面板
* 发起流式续写或优化指令,展示 Token 级增量打字机视口,支持安全中止和网络重试
*/
export default function AIPanel({
onCandidateGenerated,
workId,
blockId,
chapterId,
agentSlotKey,
}: AIPanelProps) {
const [prompt, setPrompt] = React.useState('');
const [isGenerating, setIsGenerating] = React.useState(false);
const [streamContent, setStreamContent] = React.useState('');
const [errorMsg, setErrorMsg] = React.useState('');
// 维护中止控制器的引用以管理流生命周期
const abortControllerRef = React.useRef<AbortController | null>(null);
// 流式正文最新值镜像onDone 需读累计正文回传上层。用 ref 而非在 setStreamContent 更新函数里调父组件回调——
// 后者会在 AIPanel 渲染期触发 WorkspacePage setStateReact 警告 "Cannot update a component while rendering
// a different component"。ref 与 streamContent 在 onChunk 同步累计onDone 直接读 ref 即为最新值。
const streamContentRef = React.useRef('');
const loadCandidateContent = async (suggestionId: string): Promise<string | null> => {
const detail = await api.get<SuggestionDetail>(`/suggestions/${encodeURIComponent(suggestionId)}`);
return hasCandidateContent(detail.content) ? detail.content : null;
};
const handleStreamDone = async (data: { suggestionId?: string; summary?: string }) => {
setIsGenerating(false);
// 捕获后端 done 事件回传的候选 ID它是后续采纳写入 Canonical 的唯一合法入参。
const suggestionId = data?.suggestionId ?? null;
const streamText = streamContentRef.current;
try {
// 情况一:流已送来非空正文。单个大 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;
}
// 情况二:流为空——完整正文未经流送达(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 {
abortControllerRef.current = null;
}
};
// 1. 发起 AI 生成请求
const handleGenerate = async () => {
if (!prompt.trim()) return;
// 幂等处理:如果有正在生成中的流,先主动打断
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsGenerating(true);
setStreamContent('');
streamContentRef.current = '';
setErrorMsg('');
try {
const taskRequest: CreateAiTaskRequest = {
commandId: crypto.randomUUID(),
intent: {
kind: 'continuation',
userInstruction: prompt.trim(),
outputTarget: 'suggestion',
},
// 下传真实上下文引用:服务端以 workId 作为权限边界,并校验 block/chapter 归属该作品。
// 此前只传 commandId/intent/contextScope残缺上下文被宽松 mock 掩盖;这里补全以贴合真后端契约。
workId,
// blockId/chapterId 仅在有值时带键exactOptionalPropertyTypes 下不允许显式赋 undefined
...(blockId ? { blockId } : {}),
...(chapterId ? { chapterId } : {}),
// 槽位 key 缺省回退到续写槽位,确保后端总能解析出具体 Agent 版本。
agentSlotKey: agentSlotKey ?? DEFAULT_AGENT_SLOT_KEY,
contextScope: 'work',
};
const task = await api.post<CreateAiTaskResult>('/ai/tasks', taskRequest);
if (!task.taskId) {
throw new Error('AI 任务创建成功但缺少 taskId');
}
const controller = connectAIStream(`/app-api/muse/ai/tasks/${task.taskId}/stream`, {
onChunk: (data) => {
streamContentRef.current += data.content;
setStreamContent((prev) => prev + data.content);
},
onDone: (data) => {
void handleStreamDone(data);
},
onError: (err) => {
setIsGenerating(false);
setErrorMsg(err.message || '流式传输中断');
abortControllerRef.current = null;
},
});
abortControllerRef.current = controller;
} catch (err) {
setIsGenerating(false);
setErrorMsg(err instanceof Error ? err.message : 'AI 任务创建失败');
abortControllerRef.current = null;
}
};
// 2. 主动中止生成
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsGenerating(false);
}
};
// 3. 生命周期清理:当用户切换路由或关闭侧栏导致组件卸载时,强制打断 fetch stream 连接以免泄漏
React.useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return (
<div className="w-80 border-l border-gray-200 bg-gray-50 flex flex-col h-full z-10 shadow-sm">
{/* 标题头部 */}
<div className="px-4 py-4 border-b border-gray-200 bg-white flex items-center justify-between">
<h2 className="font-bold text-sm text-gray-700 flex items-center gap-1.5">
<span>🤖</span> AI
</h2>
{isGenerating && (
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-purple-500"></span>
</span>
)}
</div>
{/* 打字机流式输出显示区 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{streamContent || isGenerating ? (
<div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100 text-sm leading-relaxed text-gray-800 break-words whitespace-pre-wrap">
{streamContent}
{isGenerating && (
<span className="inline-block w-1.5 h-4 ml-0.5 bg-purple-500 animate-pulse align-middle" />
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center text-gray-400 p-4">
<span className="text-3xl mb-2">💡</span>
<p className="text-xs font-medium">AI </p>
</div>
)}
{/* 错误提示横幅 */}
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-700 text-xs px-3 py-2.5 rounded-xl">
<strong></strong>
{errorMsg}
</div>
)}
</div>
{/* 底部控制交互栏 */}
<div className="p-3 bg-white border-t border-gray-200 space-y-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="在此处输入创作描述,如:帮我描述主角探索星际遗迹时的震撼感觉..."
disabled={isGenerating}
rows={3}
className="w-full text-xs p-2.5 border border-gray-200 rounded-xl focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none resize-none transition-shadow disabled:bg-gray-50"
/>
<div className="flex gap-2">
{isGenerating ? (
<button
onClick={handleStop}
className="flex-1 bg-red-500 hover:bg-red-600 text-white text-xs py-2 rounded-xl font-semibold shadow-sm transition-all flex items-center justify-center gap-1"
>
🛑
</button>
) : (
<button
onClick={handleGenerate}
disabled={!prompt.trim()}
className="flex-1 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-100 disabled:text-gray-400 text-white text-xs py-2 rounded-xl font-semibold shadow-sm transition-all flex items-center justify-center gap-1"
>
</button>
)}
</div>
</div>
</div>
);
}