"use client"; import Link from "next/link"; import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { BookMarked, BookOpen, ChevronDown, ClipboardCheck, FileText, Library, MessagesSquare, PanelLeft, PanelRight, PenLine, RefreshCw, Sparkles, Square, Wand2, WrapText, } from "lucide-react"; import { AppShell } from "@/components/AppShell"; import { Drawer } from "@/components/Drawer"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { useToast } from "@/components/Toast"; import { Button } from "@/components/ui/Button"; import { SectionHeader } from "@/components/ui/SectionHeader"; import { StatusNote } from "@/components/ui/StatusNote"; import { TextArea } from "@/components/ui/TextArea"; import type { ProjectResponse } from "@/lib/api/types"; import { friendlyError } from "@/lib/errors/messages"; import { useAutosave } from "@/lib/autosave/useAutosave"; import { useDraftStream } from "@/lib/stream/useDraftStream"; import { WORKBENCH_CHAPTER_NO, type ChapterEntry, } from "@/lib/workbench/chapter"; import { composeDirective, PLOT_PRESETS, STYLE_PRESETS, type Preset, } from "@/lib/workbench/directive"; import { applyRefinement } from "@/lib/workbench/refineApply"; import { buttonClass } from "@/lib/ui/variants"; import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { Editor, type EditorSelection } from "./Editor"; import { RefinePanel } from "./RefinePanel"; 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; // 当前章号(来自写作路由 `?chapter=N`);缺省回退第 1 章。 chapterNo?: number; // RSC 种入的已存草稿正文(GET .../draft);无草稿(404)→ ""(空编辑器)。 initialText?: string; // 大纲章节目录(GET .../outline);无大纲 → 空数组(目录回退到仅当前章 + 去大纲提示)。 chapters?: ChapterEntry[]; } // 移动端右栏(目录/助手)经抽屉触达;桌面用三栏栅格。 type MobilePanel = "chapters" | "assistant" | null; // M1:单章工作台。章节列表为占位(无章节列表端点);默认第 1 章。 // WORKBENCH_CHAPTER_NO 移到 lib/workbench/chapter.ts(非客户端模块),供 Server Component 安全导入。 export function Workbench({ project, chapterNo = WORKBENCH_CHAPTER_NO, initialText = "", chapters = [], }: WorkbenchProps) { // 用已存草稿作初值;流式开始后由下方 effect 覆盖(流不会被初值反扑—— // stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。 const [text, setText] = useState(initialText); const [mobilePanel, setMobilePanel] = useState(null); // 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。 const [directive, setDirective] = useState(""); const [presetIds, setPresetIds] = useState([]); // 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。 const [selection, setSelection] = useState(null); const [refineOpen, setRefineOpen] = useState(false); const [continueOpen, setContinueOpen] = useState(false); const [toolboxOpen, setToolboxOpen] = useState(false); // WFW-8:整章「再沟通/重写」结果卡开合(意见 → 流式重写一版 → 版本栈 → 接受替换整章)。 const [rewriteOpen, setRewriteOpen] = useState(false); // WFW-7:首次写章的极轻 genre 采集门。项目无题材 → 写章前先点一个题材(仅临时并入本次指令,不落库)。 const genreGate = useGenreGate(project.genre ?? null); 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(null); const toast = useToast(); const autosave = useAutosave(project.id, chapterNo, initialText); const stream = useDraftStream(); const lastStreamText = useRef(""); const canRefine = selection !== null && selection.text.trim().length > 0; const chapterTriggerRef = useRef(null); const assistantTriggerRef = useRef(null); const contextTriggerRef = useRef(null); // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 useEffect(() => { const streamed = stream.state.text; if ( (stream.state.phase === "streaming" || stream.state.phase === "done" || stream.state.phase === "aborted") && streamed !== lastStreamText.current ) { lastStreamText.current = streamed; setText(streamed); autosave.onChange(streamed); } }, [stream.state.phase, stream.state.text, autosave]); // 题材 + 三槽指令合并为一条写章指令(题材前置);effectiveGenre 无 → 仅原指令。 const startWrite = (effectiveGenre: string | null): void => { const base = composeDirective(directive, presetIds); const merged = [toGenreDirective(effectiveGenre), base] .filter((part) => part.length > 0) .join(";"); void stream.start(project.id, chapterNo, merged); }; const onWrite = (): void => { // 无题材且未点选 → 先弹采集卡,选后再写(HITL:不静默生成无题材 slop)。 if (genreGate.needsGenre && genreGate.chosenGenre === null) { setGenrePromptOpen(true); return; } startWrite(genreGate.effectiveGenre); }; // 采集卡点题材:记录本次题材并立刻开写。用 genre 直传绕开 setState 异步。 const onPickGenre = (genre: string): void => { genreGate.setChosenGenre(genre); setGenrePromptOpen(false); startWrite(genre); }; const togglePreset = (id: string): void => { setPresetIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], ); }; const onEditorChange = (value: string): void => { setText(value); autosave.onChange(value); }; // 接受润色/再沟通产出:按内容重锚把选段替换为产出并落草稿;原文已变则提示重选(不盲替换)。 const onAcceptRefine = (refined: string): void => { if (selection) { const next = applyRefinement(text, selection.text, refined, { start: selection.start, end: selection.end, }); if (next === null) { toast("正文已改动,找不到原选段,请重新选择后再润色。", "error"); } else { setText(next); autosave.onChange(next); } } setRefineOpen(false); setSelection(null); }; // 四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。 const openRefine = (): void => { setContinueOpen(false); setToolboxOpen(false); setRewriteOpen(false); setRefineOpen(true); }; // 续写前先 flush 自动保存,确保 continue 读到的是最新草稿(with_prior_chapter 读库)。 const openContinue = (): void => { autosave.flush(); setRefineOpen(false); setToolboxOpen(false); setRewriteOpen(false); setContinueOpen(true); }; const openToolbox = (): void => { setRefineOpen(false); setContinueOpen(false); setRewriteOpen(false); setToolboxOpen(true); }; const openRewrite = (): void => { setRefineOpen(false); setContinueOpen(false); setToolboxOpen(false); setRewriteOpen(true); }; // 接受某一版整章重写 → 替换整章正文并落草稿(HITL,作者点选才落)。 const onAcceptRewrite = (next: string): void => { setText(next); autosave.onChange(next); setRewriteOpen(false); }; // 追加一段文本到章末并落草稿(续写候选 / 工具箱产物共用)。 const appendToDraft = (chunk: string): void => { const base = text.trimEnd(); const next = base.length > 0 ? `${base}\n\n${chunk}` : chunk; setText(next); autosave.onChange(next); }; const onInsertContinuation = (candidate: string): void => { appendToDraft(candidate); 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); // 流式里程碑播报(仅终结句,非逐 token):完成/停止/失败时写入 role=status 区。 const liveMessage = ((): string => { switch (stream.state.phase) { case "done": return `本章生成完成,共 ${wordCount} 字`; case "aborted": return "已停止"; case "error": return stream.state.error ? friendlyError(stream.state.error.code, stream.state.error.message) .text : "本章生成失败,请稍后重试。"; default: return ""; } })(); return (
setMobilePanel("chapters")} onOpenAssistant={() => setMobilePanel("assistant")} chapterTriggerRef={chapterTriggerRef} assistantTriggerRef={assistantTriggerRef} /> {genrePromptOpen ? ( setGenrePromptOpen(false)} /> ) : null} {refineOpen && canRefine && selection ? ( setRefineOpen(false)} /> ) : null} {continueOpen ? ( setContinueOpen(false)} /> ) : null} {toolboxOpen ? ( { appendToDraft(chunk); setToolboxOpen(false); }} onClose={() => setToolboxOpen(false)} /> ) : null} {rewriteOpen ? ( setRewriteOpen(false)} /> ) : null}
{chapters.length === 0 ? ( 尚无大纲,将按设定自由生成。 ) : null}
setContextOpen(true)} contextTriggerRef={contextTriggerRef} onOpenConversation={() => setConversationOpen(true)} conversationTriggerRef={conversationTriggerRef} />
{/* 窄屏 / 中屏(1024–1280):目录 / 本章助手经抽屉触达;≥xl 已在三栏栅格里直显。 */} {/* WFW-6 上下文速查:视口无关的右侧 slide-over;旧侧栏/全宽页路由保留、可回滚。 */} setContextOpen(false)} triggerRef={contextTriggerRef} /> {/* AC-3 AI 对话:右侧 slide-over 气泡视图;从历史再接受只回填草稿,不绕验收事务。flag 灰度。 */} {AI_CONVERSATION_ENABLED ? ( setConversationOpen(false)} conversation={conversation} currentChapterNo={chapterNo} onReplaceChapter={onReplaceChapterFromHistory} onAppendDraft={onAppendDraftFromHistory} onRefillSegment={onRefillSegmentFromHistory} triggerRef={conversationTriggerRef} /> ) : null}
); } interface MobileContextBarProps { chapterNo: number; onOpenChapters: () => void; onOpenAssistant: () => void; chapterTriggerRef: RefObject; assistantTriggerRef: RefObject; } function MobileContextBar({ chapterNo, onOpenChapters, onOpenAssistant, chapterTriggerRef, assistantTriggerRef, }: MobileContextBarProps) { return (
第 {chapterNo} 章
); } interface DirectivePanelProps { directive: string; onDirectiveChange: (value: string) => void; presetIds: readonly string[]; onTogglePreset: (id: string) => void; } // 本章指令面板(T4-b):可折叠的 textarea + 风格快捷预设 chips。 // 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。 function DirectivePanel({ directive, onDirectiveChange, presetIds, onTogglePreset, }: DirectivePanelProps) { const panelId = useId(); const activeCount = presetIds.length + (directive.trim().length > 0 ? 1 : 0); return (
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}