feat(frontend): 五类交互接入 AI 对话留痕 + Workbench 抽屉入口(AC-3)

- RefinePanel/ChapterRewritePanel:结束后 append [author(意见/答复), ai(产出)];
  clarify buffer-until-concluded——澄清 Q&A(原意见→反问→答复)缓冲,随最终产出批
  一起落库同 thread;纯放弃不记。refine ai 行 meta 存 version_no+原选段(每版重锚)。
- ContinuePanel:ai-only(续写无作者输入),每候选一条 ai 气泡 meta{candidate_index}。
- GeneratorRunner:成功后 append [author(字段摘要, meta{tool_key,input_fields}),
  ai(渲染预览, meta{tool_key,output_kind})];空产物不记。lib/toolbox/aiSummary.ts 纯助手+单测。
- InlineToolbox→GeneratorRunner 透传 append+本章号;ToolboxPage 用 silent 直投项目级(chapter_no=null)。
- Workbench:挂 useAiConversation 单实例 + AiConversationDrawer + 底栏「AI 对话」入口(flag 灰度),
  三个 DRAFT-only 再接受回调(替换整章/插入正文/回填选段),成功关抽屉+toast、漂移不盲替换。
This commit is contained in:
Yaojia Wang
2026-07-09 17:27:14 +02:00
parent 59f2f60a7e
commit 33a9b7656c
10 changed files with 379 additions and 28 deletions

View File

@@ -9,6 +9,7 @@ import {
ClipboardCheck,
FileText,
Library,
MessagesSquare,
PanelLeft,
PanelRight,
PenLine,
@@ -51,9 +52,14 @@ import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox";
import { ChapterRewritePanel } from "./ChapterRewritePanel";
import { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import {
AiConversationDrawer,
AI_CONVERSATION_ENABLED,
} from "./AiConversationDrawer";
import { GenrePicker } from "./GenrePicker";
import { GENRES } from "@/lib/wizard/wizard";
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
import { useAiConversation } from "@/lib/workbench/useAiConversation";
interface WorkbenchProps {
project: ProjectResponse;
@@ -96,6 +102,10 @@ export function Workbench({
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
// WFW-6 上下文速查抽屉加法式、flag 灰度;与三张 AI 结果卡互不干扰。
const [contextOpen, setContextOpen] = useState(false);
// AC-3 AI 对话抽屉Workbench 级单实例append 即使抽屉关着也能触发flag 灰度。
const [conversationOpen, setConversationOpen] = useState(false);
const conversation = useAiConversation(project.id, chapterNo);
const conversationTriggerRef = useRef<HTMLButtonElement>(null);
const toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream();
@@ -225,6 +235,37 @@ export function Workbench({
setContinueOpen(false);
};
// 从「AI 对话」历史再接受(复用 DRAFT-only HITL只回填编辑器草稿不绕开验收事务——不变量 #3
// 返回 true=成功则抽屉自关,露出改动后的编辑器 + 成功 toastrefine 找不到原选段返回 false + 漂移提示。
const onReplaceChapterFromHistory = (content: string): boolean => {
setText(content);
autosave.onChange(content);
toast("已替换整章。", "success");
return true;
};
const onAppendDraftFromHistory = (content: string): boolean => {
appendToDraft(content);
toast("已插入正文。", "success");
return true;
};
const onRefillSegmentFromHistory = (
segment: string,
refined: string,
): boolean => {
// 合成零区间强制 slice 校验失败 → 走 indexOf 按存档的原选段重锚;找不到则不盲替换。
const next = applyRefinement(text, segment, refined, { start: 0, end: 0 });
if (next === null) {
toast("正文已改动,找不到原选段,无法回填。", "error");
return false;
}
setText(next);
autosave.onChange(next);
toast("已回填选段。", "success");
return true;
};
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
const wordCount = text.replace(/\s+/g, "").length;
const closePanel = (): void => setMobilePanel(null);
@@ -288,6 +329,7 @@ export function Workbench({
chapterNo={chapterNo}
original={selection.text}
onAccept={onAcceptRefine}
appendMessages={conversation.appendMessages}
onClose={() => setRefineOpen(false)}
/>
) : null}
@@ -296,13 +338,16 @@ export function Workbench({
projectId={project.id}
chapterNo={chapterNo}
onInsert={onInsertContinuation}
appendMessages={conversation.appendMessages}
onClose={() => setContinueOpen(false)}
/>
) : null}
{toolboxOpen ? (
<InlineToolbox
projectId={project.id}
chapterNo={chapterNo}
manuscriptText={text}
appendMessages={conversation.appendMessages}
onInsertText={(chunk) => {
appendToDraft(chunk);
setToolboxOpen(false);
@@ -316,6 +361,7 @@ export function Workbench({
chapterNo={chapterNo}
currentDraft={text}
onAccept={onAcceptRewrite}
appendMessages={conversation.appendMessages}
onClose={() => setRewriteOpen(false)}
/>
) : null}
@@ -350,6 +396,8 @@ export function Workbench({
onRewrite={openRewrite}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)}
conversationTriggerRef={conversationTriggerRef}
/>
</section>
@@ -387,6 +435,20 @@ export function Workbench({
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
{/* AC-3 AI 对话:右侧 slide-over 气泡视图从历史再接受只回填草稿不绕验收事务。flag 灰度。 */}
{AI_CONVERSATION_ENABLED ? (
<AiConversationDrawer
open={conversationOpen}
onClose={() => setConversationOpen(false)}
conversation={conversation}
currentChapterNo={chapterNo}
onReplaceChapter={onReplaceChapterFromHistory}
onAppendDraft={onAppendDraftFromHistory}
onRefillSegment={onRefillSegmentFromHistory}
triggerRef={conversationTriggerRef}
/>
) : null}
</AppShell>
);
}
@@ -585,6 +647,9 @@ interface ToolbarProps {
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
// 打开 AI 对话抽屉AC-3
onOpenConversation: () => void;
conversationTriggerRef: RefObject<HTMLButtonElement | null>;
}
function Toolbar({
@@ -605,6 +670,8 @@ function Toolbar({
onRewrite,
onOpenContext,
contextTriggerRef,
onOpenConversation,
conversationTriggerRef,
}: ToolbarProps) {
return (
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
@@ -681,6 +748,18 @@ function Toolbar({
</Button>
) : null}
{AI_CONVERSATION_ENABLED ? (
<Button
ref={conversationTriggerRef}
onClick={onOpenConversation}
variant="secondary"
size="sm"
title="打开 AI 对话(润色/重写/续写/工具箱往复留痕)"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
AI
</Button>
) : null}
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })}