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