"use client"; import Link from "next/link"; import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { BookOpen, ChevronDown, ClipboardCheck, FileText, Library, PanelLeft, PanelRight, PenLine, Square, } from "lucide-react"; import { AppShell } from "@/components/AppShell"; import { Drawer } from "@/components/Drawer"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; 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, STYLE_PRESETS } from "@/lib/workbench/directive"; import { buttonClass } from "@/lib/ui/variants"; import { ChapterList, ChapterListContent } from "./ChapterList"; import { ChapterAssistant, AssistantContent } from "./ChapterAssistant"; import { Editor } from "./Editor"; 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([]); const autosave = useAutosave(project.id, chapterNo, initialText); const stream = useDraftStream(); const lastStreamText = useRef(""); const chapterTriggerRef = useRef(null); const assistantTriggerRef = 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]); const onWrite = (): void => { void stream.start( project.id, chapterNo, composeDirective(directive, presetIds), ); }; 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 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} />
{/* 窄屏 / 中屏(1024–1280):目录 / 本章助手经抽屉触达;≥xl 已在三栏栅格里直显。 */}
); } 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} 项指令` : "可选"}