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

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { Check, MessagesSquare, RotateCcw, Square, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
@@ -12,6 +12,10 @@ import { friendlyError } from "@/lib/errors/messages";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import { useChapterRewrite } from "@/lib/workbench/useChapterRewrite";
import { useRewriteClarify } from "@/lib/workbench/useRewriteClarify";
import type {
AiTurnInput,
AiTurnMessage,
} from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:整章意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
@@ -24,6 +28,8 @@ interface ChapterRewritePanelProps {
currentDraft: string;
// 接受某一版 → 替换整章正文HITL作者显式点选才落草稿
onAccept: (text: string) => void;
// 留痕:每版重写记 [author(折后意见), ai(版本正文)],含缓冲的 clarify Q&A同 thread 迭代。
appendMessages: (turn: AiTurnInput) => void;
onClose: () => void;
}
@@ -34,12 +40,16 @@ export function ChapterRewritePanel({
chapterNo,
currentDraft,
onAccept,
appendMessages,
onClose,
}: ChapterRewritePanelProps) {
const rewrite = useChapterRewrite();
const clarify = useRewriteClarify();
const [feedback, setFeedback] = useState("");
const [pendingFeedback, setPendingFeedback] = useState<string | null>(null);
// 本面板一次会话 = 一个 thread多版迭代 + 澄清往复归此组,版本号本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
const hasVersions = rewrite.versions.length > 0;
const clarifying = clarify.status === "checking";
@@ -51,12 +61,34 @@ export function ChapterRewritePanel({
const priorDraft = rewrite.latest?.text ?? currentDraft;
const err = rewrite.state.phase === "error" ? rewrite.state.error : null;
// 折入澄清答案(或原意见)后流式重写,并清理澄清态
const runSend = (fb: string): void => {
// 折入澄清答案(或原意见)后流式重写,结束后留痕clarifyTurns 非空时把缓冲 Q&A 一并落库
const runSend = (fb: string, clarifyTurns: AiTurnMessage[] | null): void => {
clarify.reset();
setPendingFeedback(null);
setFeedback("");
void rewrite.send(projectId, chapterNo, fb, priorDraft);
void (async () => {
const v = await rewrite.send(projectId, chapterNo, fb, priorDraft);
if (!v) return; // 停止/失败/空版:不留痕。
versionNoRef.current += 1;
const versionNo = versionNoRef.current;
const aiRow: AiTurnMessage = {
role: "ai",
content: v.text,
meta: { version_no: versionNo },
};
// clarify 分支:作者答复 IS 本轮 author 输入;否则记 author(折后意见, meta{version_no})。
const messages = clarifyTurns
? [...clarifyTurns, aiRow]
: [
{
role: "author" as const,
content: fb,
meta: { version_no: versionNo },
},
aiRow,
];
appendMessages({ threadId, kind: "rewrite", chapterNo, messages });
})();
};
// 「重写整章 / 再改一版」:意见含糊/极短(或 force先预检反问否则直接流式重写。
@@ -70,14 +102,32 @@ export function ChapterRewritePanel({
return;
}
}
runSend(fb);
runSend(fb, null);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 流式重写。
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A原意见→反问→答复+ 折进意见 → 流式重写。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const q = clarify.decision?.questions[0];
const question = q?.question ?? "";
const base = pendingFeedback ?? feedback.trim();
runSend(foldClarifications(base, [{ question, answer }]));
const clarifyTurns: AiTurnMessage[] = [
{ role: "author", content: base },
{
role: "ai",
content: question,
meta: {
parent_kind: "rewrite",
options: (q?.options ?? []).map((o) => ({
label: o.label,
value: o.value,
})),
allow_free_text: q?.allowFreeText ?? false,
verification: clarify.decision?.verification ?? "",
},
},
{ role: "author", content: answer },
];
runSend(foldClarifications(base, [{ question, answer }]), clarifyTurns);
};
return (

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Check, Plus, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
@@ -8,12 +8,15 @@ import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { useContinue } from "@/lib/workbench/useContinue";
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
interface ContinuePanelProps {
projectId: string;
chapterNo: number;
// 插入选中候选到正文(追加到章末)。
onInsert: (text: string) => void;
// 留痕:每条候选记一条 ai 气泡(续写无作者输入,故无 author 气泡)。
appendMessages: (turn: AiTurnInput) => void;
onClose: () => void;
}
@@ -23,16 +26,37 @@ export function ContinuePanel({
projectId,
chapterNo,
onInsert,
appendMessages,
onClose,
}: ContinuePanelProps) {
const { status, candidates, generate } = useContinue();
const ranRef = useRef(false);
// 本面板一次会话 = 一个 thread多条候选归此组candidate_index 本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const candidateIdxRef = useRef(0);
// 生成一条候选并成功时留痕ai-onlymeta 存候选序号。
const runGenerate = useCallback(async (): Promise<void> => {
const text = await generate(projectId, chapterNo);
if (!text) return;
const candidateIndex = candidateIdxRef.current;
candidateIdxRef.current += 1;
appendMessages({
threadId,
kind: "continue",
toolKey: "continue",
chapterNo,
messages: [
{ role: "ai", content: text, meta: { candidate_index: candidateIndex } },
],
});
}, [generate, projectId, chapterNo, appendMessages, threadId]);
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void generate(projectId, chapterNo);
}, [projectId, chapterNo, generate]);
void runGenerate();
}, [runGenerate]);
const busy = status === "generating";
@@ -58,7 +82,7 @@ export function ContinuePanel({
<StatusNote variant="danger" className="mt-3 text-xs" title="续写失败">
<button
type="button"
onClick={() => void generate(projectId, chapterNo)}
onClick={() => void runGenerate()}
className="mt-1 inline-flex items-center gap-1 text-cinnabar hover:underline"
>
@@ -90,7 +114,7 @@ export function ContinuePanel({
<div className="mt-3">
<Button
onClick={() => void generate(projectId, chapterNo)}
onClick={() => void runGenerate()}
disabled={busy}
variant="secondary"
size="sm"

View File

@@ -11,11 +11,16 @@ import { GeneratorRunner } from "@/components/toolbox/GeneratorRunner";
import type { ToolDescriptorView } from "@/lib/api/types";
import { isLegacyTool } from "@/lib/toolbox/toolbox";
import { useToolboxTools } from "@/lib/toolbox/useToolboxTools";
import type { AiTurnInput } from "@/lib/workbench/useAiConversation";
interface InlineToolboxProps {
projectId: string;
// 当前章号内联工具箱生成留痕挂本章chapter_no=current
chapterNo: number;
// 把生成结果插入正文(追加到章末)。
onInsertText: (text: string) => void;
// 留痕:生成成功后 append 一批(透传给 GeneratorRunner
appendMessages: (turn: AiTurnInput) => void;
// 当前正文(编辑器 text。透传给 GeneratorRunner供 book-title 等据正文反推 brief。
manuscriptText?: string;
onClose: () => void;
@@ -26,7 +31,9 @@ interface InlineToolboxProps {
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
export function InlineToolbox({
projectId,
chapterNo,
onInsertText,
appendMessages,
manuscriptText,
onClose,
}: InlineToolboxProps) {
@@ -52,6 +59,8 @@ export function InlineToolbox({
projectId={projectId}
tool={active}
manuscriptText={manuscriptText}
appendMessages={appendMessages}
logChapterNo={chapterNo}
onClose={() => setActive(null)}
onInsertText={(text) => {
if (text.trim().length > 0) onInsertText(text);

View File

@@ -11,6 +11,10 @@ import { TextArea } from "@/components/ui/TextArea";
import { useRefine } from "@/lib/workbench/useRefine";
import { useClarify } from "@/lib/workbench/useClarify";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import type {
AiTurnInput,
AiTurnMessage,
} from "@/lib/workbench/useAiConversation";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
@@ -23,6 +27,8 @@ interface RefinePanelProps {
original: string;
// 接受回填:把选段替换为该产出(调用方按内容重锚落回草稿)。
onAccept: (refined: string) => void;
// 留痕:把本轮往复(含缓冲的 clarify Q&A作为一批 appendfail-soft不阻塞回炉
appendMessages: (turn: AiTurnInput) => void;
onClose: () => void;
}
@@ -33,6 +39,7 @@ export function RefinePanel({
chapterNo,
original,
onAccept,
appendMessages,
onClose,
}: RefinePanelProps) {
const { status, versions, latest, refine, recommunicate } = useRefine();
@@ -40,12 +47,36 @@ export function RefinePanel({
const [instruction, setInstruction] = useState("");
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
const ranRef = useRef(false);
// 本面板一次会话 = 一个 thread所有润色/再沟通/澄清往复归此组。版本号 repo 无关,本地累加。
const threadId = useRef(crypto.randomUUID()).current;
const versionNoRef = useRef(0);
// 打开即对选段回炉一次(仅一次,避免重复请求)。
// 一版 ai 产出气泡content=产出meta 存版本号 + 原选段(每版都存,供任意版 re-accept 重锚)。
const aiRow = (refined: string): AiTurnMessage => {
versionNoRef.current += 1;
return {
role: "ai",
content: refined,
meta: { version_no: versionNoRef.current, segment: original },
};
};
// 打开即对选段回炉一次(仅一次,避免重复请求);成功后留痕 [author(原选段), ai(润色)]。
useEffect(() => {
if (ranRef.current) return;
ranRef.current = true;
void refine(projectId, chapterNo, original);
void (async () => {
const v = await refine(projectId, chapterNo, original);
if (v) {
appendMessages({
threadId,
kind: "refine",
chapterNo,
messages: [{ role: "author", content: original }, aiRow(v.refined)],
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, chapterNo, original, refine]);
const busy = status === "refining";
@@ -53,12 +84,23 @@ export function RefinePanel({
const firstQuestion = clarify.decision?.questions[0] ?? null;
const asking = clarify.status === "asking" && firstQuestion !== null;
// 折入澄清答案(或原意见)后回炉,并清理澄清态
const runRecommunicate = (finalInstruction: string): void => {
// 折入澄清答案(或原意见)后回炉,结束后留痕clarifyTurns 非空时把缓冲的 Q&A 一并落库
const runRecommunicate = (
finalInstruction: string,
clarifyTurns: AiTurnMessage[] | null,
): void => {
clarify.reset();
setPendingInstruction(null);
setInstruction("");
void recommunicate(projectId, chapterNo, finalInstruction);
void (async () => {
const v = await recommunicate(projectId, chapterNo, finalInstruction);
if (!v) return;
// clarify 分支:作者答复 IS 本轮 author 输入,不再另加 author(意见) 气泡;否则记 author(意见)。
const messages = clarifyTurns
? [...clarifyTurns, aiRow(v.refined)]
: [{ role: "author" as const, content: finalInstruction }, aiRow(v.refined)];
appendMessages({ threadId, kind: "refine", chapterNo, messages });
})();
};
// 「再改一版」:意见含糊/极短(或 force先预检反问否则直接回炉。
@@ -73,14 +115,33 @@ export function RefinePanel({
return;
}
}
runRecommunicate(trimmed);
runRecommunicate(trimmed, null);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 回炉。
// 作者答复澄清(点选 value 或自由输入)→ 缓冲 Q&A原意见→反问→答复+ 折进意见 → 回炉。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const q = clarify.decision?.questions[0];
const question = q?.question ?? "";
const base = pendingInstruction ?? instruction.trim();
runRecommunicate(foldClarifications(base, [{ question, answer }]));
// 缓冲三条:原含糊意见(author) → AI 反问(ai, 选项进 meta) → 作者答复(author),随本轮产出一起落库。
const clarifyTurns: AiTurnMessage[] = [
{ role: "author", content: base },
{
role: "ai",
content: question,
meta: {
parent_kind: "refine",
options: (q?.options ?? []).map((o) => ({
label: o.label,
value: o.value,
})),
allow_free_text: q?.allowFreeText ?? false,
verification: clarify.decision?.verification ?? "",
},
},
{ role: "author", content: answer },
];
runRecommunicate(foldClarifications(base, [{ question, answer }]), clarifyTurns);
};
return (

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" })}