diff --git a/apps/web/components/toolbox/GeneratorRunner.tsx b/apps/web/components/toolbox/GeneratorRunner.tsx index 6526fc5..c18a494 100644 --- a/apps/web/components/toolbox/GeneratorRunner.tsx +++ b/apps/web/components/toolbox/GeneratorRunner.tsx @@ -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 => { const missing = missingRequiredFields(tool.input_fields, values); if (missing.length > 0) { @@ -166,6 +191,22 @@ export function GeneratorRunner({ targetLabel={fillLabel} onFill={setField} /> + {canUseManuscript ? ( +
+ + 据当前正文反推(自动摘录首尾,控制长度) + + +
+ ) : null} {(tool.input_fields ?? []).map((field) => ( void; + // 关闭时把焦点还给触发按钮(a11y)。 + triggerRef?: RefObject; +} + +// 从编辑器右侧滑出的【只读】上下文速查抽屉:不离开写作界面即可查设定库/大纲/伏笔/规则/文风。 +// 视口无关(不同于 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("codex"); + const panelRef = useRef(null); + const closeRef = useRef(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 ( +
+
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" + > +
+ + + +
+ + + +
+ +
+
+
+ ); +} + +interface TabBarProps { + activeTab: ContextTabKey; + onSelect: (key: ContextTabKey) => void; +} + +// Tab 选择条(role=tablist):设定库/大纲做完整速查,伏笔/规则/文风为摘要+跳链。 +function TabBar({ activeTab, onSelect }: TabBarProps) { + return ( +
+ {CONTEXT_TABS.map((tab) => { + const active = tab.key === activeTab; + return ( + + ); + })} +
+ ); +} + +interface TabPanelProps { + projectId: string; + activeTab: ContextTabKey; + data: ReturnType; +} + +// 按当前 Tab 渲染对应速查面板;每个面板都带一个「去完整页编辑 →」链接。 +function TabPanel({ projectId, activeTab, data }: TabPanelProps) { + const href = contextTabHref(projectId, activeTab); + return ( +
+ {activeTab === "codex" ? ( + + ) : null} + {activeTab === "outline" ? ( + + ) : null} + {activeTab === "foreshadow" ? ( + + ) : null} + {activeTab === "rules" ? ( + + ) : null} + {activeTab === "style" ? ( + + ) : null} + +
+ ); +} + +// 加载中骨架(统一给设定库/大纲复用)。 +function LoadingNote() { + return ( +
+ +
+ ); +} + +interface ErrorNoteProps { + message: string; + onRetry: () => void; +} + +function ErrorNote({ message, onRetry }: ErrorNoteProps) { + return ( + +

{message}

+ +
+ ); +} + +function EmptyNote({ text }: { text: string }) { + return ( + +

{text}

+
+ ); +} + +interface CodexPanelProps { + resource: ContextResource; + onRetry: () => void; +} + +// 设定库速查:按类型分组的实体名单(含硬规则条目),完整只读列表。 +function CodexPanel({ resource, onRetry }: CodexPanelProps) { + if (resource.status === "loading" || resource.status === "idle") { + return ; + } + if (resource.status === "error") { + return ; + } + if (resource.data.length === 0) { + return ; + } + return ( +
+ {resource.data.map((group) => ( +
+

+ {group.type} + + {group.entities.length} + +

+
    + {group.entities.map((entity) => ( +
  • +

    {entity.name}

    + {entity.rules && entity.rules.length > 0 ? ( +
      + {entity.rules.map((rule, i) => ( +
    • {rule}
    • + ))} +
    + ) : null} +
  • + ))} +
+
+ ))} +
+ ); +} + +interface OutlinePanelProps { + resource: ContextResource; + onRetry: () => void; +} + +// 大纲速查:逐章节拍紧凑列表 + 该章伏笔窗口代号徽标,完整只读。 +function OutlinePanel({ resource, onRetry }: OutlinePanelProps) { + if (resource.status === "loading" || resource.status === "idle") { + return ; + } + if (resource.status === "error") { + return ; + } + if (resource.data.length === 0) { + return ; + } + return ( +
    + {resource.data.map((row) => ( +
  • +
    + + 第 {row.no} 章 · 卷 {row.volume} + + {row.foreshadowCodes.length > 0 ? ( + + {row.foreshadowCodes.map((code) => ( + + {code} + + ))} + + ) : null} +
    + {row.beats.length > 0 ? ( +
      + {row.beats.map((beat, i) => ( +
    • {beat}
    • + ))} +
    + ) : ( +

    (本章暂无节拍)

    + )} +
  • + ))} +
+ ); +} + +// 占位面板(伏笔/规则/文风):抽屉内一句话摘要,完整编辑走下方跳链。 +function PlaceholderPanel({ summary }: { summary: string }) { + return ( + +

{summary}

+
+ ); +} + +// 「去完整页编辑 →」链接:抽屉只读速查 → 跳全宽页编辑。 +function FullPageLink({ href }: { href: string }) { + return ( + + 去完整页编辑 +