Files
writer-work-flow/apps/web/components/workbench/Workbench.tsx
Yaojia Wang b810a3fa3c feat(foreshadow): 写作台本章伏笔主动提醒(前端)
- 新增 chapterForeshadow 纯分类:按当前章分 可回收/待回收/已逾期(启用闲置的 isWindowApproaching)
- useChapterForeshadow hook(复用 GET /foreshadow,切章仅重分类不重拉)
- 右栏「本章参考」+ ContextDrawer 伏笔页新增「本章伏笔」实时清单,
  分色展示 + 跳看板链接 + 空态;标注『按已验收进度』
2026-07-12 17:53:00 +02:00

469 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { Drawer } from "@/components/Drawer";
import { useToast } from "@/components/Toast";
import { StatusNote } from "@/components/ui/StatusNote";
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 } from "@/lib/workbench/directive";
import { applyRefinement } from "@/lib/workbench/refineApply";
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 } 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";
import { useChapterListCollapse } from "@/lib/workbench/useChapterListCollapse";
import { useFocusMode } from "@/lib/workbench/useFocusMode";
import { DirectivePanel, AiVerbsRow } from "./AiToolbar";
import { MobileContextBar } from "./MobileContextBar";
import { Toolbar } from "./WorkbenchToolbar";
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<MobilePanel>(null);
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]);
// 选区级 AI 动作(润色/再沟通):编辑器上报的最新选区 + 结果卡开合。
const [selection, setSelection] = useState<EditorSelection | null>(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 / P1-5首次写章的极轻 genre 采集门。项目无题材 → 正文上方常驻一条内联「题材」选择条
// (非弹窗),作者点一个题材再写;仅临时并入本次指令、不落库,避免无题材 slop且第一次发送不被打断。
const genreGate = useGenreGate(project.genre ?? null);
// 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);
// P2-2桌面「目录」列可收起记忆状态收起时把宽让给正文窄轨留一个展开入口。
const chapterList = useChapterListCollapse();
// P3-4专注写作开关记忆状态——开启隐藏左侧图标导航 + 目录 TOC让宽给正文。
const focusMode = useFocusMode();
const conversationTriggerRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null);
// P3-7 流式跟随:正文滚动容器引用(纯展示层,不触碰 SSE/流式数据)。
const editorScrollRef = useRef<HTMLDivElement>(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]);
// P3-7 流式跟随:流式且正文增长时把滚动容器贴到底,让新生成的段落保持可见。
// 纯展示层:只读既有 text/streaming 展示态,瞬时定位(无动画,天然尊重 prefers-reduced-motion
useEffect(() => {
if (!stream.isStreaming) return;
const el = editorScrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [text, stream.isStreaming]);
// 题材 + 三槽指令合并为一条写章指令题材前置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) {
toast("先在上方选个题材AI 写得更贴合、少套话。", "info");
return;
}
startWrite(genreGate.effectiveGenre);
};
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 结果卡(润色/续写/工具箱/整章重写)互斥,避免同时占位。
// 润色以选区为对象无选区时不静默失败给出可见提示引导先选段P0-4替代 hover-only title
const openRefine = (): void => {
if (!canRefine) {
toast("先在正文里选中一段再润色", "info");
return;
}
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=成功则抽屉自关,露出改动后的编辑器 + 成功 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);
// 桌面栅格列宽:专注模式撤掉目录 TOC 列(仅正文 + 右栏);否则按目录列收起/展开取宽。
const gridCols = focusMode.focus
? "xl:grid-cols-[1fr_18rem]"
: chapterList.collapsed
? "xl:grid-cols-[2.75rem_1fr_18rem]"
: "xl:grid-cols-[12rem_1fr_18rem]";
// 流式里程碑播报(仅终结句,非逐 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 (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
projectId={project.id}
activeNav="write"
hideNav={focusMode.focus}
>
<div
className={`grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] ${gridCols}`}
>
{focusMode.focus ? null : (
<ChapterList
projectId={project.id}
chapters={chapters}
currentChapterNo={chapterNo}
collapsed={chapterList.collapsed}
onToggle={chapterList.toggle}
/>
)}
<section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar
chapterNo={chapterNo}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
{genreGate.needsGenre ? (
// 项目无题材正文上方常驻内联题材条非弹窗发送前选好即顺滑写章P1-5
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={genreGate.setChosenGenre}
/>
) : null}
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
defaultOpen={initialText.trim().length === 0}
streaming={stream.isStreaming}
onWrite={onWrite}
onStop={stream.stop}
/>
<AiVerbsRow
streaming={stream.isStreaming}
onContinue={openContinue}
onRefineSelection={openRefine}
onRewrite={openRewrite}
onToolbox={openToolbox}
/>
{refineOpen && canRefine && selection ? (
<RefinePanel
projectId={project.id}
chapterNo={chapterNo}
original={selection.text}
onAccept={onAcceptRefine}
onReaccept={onRefillSegmentFromHistory}
conversation={conversation}
onClose={() => setRefineOpen(false)}
/>
) : null}
{continueOpen ? (
<ContinuePanel
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);
}}
onClose={() => setToolboxOpen(false)}
/>
) : null}
{rewriteOpen ? (
<ChapterRewritePanel
projectId={project.id}
chapterNo={chapterNo}
currentDraft={text}
onAccept={onAcceptRewrite}
conversation={conversation}
onClose={() => setRewriteOpen(false)}
/>
) : null}
<div ref={editorScrollRef} className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4">
</StatusNote>
) : null}
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
onSelectionChange={setSelection}
/>
</div>
<Toolbar
projectId={project.id}
chapterNo={chapterNo}
wordCount={wordCount}
savedLabel={autosave.savedLabel}
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
liveMessage={liveMessage}
onStop={stream.stop}
focus={focusMode.focus}
onToggleFocus={focusMode.toggle}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
onOpenConversation={() => setConversationOpen(true)}
conversationTriggerRef={conversationTriggerRef}
/>
</section>
<ChapterAssistant projectId={project.id} chapterNo={chapterNo} />
</div>
{/* 窄屏 / 中屏(10241280):目录 / 本章助手经抽屉触达≥xl 已在三栏栅格里直显。 */}
<Drawer
open={mobilePanel === "chapters"}
onClose={closePanel}
side="left"
label="目录"
triggerRef={chapterTriggerRef}
>
<ChapterListContent
projectId={project.id}
chapters={chapters}
currentChapterNo={chapterNo}
/>
</Drawer>
<Drawer
open={mobilePanel === "assistant"}
onClose={closePanel}
side="right"
label="本章参考"
triggerRef={assistantTriggerRef}
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer>
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
chapterNo={chapterNo}
open={contextOpen}
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>
);
}