分支①第三步(纯前端,无需改后端):采纳恒以 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>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
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 setState(React 警告 "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>
|
||
);
|
||
}
|