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:
@@ -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" })}
|
||||
|
||||
Reference in New Issue
Block a user