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