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

@@ -23,6 +23,7 @@ import {
type PreviewItem, type PreviewItem,
} from "@/lib/toolbox/toolbox"; } from "@/lib/toolbox/toolbox";
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest"; import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
import { useGenerator } from "@/lib/toolbox/useGenerator"; import { useGenerator } from "@/lib/toolbox/useGenerator";
import { TemplateFiller } from "./TemplateFiller"; import { TemplateFiller } from "./TemplateFiller";
@@ -32,6 +33,9 @@ interface GeneratorRunnerProps {
onClose: () => void; onClose: () => void;
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。 // 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
onInsertText?: (text: string) => void; onInsertText?: (text: string) => void;
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
// 点击把正文摘录填进 brief让内容感知型生成器如书名据正文反推。工具箱页不传 → 不显示。
manuscriptText?: string;
} }
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate → // 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
@@ -42,6 +46,7 @@ export function GeneratorRunner({
tool, tool,
onClose, onClose,
onInsertText, onInsertText,
manuscriptText,
}: GeneratorRunnerProps) { }: GeneratorRunnerProps) {
const gen = useGenerator(); const gen = useGenerator();
const toast = useToast(); const toast = useToast();
@@ -67,6 +72,26 @@ export function GeneratorRunner({
[tool.input_fields, fillTarget], [tool.input_fields, fillTarget],
); );
// 当前工具是否有名为 brief 的输入字段——只有它才承接「据正文反推」。
const hasBriefField = useMemo(
() => (tool.input_fields ?? []).some((f) => f.name === "brief"),
[tool.input_fields],
);
// 有 brief 字段且拿到非空正文时,才提供「用当前正文」。
const canUseManuscript =
hasBriefField && (manuscriptText?.trim().length ?? 0) > 0;
const onUseManuscript = useCallback((): void => {
if (!manuscriptText) return;
const excerpt = buildManuscriptExcerpt(manuscriptText);
if (excerpt.length === 0) {
toast("当前还没有正文可供反推。", "error");
return;
}
setField("brief", excerpt);
toast("已按当前正文填入,可继续编辑后生成。", "success");
}, [manuscriptText, setField, toast]);
const onGenerate = useCallback(async (): Promise<void> => { const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values); const missing = missingRequiredFields(tool.input_fields, values);
if (missing.length > 0) { if (missing.length > 0) {
@@ -166,6 +191,22 @@ export function GeneratorRunner({
targetLabel={fillLabel} targetLabel={fillLabel}
onFill={setField} onFill={setField}
/> />
{canUseManuscript ? (
<div className="flex items-center justify-between gap-2 rounded border border-line bg-bg/50 p-2">
<span className="text-xs text-ink-soft">
</span>
<Button
type="button"
onClick={onUseManuscript}
variant="outline"
size="sm"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
) : null}
{(tool.input_fields ?? []).map((field) => ( {(tool.input_fields ?? []).map((field) => (
<FormField <FormField
key={field.name} key={field.name}

View File

@@ -0,0 +1,336 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState, type RefObject } from "react";
import { ArrowUpRight, BookMarked, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { badgeClass, cn, overlayScrim } from "@/lib/ui/variants";
import {
CONTEXT_TABS,
contextTabHref,
type CodexGroup,
type ContextTabKey,
type OutlineRow,
} from "@/lib/workbench/contextDrawer";
import {
useContextDrawerData,
type ContextResource,
} from "@/lib/workbench/useContextDrawer";
// 速查抽屉总开关:一键关闭即回滚(触发按钮据此隐藏,旧侧栏/全宽页路由不受影响)。
export const CONTEXT_DRAWER_ENABLED = true;
interface ContextDrawerProps {
projectId: string;
open: boolean;
onClose: () => void;
// 关闭时把焦点还给触发按钮a11y
triggerRef?: RefObject<HTMLElement | null>;
}
// 从编辑器右侧滑出的【只读】上下文速查抽屉:不离开写作界面即可查设定库/大纲/伏笔/规则/文风。
// 视口无关(不同于 components/Drawer 的 lg:hidden 移动端抽屉),全只读、绝不写库。
// a11y 复用命令面板/Drawer 范式role=dialog + aria-modal + Esc 关闭 + focus trap + body scroll lock + 还原焦点。
export function ContextDrawer({
projectId,
open,
onClose,
triggerRef,
}: ContextDrawerProps) {
const [activeTab, setActiveTab] = useState<ContextTabKey>("codex");
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
const data = useContextDrawerData(projectId, activeTab, open);
useBodyScrollLock(open);
useEffect(() => {
if (!open) {
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef?.current?.focus();
}
return;
}
wasOpenRef.current = true;
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, triggerRef]);
if (!open) return null;
return (
<div className={overlayScrim} onClick={onClose}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="上下文速查"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (panelRef.current) handleTabTrap(panelRef.current, e);
}}
className="absolute right-0 top-0 flex h-full w-96 max-w-[90vw] flex-col border-l border-line bg-panel shadow-paper outline-none"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<span className="flex items-center gap-2 font-serif text-base text-ink">
<BookMarked className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</span>
<Button
ref={closeRef}
onClick={onClose}
aria-label="关闭上下文速查"
variant="ghost"
size="icon"
>
<X className="h-5 w-5" aria-hidden="true" />
</Button>
</header>
<TabBar activeTab={activeTab} onSelect={setActiveTab} />
<div className="flex-1 overflow-auto overscroll-contain px-4 py-4">
<TabPanel projectId={projectId} activeTab={activeTab} data={data} />
</div>
</div>
</div>
);
}
interface TabBarProps {
activeTab: ContextTabKey;
onSelect: (key: ContextTabKey) => void;
}
// Tab 选择条role=tablist设定库/大纲做完整速查,伏笔/规则/文风为摘要+跳链。
function TabBar({ activeTab, onSelect }: TabBarProps) {
return (
<div
role="tablist"
aria-label="上下文分类"
className="flex gap-1 overflow-x-auto border-b border-line px-2 py-2"
>
{CONTEXT_TABS.map((tab) => {
const active = tab.key === activeTab;
return (
<button
key={tab.key}
role="tab"
type="button"
aria-selected={active}
onClick={() => onSelect(tab.key)}
className={cn(
"shrink-0 rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
active
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink-soft hover:text-cinnabar",
)}
>
{tab.label}
</button>
);
})}
</div>
);
}
interface TabPanelProps {
projectId: string;
activeTab: ContextTabKey;
data: ReturnType<typeof useContextDrawerData>;
}
// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。
function TabPanel({ projectId, activeTab, data }: TabPanelProps) {
const href = contextTabHref(projectId, activeTab);
return (
<div role="tabpanel" className="space-y-3">
{activeTab === "codex" ? (
<CodexPanel resource={data.codex} onRetry={data.reloadCodex} />
) : null}
{activeTab === "outline" ? (
<OutlinePanel resource={data.outline} onRetry={data.reloadOutline} />
) : null}
{activeTab === "foreshadow" ? (
<PlaceholderPanel summary="伏笔账本:埋设/回收窗口与逾期告警在完整看板逐条管理。" />
) : null}
{activeTab === "rules" ? (
<PlaceholderPanel summary="世界硬规则:分级约束正文一致性,在规则页增删改。" />
) : null}
{activeTab === "style" ? (
<PlaceholderPanel summary="文风指纹16 维文风画像,在文风页查看维度与证据。" />
) : null}
<FullPageLink href={href} />
</div>
);
}
// 加载中骨架(统一给设定库/大纲复用)。
function LoadingNote() {
return (
<div className="py-6 text-center">
<ThinkingIndicator label="加载中" className="text-sm text-ink-soft" />
</div>
);
}
interface ErrorNoteProps {
message: string;
onRetry: () => void;
}
function ErrorNote({ message, onRetry }: ErrorNoteProps) {
return (
<StatusNote variant="danger" title="加载失败">
<p>{message}</p>
<Button onClick={onRetry} variant="secondary" size="sm" className="mt-2">
</Button>
</StatusNote>
);
}
function EmptyNote({ text }: { text: string }) {
return (
<StatusNote variant="info">
<p>{text}</p>
</StatusNote>
);
}
interface CodexPanelProps {
resource: ContextResource<CodexGroup[]>;
onRetry: () => void;
}
// 设定库速查:按类型分组的实体名单(含硬规则条目),完整只读列表。
function CodexPanel({ resource, onRetry }: CodexPanelProps) {
if (resource.status === "loading" || resource.status === "idle") {
return <LoadingNote />;
}
if (resource.status === "error") {
return <ErrorNote message="设定库暂不可用。" onRetry={onRetry} />;
}
if (resource.data.length === 0) {
return <EmptyNote text="还没有世界观实体,去设定库添加后回来速查。" />;
}
return (
<div className="space-y-4">
{resource.data.map((group) => (
<section key={group.type}>
<h3 className="mb-1.5 flex items-center gap-2 text-sm font-medium text-ink">
{group.type}
<span className={badgeClass({ variant: "neutral" })}>
{group.entities.length}
</span>
</h3>
<ul className="space-y-1.5">
{group.entities.map((entity) => (
<li
key={`${group.type}-${entity.name}`}
className="rounded border border-line bg-bg px-3 py-2"
>
<p className="text-sm text-ink">{entity.name}</p>
{entity.rules && entity.rules.length > 0 ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-xs text-ink-soft">
{entity.rules.map((rule, i) => (
<li key={i}>{rule}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
</section>
))}
</div>
);
}
interface OutlinePanelProps {
resource: ContextResource<OutlineRow[]>;
onRetry: () => void;
}
// 大纲速查:逐章节拍紧凑列表 + 该章伏笔窗口代号徽标,完整只读。
function OutlinePanel({ resource, onRetry }: OutlinePanelProps) {
if (resource.status === "loading" || resource.status === "idle") {
return <LoadingNote />;
}
if (resource.status === "error") {
return <ErrorNote message="大纲暂不可用。" onRetry={onRetry} />;
}
if (resource.data.length === 0) {
return <EmptyNote text="还没有大纲,去大纲页生成后回来速查。" />;
}
return (
<ul className="space-y-1.5">
{resource.data.map((row) => (
<li
key={row.no}
className="rounded border border-line bg-bg px-3 py-2"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-ink-soft">
{row.no} · {row.volume}
</span>
{row.foreshadowCodes.length > 0 ? (
<span className="flex flex-wrap gap-1">
{row.foreshadowCodes.map((code) => (
<span key={code} className={badgeClass({ variant: "accent" })}>
{code}
</span>
))}
</span>
) : null}
</div>
{row.beats.length > 0 ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-sm text-ink">
{row.beats.map((beat, i) => (
<li key={i}>{beat}</li>
))}
</ul>
) : (
<p className="mt-1 text-xs text-ink-soft"></p>
)}
</li>
))}
</ul>
);
}
// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。
function PlaceholderPanel({ summary }: { summary: string }) {
return (
<StatusNote variant="info">
<p>{summary}</p>
</StatusNote>
);
}
// 「去完整页编辑 →」链接:抽屉只读速查 → 跳全宽页编辑。
function FullPageLink({ href }: { href: string }) {
return (
<Link
href={href}
className="inline-flex items-center gap-1 text-sm text-cinnabar hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35"
>
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</Link>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { Button } from "@/components/ui/Button";
interface GenrePickerProps {
// 备选题材(来自 lib/wizard/wizard.ts 的 GENRES
genres: readonly string[];
// 当前点选题材(未选 = null
value: string | null;
// 点选某题材(点已选项亦回传该值,交由上层决定是否切换)。
onPick: (genre: string) => void;
// 可选:关闭/跳过采集卡。
onDismiss?: () => void;
}
// 极轻 genre 采集卡WFW-7一组 chips 让作者点本次写作题材,
// 杜绝无题材 slop。纯展示选中态用 aria-pressed 标注。
export function GenrePicker({
genres,
value,
onPick,
onDismiss,
}: GenrePickerProps) {
return (
<div className="border-b border-line bg-panel px-4 py-3 sm:px-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-serif text-base text-ink"></p>
<p className="mt-0.5 text-sm text-ink-soft">
</p>
</div>
{onDismiss ? (
<Button onClick={onDismiss} variant="secondary" size="sm">
</Button>
) : null}
</div>
<div
className="mt-3 flex flex-wrap gap-2"
role="group"
aria-label="本章题材"
>
{genres.map((genre) => {
const active = genre === value;
return (
<Button
key={genre}
type="button"
aria-pressed={active}
onClick={() => onPick(genre)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{genre}
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -16,6 +16,8 @@ interface InlineToolboxProps {
projectId: string; projectId: string;
// 把生成结果插入正文(追加到章末)。 // 把生成结果插入正文(追加到章末)。
onInsertText: (text: string) => void; onInsertText: (text: string) => void;
// 当前正文(编辑器 text。透传给 GeneratorRunner供 book-title 等据正文反推 brief。
manuscriptText?: string;
onClose: () => void; onClose: () => void;
} }
@@ -25,6 +27,7 @@ interface InlineToolboxProps {
export function InlineToolbox({ export function InlineToolbox({
projectId, projectId,
onInsertText, onInsertText,
manuscriptText,
onClose, onClose,
}: InlineToolboxProps) { }: InlineToolboxProps) {
const { status, tools } = useToolboxTools(); const { status, tools } = useToolboxTools();
@@ -48,6 +51,7 @@ export function InlineToolbox({
<GeneratorRunner <GeneratorRunner
projectId={projectId} projectId={projectId}
tool={active} tool={active}
manuscriptText={manuscriptText}
onClose={() => setActive(null)} onClose={() => setActive(null)}
onInsertText={(text) => { onInsertText={(text) => {
if (text.trim().length > 0) onInsertText(text); if (text.trim().length > 0) onInsertText(text);

View File

@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { import {
BookMarked,
BookOpen, BookOpen,
ChevronDown, ChevronDown,
ClipboardCheck, ClipboardCheck,
@@ -47,6 +48,10 @@ import { Editor, type EditorSelection } from "./Editor";
import { RefinePanel } from "./RefinePanel"; import { RefinePanel } from "./RefinePanel";
import { ContinuePanel } from "./ContinuePanel"; import { ContinuePanel } from "./ContinuePanel";
import { InlineToolbox } from "./InlineToolbox"; 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 { interface WorkbenchProps {
project: ProjectResponse; project: ProjectResponse;
@@ -82,6 +87,11 @@ export function Workbench({
const [refineOpen, setRefineOpen] = useState(false); const [refineOpen, setRefineOpen] = useState(false);
const [continueOpen, setContinueOpen] = useState(false); const [continueOpen, setContinueOpen] = useState(false);
const [toolboxOpen, setToolboxOpen] = 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 toast = useToast();
const autosave = useAutosave(project.id, chapterNo, initialText); const autosave = useAutosave(project.id, chapterNo, initialText);
const stream = useDraftStream(); const stream = useDraftStream();
@@ -89,6 +99,7 @@ export function Workbench({
const canRefine = selection !== null && selection.text.trim().length > 0; const canRefine = selection !== null && selection.text.trim().length > 0;
const chapterTriggerRef = useRef<HTMLButtonElement>(null); const chapterTriggerRef = useRef<HTMLButtonElement>(null);
const assistantTriggerRef = useRef<HTMLButtonElement>(null); const assistantTriggerRef = useRef<HTMLButtonElement>(null);
const contextTriggerRef = useRef<HTMLButtonElement>(null);
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。 // 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => { useEffect(() => {
@@ -105,12 +116,29 @@ export function Workbench({
} }
}, [stream.state.phase, stream.state.text, autosave]); }, [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 => { const onWrite = (): void => {
void stream.start( // 无题材且未点选 → 先弹采集卡选后再写HITL不静默生成无题材 slop
project.id, if (genreGate.needsGenre && genreGate.chosenGenre === null) {
chapterNo, setGenrePromptOpen(true);
composeDirective(directive, presetIds), return;
); }
startWrite(genreGate.effectiveGenre);
};
// 采集卡点题材:记录本次题材并立刻开写。用 genre 直传绕开 setState 异步。
const onPickGenre = (genre: string): void => {
genreGate.setChosenGenre(genre);
setGenrePromptOpen(false);
startWrite(genre);
}; };
const togglePreset = (id: string): void => { const togglePreset = (id: string): void => {
@@ -219,6 +247,14 @@ export function Workbench({
chapterTriggerRef={chapterTriggerRef} chapterTriggerRef={chapterTriggerRef}
assistantTriggerRef={assistantTriggerRef} assistantTriggerRef={assistantTriggerRef}
/> />
{genrePromptOpen ? (
<GenrePicker
genres={GENRES}
value={genreGate.chosenGenre}
onPick={onPickGenre}
onDismiss={() => setGenrePromptOpen(false)}
/>
) : null}
<DirectivePanel <DirectivePanel
directive={directive} directive={directive}
onDirectiveChange={setDirective} onDirectiveChange={setDirective}
@@ -245,6 +281,7 @@ export function Workbench({
{toolboxOpen ? ( {toolboxOpen ? (
<InlineToolbox <InlineToolbox
projectId={project.id} projectId={project.id}
manuscriptText={text}
onInsertText={(chunk) => { onInsertText={(chunk) => {
appendToDraft(chunk); appendToDraft(chunk);
setToolboxOpen(false); setToolboxOpen(false);
@@ -253,6 +290,11 @@ export function Workbench({
/> />
) : null} ) : null}
<div className="flex-1 overflow-auto px-6 py-8"> <div className="flex-1 overflow-auto px-6 py-8">
{chapters.length === 0 ? (
<StatusNote variant="info" className="mb-4">
</StatusNote>
) : null}
<Editor <Editor
value={text} value={text}
onChange={onEditorChange} onChange={onEditorChange}
@@ -275,6 +317,8 @@ export function Workbench({
onRefineSelection={openRefine} onRefineSelection={openRefine}
onContinue={openContinue} onContinue={openContinue}
onToolbox={openToolbox} onToolbox={openToolbox}
onOpenContext={() => setContextOpen(true)}
contextTriggerRef={contextTriggerRef}
/> />
</section> </section>
@@ -304,6 +348,14 @@ export function Workbench({
> >
<AssistantContent projectId={project.id} chapterNo={chapterNo} /> <AssistantContent projectId={project.id} chapterNo={chapterNo} />
</Drawer> </Drawer>
{/* WFW-6 上下文速查:视口无关的右侧 slide-over旧侧栏/全宽页路由保留、可回滚。 */}
<ContextDrawer
projectId={project.id}
open={contextOpen}
onClose={() => setContextOpen(false)}
triggerRef={contextTriggerRef}
/>
</AppShell> </AppShell>
); );
} }
@@ -497,6 +549,9 @@ interface ToolbarProps {
onContinue: () => void; onContinue: () => void;
// 工具箱:编辑器内联调用生成器,结果回填正文。 // 工具箱:编辑器内联调用生成器,结果回填正文。
onToolbox: () => void; onToolbox: () => void;
// 打开上下文速查抽屉WFW-6
onOpenContext: () => void;
contextTriggerRef: RefObject<HTMLButtonElement | null>;
} }
function Toolbar({ function Toolbar({
@@ -514,6 +569,8 @@ function Toolbar({
onRefineSelection, onRefineSelection,
onContinue, onContinue,
onToolbox, onToolbox,
onOpenContext,
contextTriggerRef,
}: ToolbarProps) { }: ToolbarProps) {
return ( 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"> <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"> <span className="sr-only" role="status" aria-live="polite">
{liveMessage} {liveMessage}
</span> </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 <Link
href={`/projects/${projectId}/outline`} href={`/projects/${projectId}/outline`}
className={buttonClass({ variant: "secondary", size: "sm" })} className={buttonClass({ variant: "secondary", size: "sm" })}

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { OutlineChapterView, WorldEntityCardView } from "@/lib/api/types";
import {
CONTEXT_TABS,
contextTabHref,
toCodexGroups,
toOutlineRows,
} from "./contextDrawer";
const entity = (
type: string,
name: string,
rules: string[] = [],
): WorldEntityCardView => ({ type, name, rules });
describe("contextTabHref", () => {
it("按 Tab key 拼出完整编辑页路由", () => {
// Arrange / Act / Assert
expect(contextTabHref("p1", "codex")).toBe("/projects/p1/codex");
expect(contextTabHref("p1", "foreshadow")).toBe("/projects/p1/foreshadow");
expect(contextTabHref("p1", "style")).toBe("/projects/p1/style");
});
it("五个 Tab 覆盖全部读为主入口", () => {
expect(CONTEXT_TABS.map((t) => t.key)).toEqual([
"codex",
"outline",
"foreshadow",
"rules",
"style",
]);
});
});
describe("toCodexGroups", () => {
it("空列表返回空分组", () => {
expect(toCodexGroups([])).toEqual([]);
});
it("按 type 归组并保持首次出现顺序", () => {
const entities = [
entity("势力", "雾港议会"),
entity("地理", "北境"),
entity("势力", "红手会"),
];
const groups = toCodexGroups(entities);
expect(groups.map((g) => g.type)).toEqual(["势力", "地理"]);
expect(groups[0]?.entities.map((e) => e.name)).toEqual([
"雾港议会",
"红手会",
]);
expect(groups[1]?.entities.map((e) => e.name)).toEqual(["北境"]);
});
it("空白/缺省 type 归入「未分类」", () => {
const groups = toCodexGroups([entity(" ", "无名之物")]);
expect(groups[0]?.type).toBe("未分类");
});
it("不修改入参数组", () => {
const entities = [entity("势力", "甲"), entity("势力", "乙")];
const snapshot = [...entities];
toCodexGroups(entities);
expect(entities).toEqual(snapshot);
});
});
describe("toOutlineRows", () => {
const chapter = (
no: number,
volume: number,
beats: string[],
codes: string[] = [],
): OutlineChapterView => ({
no,
volume,
beats,
foreshadow_windows: codes.map((code) => ({ code })),
});
it("空列表返回空数组", () => {
expect(toOutlineRows([])).toEqual([]);
});
it("按章号升序整形并抽出节拍与伏笔代号", () => {
const rows = toOutlineRows([
chapter(2, 1, ["决战"], ["F2"]),
chapter(1, 1, ["开场", "引入"], ["F1"]),
]);
expect(rows.map((r) => r.no)).toEqual([1, 2]);
expect(rows[0]?.beats).toEqual(["开场", "引入"]);
expect(rows[0]?.foreshadowCodes).toEqual(["F1"]);
expect(rows[1]?.foreshadowCodes).toEqual(["F2"]);
});
it("缺省 beats / foreshadow_windows 回退为空数组", () => {
const rows = toOutlineRows([{ no: 1, volume: 1 }]);
expect(rows[0]?.beats).toEqual([]);
expect(rows[0]?.foreshadowCodes).toEqual([]);
});
it("不修改入参数组", () => {
const chapters = [chapter(2, 1, []), chapter(1, 1, [])];
const snapshot = chapters.map((c) => c.no);
toOutlineRows(chapters);
expect(chapters.map((c) => c.no)).toEqual(snapshot);
});
});

View File

@@ -0,0 +1,84 @@
import type { OutlineChapterView, WorldEntityCardView } from "@/lib/api/types";
// 速查抽屉ContextDrawer的 Tab 配置与纯整形逻辑。
// 视图无关、无副作用,供组件与单测复用(组件本身不进 vitest 范围)。
export type ContextTabKey =
| "codex"
| "outline"
| "foreshadow"
| "rules"
| "style";
export interface ContextTab {
key: ContextTabKey;
label: string;
// 完整编辑页路由后缀(拼在 /projects/{id}/ 之后)。
routeSegment: string;
}
// Tab 顺序即抽屉里的呈现顺序:读为主的设定入口,设定库/大纲在前(本期做完整)。
export const CONTEXT_TABS: readonly ContextTab[] = [
{ key: "codex", label: "设定库", routeSegment: "codex" },
{ key: "outline", label: "大纲", routeSegment: "outline" },
{ key: "foreshadow", label: "伏笔", routeSegment: "foreshadow" },
{ key: "rules", label: "规则", routeSegment: "rules" },
{ key: "style", label: "文风", routeSegment: "style" },
];
// 抽屉只读速查 →「去完整页编辑」链接。未知 key 回退用 key 作后缀(防御)。
export function contextTabHref(projectId: string, key: ContextTabKey): string {
const tab = CONTEXT_TABS.find((t) => t.key === key);
const segment = tab?.routeSegment ?? key;
return `/projects/${projectId}/${segment}`;
}
export interface CodexGroup {
type: string;
entities: WorldEntityCardView[];
}
// 设定库实体按 type 归组,保持首次出现顺序(紧凑分组展示)。空 type 归「未分类」。
// 不可变:不改入参,返回全新分组数组。
export function toCodexGroups(
entities: readonly WorldEntityCardView[],
): CodexGroup[] {
const groups: CodexGroup[] = [];
const indexByType = new Map<string, number>();
for (const entity of entities) {
const type = entity.type.trim() || "未分类";
const idx = indexByType.get(type);
if (idx === undefined) {
indexByType.set(type, groups.length);
groups.push({ type, entities: [entity] });
} else {
const group = groups.at(idx);
if (group) {
groups[idx] = { type: group.type, entities: [...group.entities, entity] };
}
}
}
return groups;
}
export interface OutlineRow {
no: number;
volume: number;
beats: string[];
foreshadowCodes: string[];
}
// 大纲章节整形为紧凑速查行:按章号升序,抽出节拍与该章伏笔窗口代号。
// 不可变:先复制再排序,不改入参。
export function toOutlineRows(
chapters: readonly OutlineChapterView[],
): OutlineRow[] {
return [...chapters]
.sort((a, b) => a.no - b.no)
.map((chapter) => ({
no: chapter.no,
volume: chapter.volume,
beats: chapter.beats ?? [],
foreshadowCodes: (chapter.foreshadow_windows ?? []).map((w) => w.code),
}));
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildManuscriptExcerpt,
DEFAULT_EXCERPT_MAX_CHARS,
} from "./manuscriptExcerpt";
describe("buildManuscriptExcerpt", () => {
it("短文未超界trim 后原样返回,无省略标记", () => {
const text = " 第一章 少年初入江湖。 ";
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toBe("第一章 少年初入江湖。");
expect(excerpt).not.toContain("中略");
});
it("空串与纯空白 → 返回 \"\"", () => {
expect(buildManuscriptExcerpt("", 1800)).toBe("");
expect(buildManuscriptExcerpt(" \n\t ", 1800)).toBe("");
});
it("正好等于上界 → 原样返回,不截断", () => {
const text = "字".repeat(1800);
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toBe(text);
expect(excerpt).not.toContain("中略");
});
it("超长 → 取首尾拼接、含省略标记,且首尾片段都保留", () => {
const head = "甲".repeat(1000);
const tail = "乙".repeat(1000);
const text = head + tail; // 2000 > 1800
const excerpt = buildManuscriptExcerpt(text, 1800);
expect(excerpt).toContain("中略");
// 首段来自开头、尾段来自结尾。
expect(excerpt.startsWith("甲")).toBe(true);
expect(excerpt.endsWith("乙")).toBe(true);
});
it("超长摘录长度 ≤ 上界 + 少许标记冗余(正文部分不超界)", () => {
const text = "文".repeat(5000);
const maxChars = 1800;
const excerpt = buildManuscriptExcerpt(text, maxChars);
// 省略标记之外的正文字符数不得超过上界。
const bodyLen = excerpt.replace(/……(中略)……/g, "").replace(/\s+/g, "")
.length;
expect(bodyLen).toBeLessThanOrEqual(maxChars);
// 整体长度仅比上界多出标记长度这点冗余。
expect(excerpt.length).toBeLessThanOrEqual(maxChars + 20);
});
it("默认上界常量生效(不传 maxChars", () => {
const text = "字".repeat(DEFAULT_EXCERPT_MAX_CHARS + 500);
const excerpt = buildManuscriptExcerpt(text);
expect(excerpt).toContain("中略");
const bodyLen = excerpt.replace(/……(中略)……/g, "").replace(/\s+/g, "")
.length;
expect(bodyLen).toBeLessThanOrEqual(DEFAULT_EXCERPT_MAX_CHARS);
});
});

View File

@@ -0,0 +1,31 @@
// 从作者【已写正文】抽一段代表性摘录,喂给内容感知型生成器(如书名反推)的 brief 字段。
// 纯函数、无副作用;硬上界 maxChars 控 token保证首尾都进入摘录。
/** 默认摘录上界(字符数),约束喂给 LLM 的 token 体量。 */
export const DEFAULT_EXCERPT_MAX_CHARS = 1800;
/** 首尾之间插入的省略标记,提示中间正文被截去。 */
const ELLIPSIS_MARKER = "\n\n……中略……\n\n";
/**
* 取正文的代表性摘录:
* - 空/纯空白 → 返回 ""。
* - 长度 ≤ maxChars → 原样返回trim 后)。
* - 超界 → 取「开头一段 + 结尾一段」拼接,中间插省略标记,保证首尾都在。
* 正文预算按 maxChars 均分给首尾两段;返回长度 ≤ maxChars + 标记长度。
*/
export function buildManuscriptExcerpt(
fullText: string,
maxChars: number = DEFAULT_EXCERPT_MAX_CHARS,
): string {
const trimmed = fullText.trim();
if (trimmed.length === 0) return "";
if (trimmed.length <= maxChars) return trimmed;
// 正文预算(不含标记)均分给首尾,向下取整避免超界。
const headBudget = Math.floor(maxChars / 2);
const tailBudget = maxChars - headBudget;
const head = trimmed.slice(0, headBudget).trimEnd();
const tail = trimmed.slice(trimmed.length - tailBudget).trimStart();
return `${head}${ELLIPSIS_MARKER}${tail}`;
}

View File

@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useContextDrawerData } from "./useContextDrawer";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const CODEX_PATH = "/projects/{project_id}/world_entities";
const OUTLINE_PATH = "/projects/{project_id}/outline";
// 按路径分流 codex / outline 两个端点的 mock 响应。
function routeGet(
responses: {
codex?: unknown;
outline?: unknown;
codexReject?: boolean;
outlineReject?: boolean;
} = {},
): void {
get.mockImplementation((path: string) => {
if (path === CODEX_PATH) {
if (responses.codexReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.codex ?? { data: null, error: null });
}
if (path === OUTLINE_PATH) {
if (responses.outlineReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.outline ?? { data: null, error: null });
}
return Promise.resolve({ data: null, error: null });
});
}
describe("useContextDrawerData", () => {
beforeEach(() => {
get.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("抽屉关闭时不发请求,两资源保持 idle", () => {
routeGet();
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", false),
);
expect(get).not.toHaveBeenCalled();
expect(result.current.codex.status).toBe("idle");
expect(result.current.outline.status).toBe("idle");
});
it("打开且在设定库 Tab拉取并归组到 ready", async () => {
routeGet({
codex: {
data: {
world_entities: [
{ type: "势力", name: "雾港议会", rules: [] },
{ type: "地理", name: "北境", rules: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data.map((g) => g.type)).toEqual([
"势力",
"地理",
]);
expect(get).toHaveBeenCalledTimes(1);
expect(toast).not.toHaveBeenCalled();
});
it("打开且在大纲 Tab拉取并按章号升序整形", async () => {
routeGet({
outline: {
data: {
chapters: [
{ no: 2, volume: 1, beats: ["决战"], foreshadow_windows: [] },
{ no: 1, volume: 1, beats: ["开场"], foreshadow_windows: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "outline", true),
);
await waitFor(() => expect(result.current.outline.status).toBe("ready"));
expect(result.current.outline.data.map((r) => r.no)).toEqual([1, 2]);
});
it("data 为空时 ready 且为空数组", async () => {
routeGet({ codex: { data: { world_entities: null }, error: null } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toEqual([]);
});
it("后端返回 errorerror 态 + 错误 toast", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常error 态 + 网络异常 toast", async () => {
routeGet({ codexReject: true });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(
"设定库加载异常,请检查网络。",
"error",
);
});
it("已加载后切走再切回不重复拉取(缓存)", async () => {
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
const { result, rerender } = renderHook(
({ tab }: { tab: "codex" | "outline" }) =>
useContextDrawerData("p1", tab, true),
{ initialProps: { tab: "codex" } as { tab: "codex" | "outline" } },
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
rerender({ tab: "outline" });
rerender({ tab: "codex" });
expect(get.mock.calls.filter((c) => c[0] === CODEX_PATH)).toHaveLength(1);
});
it("reloadCodex 在出错后可重试拉取", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
act(() => result.current.reloadCodex());
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toHaveLength(1);
});
});

View File

@@ -0,0 +1,145 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
toCodexGroups,
toOutlineRows,
type CodexGroup,
type ContextTabKey,
type OutlineRow,
} from "./contextDrawer";
// 速查抽屉的数据层:抽屉打开且切到对应 Tab 时【懒加载】设定库 / 大纲(只读,绝不写库)。
// 加载成功后缓存(切走再切回不重复拉);项目切换清缓存;失败可 reload 重试。
export type ResourceStatus = "idle" | "loading" | "ready" | "error";
export interface ContextResource<T> {
status: ResourceStatus;
data: T;
}
export interface UseContextDrawerData {
codex: ContextResource<CodexGroup[]>;
outline: ContextResource<OutlineRow[]>;
reloadCodex: () => void;
reloadOutline: () => void;
}
const CODEX_PATH = "/projects/{project_id}/world_entities" as const;
const OUTLINE_PATH = "/projects/{project_id}/outline" as const;
const EMPTY_CODEX: CodexGroup[] = [];
const EMPTY_OUTLINE: OutlineRow[] = [];
export function useContextDrawerData(
projectId: string,
activeTab: ContextTabKey,
open: boolean,
): UseContextDrawerData {
const [codex, setCodex] = useState<ContextResource<CodexGroup[]>>({
status: "idle",
data: EMPTY_CODEX,
});
const [outline, setOutline] = useState<ContextResource<OutlineRow[]>>({
status: "idle",
data: EMPTY_OUTLINE,
});
const toast = useToast();
// 单飞哨兵:已发起过请求就不再重复(成功缓存 / 在途去重);出错时重置以允许重试。
const codexReqRef = useRef(false);
const outlineReqRef = useRef(false);
// reload 触发器:自增即重新拉取(配合把 req 哨兵复位)。
const [codexNonce, setCodexNonce] = useState(0);
const [outlineNonce, setOutlineNonce] = useState(0);
// 项目切换 → 清缓存 + 复位哨兵,允许对新项目重新拉取。声明在拉取 effect 之前,
// 保证同一次提交里先复位、后续 effect 才据此触发。
useEffect(() => {
codexReqRef.current = false;
outlineReqRef.current = false;
setCodex({ status: "idle", data: EMPTY_CODEX });
setOutline({ status: "idle", data: EMPTY_OUTLINE });
}, [projectId]);
useEffect(() => {
if (!open || activeTab !== "codex" || codexReqRef.current) return;
codexReqRef.current = true;
let cancelled = false;
setCodex({ status: "loading", data: EMPTY_CODEX });
void (async () => {
try {
const { data, error } = await api.GET(CODEX_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
codexReqRef.current = false;
setCodex({ status: "error", data: EMPTY_CODEX });
toast("设定库加载失败,请重试。", "error");
return;
}
setCodex({
status: "ready",
data: toCodexGroups(data.world_entities ?? []),
});
} catch {
if (cancelled) return;
codexReqRef.current = false;
setCodex({ status: "error", data: EMPTY_CODEX });
toast("设定库加载异常,请检查网络。", "error");
}
})();
return () => {
cancelled = true;
};
}, [open, activeTab, projectId, codexNonce, toast]);
useEffect(() => {
if (!open || activeTab !== "outline" || outlineReqRef.current) return;
outlineReqRef.current = true;
let cancelled = false;
setOutline({ status: "loading", data: EMPTY_OUTLINE });
void (async () => {
try {
const { data, error } = await api.GET(OUTLINE_PATH, {
params: { path: { project_id: projectId } },
});
if (cancelled) return;
if (error || !data) {
outlineReqRef.current = false;
setOutline({ status: "error", data: EMPTY_OUTLINE });
toast("大纲加载失败,请重试。", "error");
return;
}
setOutline({
status: "ready",
data: toOutlineRows(data.chapters ?? []),
});
} catch {
if (cancelled) return;
outlineReqRef.current = false;
setOutline({ status: "error", data: EMPTY_OUTLINE });
toast("大纲加载异常,请检查网络。", "error");
}
})();
return () => {
cancelled = true;
};
}, [open, activeTab, projectId, outlineNonce, toast]);
const reloadCodex = (): void => {
codexReqRef.current = false;
setCodex({ status: "idle", data: EMPTY_CODEX });
setCodexNonce((n) => n + 1);
};
const reloadOutline = (): void => {
outlineReqRef.current = false;
setOutline({ status: "idle", data: EMPTY_OUTLINE });
setOutlineNonce((n) => n + 1);
};
return { codex, outline, reloadCodex, reloadOutline };
}

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { toGenreDirective, useGenreGate } from "./useGenreGate";
describe("toGenreDirective", () => {
it("有题材时形如「本章题材:<genre>」", () => {
expect(toGenreDirective("玄幻")).toBe("本章题材:玄幻");
});
it("空/纯空白/null → 空串", () => {
expect(toGenreDirective(null)).toBe("");
expect(toGenreDirective("")).toBe("");
expect(toGenreDirective(" ")).toBe("");
});
});
describe("useGenreGate", () => {
it("项目已有 genreneedsGenre=falsegenreDirective 反映项目题材", () => {
const { result } = renderHook(() => useGenreGate("仙侠"));
expect(result.current.needsGenre).toBe(false);
expect(result.current.effectiveGenre).toBe("仙侠");
expect(result.current.genreDirective).toBe("本章题材:仙侠");
});
it("项目 genre 为纯空白也视为缺失", () => {
const { result } = renderHook(() => useGenreGate(" "));
expect(result.current.needsGenre).toBe(true);
expect(result.current.effectiveGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("无 genreneedsGenre=true初始无生效题材与题材指令", () => {
const { result } = renderHook(() => useGenreGate(null));
expect(result.current.needsGenre).toBe(true);
expect(result.current.chosenGenre).toBeNull();
expect(result.current.effectiveGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("无 genre 时 setChosenGenre 后 effectiveGenre/genreDirective 更新", () => {
const { result } = renderHook(() => useGenreGate(null));
act(() => result.current.setChosenGenre("都市"));
expect(result.current.chosenGenre).toBe("都市");
expect(result.current.effectiveGenre).toBe("都市");
expect(result.current.genreDirective).toBe("本章题材:都市");
});
it("setChosenGenre 传空串 → 回落 null不产生题材指令", () => {
const { result } = renderHook(() => useGenreGate(null));
act(() => result.current.setChosenGenre("科幻"));
act(() => result.current.setChosenGenre(" "));
expect(result.current.chosenGenre).toBeNull();
expect(result.current.genreDirective).toBe("");
});
it("项目 genre 优先于作者点选", () => {
const { result } = renderHook(() => useGenreGate("历史"));
act(() => result.current.setChosenGenre("悬疑"));
// needsGenre=false 时 UI 不会调 setChosenGenre但即便调了项目题材仍优先。
expect(result.current.effectiveGenre).toBe("历史");
expect(result.current.genreDirective).toBe("本章题材:历史");
});
});

View File

@@ -0,0 +1,55 @@
"use client";
import { useCallback, useMemo, useState } from "react";
// 极轻 genre 采集门WFW-7纯前端零契约。首次写章前若项目无 genre
// 让作者点一个题材仅【临时并入本次写作指令】——MVP 不落库(无项目更新端点),
// 避免无题材 slop。纯状态无副作用组件只负责渲染 chips + 触发 onWrite。
// 有 effectiveGenre 时的题材指令前缀(并进 composeDirective。空题材 → ""。
export function toGenreDirective(genre: string | null): string {
const trimmed = genre?.trim() ?? "";
return trimmed.length > 0 ? `本章题材:${trimmed}` : "";
}
export interface GenreGate {
// 项目缺 genre需先采集。为空串/纯空白亦视为缺失。
needsGenre: boolean;
// 作者本次临时点选的题材(未选 = null
chosenGenre: string | null;
setChosenGenre: (genre: string) => void;
// 生效题材:项目 genre 优先,其次作者点选,都无 → null。
effectiveGenre: string | null;
// 并入本次 directive 的题材串(形如「本章题材:玄幻」);无生效题材 → ""。
genreDirective: string;
}
export function useGenreGate(projectGenre: string | null): GenreGate {
const [chosenGenre, setChosenGenreState] = useState<string | null>(null);
const setChosenGenre = useCallback((genre: string): void => {
const trimmed = genre.trim();
setChosenGenreState(trimmed.length > 0 ? trimmed : null);
}, []);
const needsGenre = (projectGenre?.trim() ?? "").length === 0;
const effectiveGenre = useMemo<string | null>(() => {
const fromProject = projectGenre?.trim() ?? "";
if (fromProject.length > 0) return fromProject;
return chosenGenre;
}, [projectGenre, chosenGenre]);
const genreDirective = useMemo(
() => toGenreDirective(effectiveGenre),
[effectiveGenre],
);
return {
needsGenre,
chosenGenre,
setChosenGenre,
effectiveGenre,
genreDirective,
};
}