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:
@@ -23,6 +23,7 @@ import {
|
||||
type PreviewItem,
|
||||
} from "@/lib/toolbox/toolbox";
|
||||
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
|
||||
import { buildManuscriptExcerpt } from "@/lib/workbench/manuscriptExcerpt";
|
||||
import { useGenerator } from "@/lib/toolbox/useGenerator";
|
||||
import { TemplateFiller } from "./TemplateFiller";
|
||||
|
||||
@@ -32,6 +33,9 @@ interface GeneratorRunnerProps {
|
||||
onClose: () => void;
|
||||
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
|
||||
onInsertText?: (text: string) => void;
|
||||
// 可选:作者【已写正文】。传入且当前工具有 brief 字段时,表单里出现「用当前正文」按钮,
|
||||
// 点击把正文摘录填进 brief,让内容感知型生成器(如书名)据正文反推。工具箱页不传 → 不显示。
|
||||
manuscriptText?: string;
|
||||
}
|
||||
|
||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||
@@ -42,6 +46,7 @@ export function GeneratorRunner({
|
||||
tool,
|
||||
onClose,
|
||||
onInsertText,
|
||||
manuscriptText,
|
||||
}: GeneratorRunnerProps) {
|
||||
const gen = useGenerator();
|
||||
const toast = useToast();
|
||||
@@ -67,6 +72,26 @@ export function GeneratorRunner({
|
||||
[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 missing = missingRequiredFields(tool.input_fields, values);
|
||||
if (missing.length > 0) {
|
||||
@@ -166,6 +191,22 @@ export function GeneratorRunner({
|
||||
targetLabel={fillLabel}
|
||||
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) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
|
||||
336
apps/web/components/workbench/ContextDrawer.tsx
Normal file
336
apps/web/components/workbench/ContextDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/web/components/workbench/GenrePicker.tsx
Normal file
62
apps/web/components/workbench/GenrePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ interface InlineToolboxProps {
|
||||
projectId: string;
|
||||
// 把生成结果插入正文(追加到章末)。
|
||||
onInsertText: (text: string) => void;
|
||||
// 当前正文(编辑器 text)。透传给 GeneratorRunner,供 book-title 等据正文反推 brief。
|
||||
manuscriptText?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +27,7 @@ interface InlineToolboxProps {
|
||||
export function InlineToolbox({
|
||||
projectId,
|
||||
onInsertText,
|
||||
manuscriptText,
|
||||
onClose,
|
||||
}: InlineToolboxProps) {
|
||||
const { status, tools } = useToolboxTools();
|
||||
@@ -48,6 +51,7 @@ export function InlineToolbox({
|
||||
<GeneratorRunner
|
||||
projectId={projectId}
|
||||
tool={active}
|
||||
manuscriptText={manuscriptText}
|
||||
onClose={() => setActive(null)}
|
||||
onInsertText={(text) => {
|
||||
if (text.trim().length > 0) onInsertText(text);
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
112
apps/web/lib/workbench/contextDrawer.test.ts
Normal file
112
apps/web/lib/workbench/contextDrawer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
apps/web/lib/workbench/contextDrawer.ts
Normal file
84
apps/web/lib/workbench/contextDrawer.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
61
apps/web/lib/workbench/manuscriptExcerpt.test.ts
Normal file
61
apps/web/lib/workbench/manuscriptExcerpt.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
apps/web/lib/workbench/manuscriptExcerpt.ts
Normal file
31
apps/web/lib/workbench/manuscriptExcerpt.ts
Normal 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}`;
|
||||
}
|
||||
165
apps/web/lib/workbench/useContextDrawer.test.ts
Normal file
165
apps/web/lib/workbench/useContextDrawer.test.ts
Normal 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("后端返回 error:error 态 + 错误 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);
|
||||
});
|
||||
});
|
||||
145
apps/web/lib/workbench/useContextDrawer.ts
Normal file
145
apps/web/lib/workbench/useContextDrawer.ts
Normal 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 };
|
||||
}
|
||||
65
apps/web/lib/workbench/useGenreGate.test.ts
Normal file
65
apps/web/lib/workbench/useGenreGate.test.ts
Normal 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("项目已有 genre:needsGenre=false,genreDirective 反映项目题材", () => {
|
||||
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("无 genre:needsGenre=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("本章题材:历史");
|
||||
});
|
||||
});
|
||||
55
apps/web/lib/workbench/useGenreGate.ts
Normal file
55
apps/web/lib/workbench/useGenreGate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user