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:
@@ -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=成功则抽屉自关,露出改动后的编辑器 + 成功 toast;refine 找不到原选段返回 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" })}
|
||||
|
||||
Reference in New Issue
Block a user