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%。
This commit is contained in:
Yaojia Wang
2026-07-07 20:44:09 +02:00
parent 351fbc99e8
commit 3dd7d2861f
13 changed files with 1235 additions and 5 deletions

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
BookMarked,
BookOpen,
ChevronDown,
ClipboardCheck,
@@ -47,6 +48,10 @@ 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;
@@ -82,6 +87,11 @@ export function Workbench({
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();
@@ -89,6 +99,7 @@ export function Workbench({
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(() => {
@@ -105,12 +116,29 @@ export function Workbench({
}
}, [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 => {
void stream.start(
project.id,
chapterNo,
composeDirective(directive, presetIds),
);
// 无题材且未点选 → 先弹采集卡选后再写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 => {
@@ -219,6 +247,14 @@ export function Workbench({
chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef}
/>
{genrePromptOpen ? (
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={onPickGenre}
onDismiss={() => setGenrePromptOpen(false)}
/>
) : null}
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
@@ -245,6 +281,7 @@ export function Workbench({
{toolboxOpen ? (
<InlineToolbox
projectId={project.id}
manuscriptText={text}
onInsertText={(chunk) => {
appendToDraft(chunk);
setToolboxOpen(false);
@@ -253,6 +290,11 @@ export function Workbench({
/>
) : 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}
@@ -275,6 +317,8 @@ export function Workbench({
onRefineSelection={openRefine}
onContinue={openContinue}
onToolbox={openToolbox}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
/>
</section>
@@ -304,6 +348,14 @@ export function Workbench({
>
<AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer>
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
</AppShell>
);
}
@@ -497,6 +549,9 @@ interface ToolbarProps {
onContinue: () => void;
// 工具箱:编辑器内联调用生成器,结果回填正文。
onToolbox: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
}
function Toolbar({
@@ -514,6 +569,8 @@ function Toolbar({
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">
@@ -574,6 +631,18 @@ function Toolbar({
<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" })}