Files
writer-work-flow/apps/web/components/workbench/Workbench.tsx
Yaojia Wang 3dd7d2861f feat(frontend): 写作工作台补齐三项——内容感知书名 / 上下文速查抽屉 / genre采集
写作工作台重构剩余 3 项(多 agent 并行构建各自新文件,主线统一集成):

- WFW-5 内容感知书名(#6):buildManuscriptExcerpt 取正文首尾摘录(硬上界控 token);
  GeneratorRunner 加 manuscriptText,有 brief 字段时出现「用当前正文」按钮把摘录填入
  brief,让 book-title 等据正文反推。纯前端零后端;HITL——只填表单,生成仍需作者触发。
- WFW-6 上下文速查抽屉(#7):ContextDrawer 视口无关右侧 slide-over,设定库/大纲完整
  只读速查 + 伏笔/规则/文风摘要跳链;底栏「速查」按钮触发。加法式、CONTEXT_DRAWER_ENABLED
  一键回滚、旧侧栏/全宽页路由全保留。
- WFW-7 genre 采集 + 无大纲降级:项目无题材时首次写章前弹 GenrePicker 采集(临时并入
  本次 directive 不落库);chapters 为空时编辑器显式提示「尚无大纲,将按设定自由生成」。

各项 TDD:manuscriptExcerpt/useGenreGate/contextDrawer/useContextDrawer 共 32 新单测。
门禁全绿:vitest 629 / tsc / lint / build / coverage 95.43%。
2026-07-07 20:44:09 +02:00

688 lines
23 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 Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookMarked,
BookOpen,
ChevronDown,
ClipboardCheck,
FileText,
Library,
PanelLeft,
PanelRight,
PenLine,
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 { ContextDrawer, CONTEXT_DRAWER_ENABLED } from "./ContextDrawer";
import { GenrePicker } from "./GenrePicker";
import { GENRES } from "@/lib/wizard/wizard";
import { useGenreGate, toGenreDirective } from "@/lib/workbench/useGenreGate";
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-7首次写章的极轻 genre 采集门。项目无题材 → 写章前先点一个题材(仅临时并入本次指令,不落库)。
const genreGate = useGenreGate(project.genre ?? null);
const [genrePromptOpen, setGenrePromptOpen] = useState(false);
// WFW-6 上下文速查抽屉加法式、flag 灰度;与三张 AI 结果卡互不干扰。
const [contextOpen, setContextOpen] = useState(false);
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);
// 流式 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);
setRefineOpen(true);
};
// 续写前先 flush 自动保存,确保 continue 读到的是最新草稿with_prior_chapter 读库)。
const openContinue = (): void => {
autosave.flush();
setRefineOpen(false);
setToolboxOpen(false);
setContinueOpen(true);
};
const openToolbox = (): void => {
setRefineOpen(false);
setContinueOpen(false);
setToolboxOpen(true);
};
// 追加一段文本到章末并落草稿(续写候选 / 工具箱产物共用)。
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);
};
// 字数统计非空白字符(与中文读者直觉一致:标点/空格不计入正文体量)。
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 (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
projectId={project.id}
activeNav="write"
>
<div className="grid min-h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 xl:h-[calc(100vh-var(--chrome,4rem))] xl:grid-cols-[12rem_1fr_18rem]">
<ChapterList
projectId={project.id}
chapters={chapters}
currentChapterNo={chapterNo}
/>
<section className="flex min-w-0 flex-col bg-bg">
<MobileContextBar
chapterNo={chapterNo}
onOpenChapters={() => setMobilePanel("chapters")}
onOpenAssistant={() => setMobilePanel("assistant")}
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
{genrePromptOpen ? (
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={onPickGenre}
onDismiss={() => setGenrePromptOpen(false)}
/>
) : null}
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
/>
{refineOpen && canRefine && selection ? (
<RefinePanel
projectId={project.id}
chapterNo={chapterNo}
original={selection.text}
onAccept={onAcceptRefine}
onClose={() => setRefineOpen(false)}
/>
) : null}
{continueOpen ? (
<ContinuePanel
projectId={project.id}
chapterNo={chapterNo}
onInsert={onInsertContinuation}
onClose={() => setContinueOpen(false)}
/>
) : null}
{toolboxOpen ? (
<InlineToolbox
projectId={project.id}
manuscriptText={text}
onInsertText={(chunk) => {
appendToDraft(chunk);
setToolboxOpen(false);
}}
onClose={() => setToolboxOpen(false)}
/>
) : null}
<div 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}
onWrite={onWrite}
onStop={stream.stop}
canRefine={canRefine}
onRefineSelection={openRefine}
onContinue={openContinue}
onToolbox={openToolbox}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
/>
</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}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
</AppShell>
);
}
interface MobileContextBarProps {
chapterNo: number;
onOpenChapters: () => void;
onOpenAssistant: () => void;
chapterTriggerRef: RefObject<HTMLButtonElement | null>;
assistantTriggerRef: RefObject<HTMLButtonElement | null>;
}
function MobileContextBar({
chapterNo,
onOpenChapters,
onOpenAssistant,
chapterTriggerRef,
assistantTriggerRef,
}: MobileContextBarProps) {
return (
<div className="flex items-center justify-between gap-2 border-b border-line bg-panel px-4 py-2 xl:hidden">
<Button
ref={chapterTriggerRef}
onClick={onOpenChapters}
variant="secondary"
size="sm"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="min-w-0 truncate font-mono text-xs text-ink-soft">
{chapterNo}
</span>
<Button
ref={assistantTriggerRef}
onClick={onOpenAssistant}
variant="secondary"
size="sm"
>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
}
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 (
<details className="group border-b border-line bg-panel px-4 py-3 sm:px-6">
<summary
className="flex cursor-pointer list-none items-center justify-between gap-3 text-sm text-ink-soft hover:text-cinnabar"
aria-controls={panelId}
>
<span className="flex items-center gap-2 font-serif text-base text-ink">
<ChevronDown
className="h-4 w-4 text-ink-soft transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</span>
<span className="rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink-soft">
{activeCount > 0 ? `${activeCount} 项指令` : "可选"}
</span>
</summary>
<div id={panelId} className="mt-3 space-y-3">
<SectionHeader
title="AI 写作诉求"
description="选文风、点剧情需求、写自己的要求,只影响本次生成。"
/>
<PresetGroup
label="文风"
presets={STYLE_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<PresetGroup
label="剧情需求"
presets={PLOT_PRESETS}
presetIds={presetIds}
onToggle={onTogglePreset}
/>
<label className="block">
<span className="mb-1 block text-sm text-ink-soft"></span>
<TextArea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
/>
</label>
{activeCount > 0 ? (
<StatusNote variant="info">
/
</StatusNote>
) : null}
</div>
</details>
);
}
interface PresetGroupProps {
label: string;
presets: readonly Preset[];
presetIds: readonly string[];
onToggle: (id: string) => void;
}
// 一组预设 chips文风 / 剧情需求):点选 toggle选中高亮。id 统一进 presetIds。
function PresetGroup({ label, presets, presetIds, onToggle }: PresetGroupProps) {
return (
<div>
<p className="mb-1 text-sm text-ink-soft">{label}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={label}>
{presets.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<Button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onToggle(preset.id)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{preset.label}
</Button>
);
})}
</div>
</div>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,
}: {
streamError: { code: string; message: string };
}) {
const friendly = friendlyError(streamError.code, streamError.message);
return (
<p className="mb-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
// 流式里程碑播报句(完成/停止/失败);仅在 role=status 区出现,不逐 token 刷新。
liveMessage: string;
onWrite: () => void;
onStop: () => void;
// 选区级润色:有非空选区时可用;点击打开润色/再沟通结果卡。
canRefine: boolean;
onRefineSelection: () => void;
// 续写:读本章前文续写候选。
onContinue: () => void;
// 工具箱:编辑器内联调用生成器,结果回填正文。
onToolbox: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
liveMessage,
onWrite,
onStop,
canRefine,
onRefineSelection,
onContinue,
onToolbox,
onOpenContext,
contextTriggerRef,
}: 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">
{streamError ? <StreamErrorNote streamError={streamError} /> : null}
<div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{streaming ? (
<Button onClick={onStop} variant="danger" size="sm">
<Square className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={onWrite} variant="primary" size="sm">
<PenLine className="h-4 w-4" aria-hidden="true" />
</Button>
)}
{!streaming ? (
<>
<Button onClick={onContinue} variant="secondary" size="sm">
<WrapText className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={onRefineSelection}
disabled={!canRefine}
variant="secondary"
size="sm"
title={canRefine ? undefined : "先在正文里选中一段再润色"}
>
<Wand2 className="h-4 w-4" aria-hidden="true" />
</Button>
<Button onClick={onToolbox} variant="secondary" size="sm">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
</>
) : null}
{streaming ? (
// ThinkingIndicator 自带 role=status开始时播报一次「生成中」
// 易变字数 aria-hidden仅供视觉不逐 token 打扰屏读。
<span className="inline-flex items-center gap-2">
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
<span
aria-hidden="true"
className="font-mono text-xs text-cinnabar"
>
{wordCount}
</span>
</span>
) : (
<span className="font-mono text-xs text-ink-soft">
{wordCount}
</span>
)}
{/* 终结句播报区:完成/停止/失败时写入,屏读只播报里程碑。 */}
<span className="sr-only" role="status" aria-live="polite">
{liveMessage}
</span>
{CONTEXT_DRAWER_ENABLED ? (
<Button
ref={contextTriggerRef}
onClick={onOpenContext}
variant="secondary"
size="sm"
title="打开上下文速查(设定库/大纲/伏笔/规则/文风)"
>
<BookMarked className="h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<Link
href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<BookOpen className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href={`/projects/${projectId}/foreshadow`}
className={buttonClass({
variant: "secondary",
size: "sm",
className: "hidden sm:inline-flex",
})}
>
<Library className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
稿
</Link>
</div>
<span
role="status"
aria-live="polite"
className="min-w-0 font-mono text-xs text-ink-soft sm:ml-auto"
>
<FileText className="mr-1 inline h-3.5 w-3.5" aria-hidden="true" />
{saveStatus === "saving"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}