From 3d2a8b8ceeeea04a8b20e2ef1a55355d4788e3a7 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 17:38:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=A7=92=E8=89=B2=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E5=9B=BE=E8=B0=B1=EF=BC=88=E7=81=B5=E6=84=9F=E2=91=A5?= =?UTF-8?q?=E7=BC=A9=E5=87=8F=E7=89=88/D4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设定库人物 tab 新增 React Flow 关系图谱,仅用现有角色读端点数据 (CharacterRelationView={name,kind,note}):边按 name-join、kind 作中文 标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造)。 - lib/characters/graphLayout.ts:纯逻辑(role→分组配色关键词映射 / 节点 去空重名 / kind→标签+DEFAULT_RELATION_LABEL / 悬空边跳过并计数 / 自指 跳过 / 互指无向去重 / @dagrejs/dagre 一次性确定性初始坐标、中心→左上、 不可变更新)+22 单测。 - components/characters/RelationshipGraph.tsx:'use client',经 CodexPage dynamic(ssr:false) 挂载;显式容器高度 + import RF CSS + prefers-reduced- motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示。 - 依赖:@xyflow/react@^12.11.2 + @dagrejs/dagre@^3.0.0。 无后端契约变更/迁移/gen:api。门禁:lint/typecheck 干净、vitest 565、 coverage lib 95.4%(graphLayout 97.6%)、build OK。 --- PROGRESS.md | 1 + .../characters/RelationshipGraph.tsx | 149 ++++++++++ apps/web/components/codex/CodexPage.tsx | 29 +- apps/web/lib/characters/graphLayout.test.ts | 258 ++++++++++++++++++ apps/web/lib/characters/graphLayout.ts | 245 +++++++++++++++++ apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 208 ++++++++++++++ apps/web/pnpm-workspace.yaml | 3 + memory/contracts.md | 2 + 9 files changed, 896 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/characters/RelationshipGraph.tsx create mode 100644 apps/web/lib/characters/graphLayout.test.ts create mode 100644 apps/web/lib/characters/graphLayout.ts diff --git a/PROGRESS.md b/PROGRESS.md index b6b7920..d90096c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -244,3 +244,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st - [2026-06-20] @frontend — **Tier4 完成**(T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop)。T4-a:新 `lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件,AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3,余 4rem),6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b(全链):后端 `assemble/_build_volatile` 加 `directive` 形参(「本章指令」段领衔 volatile,**绝不入 stable_core**,守缓存前缀不变量 #9),`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDD:ai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩(T4-b)。 - [2026-06-28] @backend/@frontend — **UI Phase G 后端依赖补齐**:`ProjectResponse` 增加 `updated_at` / `pending_review_count`,`chapters` 加 `updated_at` 迁移 `8c1d2e3f4a5b`;`SqlProjectRepo` 聚合项目自身、章节、审稿时间作为最近编辑,并按草稿是否晚于最近 accepted/review 统计待审稿章数。前端 `pnpm gen:api` 后项目库默认按最近编辑排序,新增“待审稿”筛选,作品卡显示最近编辑与待审稿徽标。契约记 `memory/contracts.md` C3 扩,UI 计划记 `docs/design/ui-improvement-plan.md` 0.16。 - [2026-06-29] @frontend/@qa — **UI review follow-up 修复完成**:补 `lib/review/chapterNav.ts` 使新增章导航测试可运行;`ReviewSectionPanel` 改为本地 open 状态,避免用户折叠后重渲染强制弹开;`ReviewReport.hasReport` 纳入 clean review;新增 `tests/test_project_metadata_e2e.py` 用真实 DB 覆盖项目最近编辑/待审稿统计口径。验证:前端 lint/typecheck/vitest **490 passed**/build;后端 ruff/format/mypy、pytest **763 passed**;浏览器抽样 review/settings 无 console error/横向溢出。 +- [2026-07-06] @frontend — **✅ 灵感⑥ 关系图谱(缩减版)+ D4 完成**(纯前端,无后端契约/迁移/gen:api)。新增 `lib/characters/graphLayout.ts`(纯逻辑:`roleStyle` role→分组配色关键词映射、`buildGraphNodes`/`buildGraphEdges`(kind→标签,`DEFAULT_RELATION_LABEL` 命名常量)、`countDanglingRelations`、`layoutGraph`(@dagrejs/dagre 一次性确定性初始坐标、中心→左上、不可变)、`buildRelationshipGraph` 端到端)——**仅用现有 `CharacterCardView.relations={name,kind,note}`**:边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造);悬空边(引用未入库角色)跳过并计数。新增 `components/characters/RelationshipGraph.tsx`(`'use client'`,React Flow @xyflow/react;CodexPage 经 `dynamic(ssr:false)` 挂载于人物 tab,显式容器高度 h-[28rem] + `import '@xyflow/react/dist/style.css'` + prefers-reduced-motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示)。依赖:`@xyflow/react@^12.11.2`+`@dagrejs/dagre@^3.0.0`(见 contracts.md 2026-07-06)。TDD:graphLayout **+22 测**(role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿:lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%(graphLayout.ts 97.6%)** · **build OK**(codex 路由 7.58 kB,React Flow 懒加载独立 chunk)。注:`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500(stale chunk),非代码缺陷、dev watcher 下次重编自愈,未重启服务。 diff --git a/apps/web/components/characters/RelationshipGraph.tsx b/apps/web/components/characters/RelationshipGraph.tsx new file mode 100644 index 0000000..b2b25e9 --- /dev/null +++ b/apps/web/components/characters/RelationshipGraph.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Background, + BackgroundVariant, + Controls, + ReactFlow, + type Edge, + type Node, +} from "@xyflow/react"; +import { Users } from "lucide-react"; + +// React Flow 样式必须显式引入(组件经 dynamic ssr:false 加载,样式随该 chunk 打包)。 +import "@xyflow/react/dist/style.css"; + +import { EmptyState } from "@/components/ui/EmptyState"; +import { + buildRelationshipGraph, + NODE_WIDTH, + type GraphNode, +} from "@/lib/characters/graphLayout"; +import type { CharacterCardView } from "@/lib/api/types"; + +interface RelationshipGraphProps { + characters: CharacterCardView[]; +} + +// 纸感节点样式:面板底 + 细线框 + 左侧按分组配色的强调条(色值走主题 CSS 变量)。 +function paperNodeStyle(colorVar: string): React.CSSProperties { + return { + width: NODE_WIDTH, + padding: "8px 10px", + borderRadius: 6, + background: "var(--color-panel)", + color: "var(--color-ink)", + border: "1px solid var(--color-line)", + borderLeft: `4px solid var(${colorVar})`, + boxShadow: "0 1px 3px var(--shadow-paper)", + fontSize: 12, + textAlign: "left", + }; +} + +// 节点内容:角色名(宋体)+ 定位副标题。 +function NodeLabel({ name, role }: { name: string; role: string }) { + return ( +
+

{name}

+ {role ?

{role}

: null} +
+ ); +} + +function toReactFlowNodes(nodes: GraphNode[]): Node[] { + return nodes.map((node) => ({ + id: node.id, + position: node.position, + data: { label: }, + style: paperNodeStyle(node.data.colorVar), + // 只读可视化:可拖动重排,不可新建连线。 + connectable: false, + })); +} + +function toReactFlowEdges( + edges: { id: string; source: string; target: string; label: string }[], +): Edge[] { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + label: edge.label, + labelStyle: { fill: "var(--color-ink-soft)", fontSize: 11 }, + labelBgStyle: { fill: "var(--color-bg)", fillOpacity: 0.9 }, + labelBgPadding: [4, 2] as [number, number], + labelBgBorderRadius: 4, + style: { stroke: "var(--color-line)", strokeWidth: 1.5 }, + })); +} + +// 尊重 prefers-reduced-motion:减动偏好下关闭 fitView 平滑动画与边流动动画。 +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReduced(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return reduced; +} + +// 角色关系图谱(灵感⑥ 缩减版 / D4):只用现有 relations 数据,节点按 role 上色、 +// 边按 name-join、kind 作中文标签。悬空边(引用未入库角色)已在 graphLayout 略过并计数。 +export function RelationshipGraph({ characters }: RelationshipGraphProps) { + const reducedMotion = usePrefersReducedMotion(); + + const graph = useMemo( + () => buildRelationshipGraph(characters), + [characters], + ); + const nodes = useMemo(() => toReactFlowNodes(graph.nodes), [graph.nodes]); + const edges = useMemo(() => toReactFlowEdges(graph.edges), [graph.edges]); + + if (graph.nodes.length === 0) { + return ( + + ); + } + + return ( +
+ {/* React Flow 必须有显式尺寸的容器,否则画布高度为 0。 */} +
+ + + + +
+ {graph.danglingCount > 0 ? ( +

+ 有 {graph.danglingCount} 条关系引用了未入库的角色,已略过。 +

+ ) : null} +
+ ); +} diff --git a/apps/web/components/codex/CodexPage.tsx b/apps/web/components/codex/CodexPage.tsx index 6124627..9012925 100644 --- a/apps/web/components/codex/CodexPage.tsx +++ b/apps/web/components/codex/CodexPage.tsx @@ -1,7 +1,8 @@ "use client"; import { useMemo, useState } from "react"; -import { Clock3, Globe2, Sparkles, UserRound } from "lucide-react"; +import dynamic from "next/dynamic"; +import { Clock3, Globe2, Share2, Sparkles, UserRound } from "lucide-react"; import { AppShell } from "@/components/AppShell"; import { CharacterGenerator } from "@/components/generation/CharacterGenerator"; @@ -39,6 +40,23 @@ const TABS: { key: CodexTab; label: string }[] = [ { key: "timeline", label: "时间线" }, ]; +// 关系图谱(React Flow)只在客户端渲染:ssr:false 绕开 canvas/window 依赖, +// 并把 React Flow 及其样式拆到独立 chunk,进人物 tab 才加载。 +const RelationshipGraph = dynamic( + () => + import("@/components/characters/RelationshipGraph").then( + (mod) => mod.RelationshipGraph, + ), + { + ssr: false, + loading: () => ( +
+ 关系图谱加载中… +
+ ), + }, +); + // 设定库 Codex(UX §6.5):管理人物 / 世界观 / 时间线。 // 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显, // 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards)。 @@ -143,6 +161,15 @@ export function CodexPage({ /> )} + {characters.length > 0 ? ( + +

+

+ +
+ ) : null}
({ + name: r.name, + kind: r.kind ?? "", + note: r.note ?? null, + })), + }; +} + +describe("roleStyle", () => { + it("主角关键词 → protagonist + 朱砂色", () => { + expect(roleStyle("主角")).toEqual({ + group: "protagonist", + colorVar: "--color-cinnabar", + }); + expect(roleStyle("男主角").group).toBe("protagonist"); + }); + + it("反派/对手/敌 → antagonist + 冲突色", () => { + expect(roleStyle("反派").group).toBe("antagonist"); + expect(roleStyle("对手").group).toBe("antagonist"); + expect(roleStyle("宿敌").group).toBe("antagonist"); + expect(roleStyle("反派").colorVar).toBe("--color-conflict"); + }); + + it("导师/师父 → mentor + info 色", () => { + expect(roleStyle("导师").group).toBe("mentor"); + expect(roleStyle("师父").group).toBe("mentor"); + expect(roleStyle("导师").colorVar).toBe("--color-info"); + }); + + it("CP/恋人 → ally(大小写不敏感)", () => { + expect(roleStyle("CP").group).toBe("ally"); + expect(roleStyle("cp").group).toBe("ally"); + expect(roleStyle("恋人").group).toBe("ally"); + }); + + it("配角/工具人 → supporting", () => { + expect(roleStyle("配角").group).toBe("supporting"); + expect(roleStyle("工具人").group).toBe("supporting"); + }); + + it("未知/空定位 → other(默认)", () => { + expect(roleStyle("神秘存在").group).toBe("other"); + expect(roleStyle("").group).toBe("other"); + expect(roleStyle(" ").group).toBe("other"); + }); +}); + +describe("buildGraphNodes", () => { + it("每个唯一角色一个节点,id=name,role 决定分组/配色", () => { + const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]); + expect(nodes).toHaveLength(2); + expect(nodes[0]).toMatchObject({ + id: "林风", + position: { x: 0, y: 0 }, + data: { + label: "林风", + role: "主角", + group: "protagonist", + colorVar: "--color-cinnabar", + }, + }); + expect(nodes[1]?.data.group).toBe("antagonist"); + }); + + it("跳过空名与重名角色(先到先得)", () => { + const nodes = buildGraphNodes([ + ch("林风", "主角"), + ch(" ", "配角"), + ch("林风", "对手"), + ]); + expect(nodes).toHaveLength(1); + expect(nodes[0]?.data.role).toBe("主角"); + }); + + it("名字两端空白被裁剪用作 id", () => { + const nodes = buildGraphNodes([ch(" 林风 ", "主角")]); + expect(nodes[0]?.id).toBe("林风"); + }); +}); + +describe("buildGraphEdges", () => { + const known = new Set(["林风", "赵石", "青禾"]); + + it("kind → 边标签", () => { + const edges = buildGraphEdges( + [ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])], + known, + ); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ + source: "林风", + target: "赵石", + label: "宿敌", + }); + }); + + it("kind 为空 → 回退到命名常量默认标签", () => { + const edges = buildGraphEdges( + [ch("林风", "主角", [{ name: "青禾", kind: "" }])], + known, + ); + expect(edges[0]?.label).toBe(DEFAULT_RELATION_LABEL); + }); + + it("悬空边(目标未入库)被跳过", () => { + const edges = buildGraphEdges( + [ch("林风", "主角", [{ name: "不存在的人", kind: "师父" }])], + known, + ); + expect(edges).toHaveLength(0); + }); + + it("自指关系被跳过", () => { + const edges = buildGraphEdges( + [ch("林风", "主角", [{ name: "林风", kind: "内心" }])], + known, + ); + expect(edges).toHaveLength(0); + }); + + it("互指关系无向去重为单边", () => { + const edges = buildGraphEdges( + [ + ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }]), + ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]), + ], + known, + ); + expect(edges).toHaveLength(1); + }); + + it("边 id 唯一", () => { + const edges = buildGraphEdges( + [ + ch("林风", "主角", [ + { name: "赵石", kind: "宿敌" }, + { name: "青禾", kind: "青梅竹马" }, + ]), + ], + known, + ); + const ids = edges.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("countDanglingRelations", () => { + it("统计引用未入库角色的关系数", () => { + const known = new Set(["林风"]); + const count = countDanglingRelations( + [ + ch("林风", "主角", [ + { name: "赵石", kind: "宿敌" }, // 悬空 + { name: "青禾", kind: "青梅竹马" }, // 悬空 + ]), + ], + known, + ); + expect(count).toBe(2); + }); + + it("全部已入库时为 0", () => { + const known = new Set(["林风", "赵石"]); + const count = countDanglingRelations( + [ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])], + known, + ); + expect(count).toBe(0); + }); +}); + +describe("layoutGraph", () => { + const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]); + const edges = buildGraphEdges( + [ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])], + new Set(["林风", "赵石"]), + ); + + it("同输入产出相同坐标(确定性)", () => { + const a = layoutGraph(nodes, edges); + const b = layoutGraph(nodes, edges); + expect(a).toEqual(b); + }); + + it("dagre 赋予非零坐标并保持节点数据", () => { + const laid = layoutGraph(nodes, edges); + expect(laid).toHaveLength(2); + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + expect(n.data.label).toBeTruthy(); + } + // 两节点经布局后不再重叠于原点。 + expect(laid[0]?.position).not.toEqual(laid[1]?.position); + }); + + it("不可变:不修改传入节点,返回新对象", () => { + const before = structuredClone(nodes); + const laid = layoutGraph(nodes, edges); + expect(nodes).toEqual(before); // 入参未被修改 + expect(laid[0]).not.toBe(nodes[0]); // 新对象 + }); +}); + +describe("buildRelationshipGraph", () => { + it("端到端:布局后节点 + 有效边 + 悬空计数", () => { + const graph = buildRelationshipGraph([ + ch("林风", "主角", [ + { name: "赵石", kind: "宿敌" }, + { name: "幽灵", kind: "谜团" }, // 悬空 + ]), + ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]), + ]); + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(1); + expect(graph.danglingCount).toBe(1); + // 已布局(非全零)。 + const positions = graph.nodes.map((n) => `${n.position.x},${n.position.y}`); + expect(new Set(positions).size).toBe(2); + }); + + it("空输入 → 空图", () => { + const graph = buildRelationshipGraph([]); + expect(graph.nodes).toEqual([]); + expect(graph.edges).toEqual([]); + expect(graph.danglingCount).toBe(0); + }); +}); diff --git a/apps/web/lib/characters/graphLayout.ts b/apps/web/lib/characters/graphLayout.ts new file mode 100644 index 0000000..9d7a1c4 --- /dev/null +++ b/apps/web/lib/characters/graphLayout.ts @@ -0,0 +1,245 @@ +import dagre from "@dagrejs/dagre"; + +import type { CharacterCardView } from "@/lib/api/types"; + +// 关系图布局纯逻辑(灵感⑥ 缩减版):把「角色 + relations」转成节点/边并跑一次性 +// dagre 初始坐标。仅用现有读端点数据(CharacterRelationView={name,kind,note})—— +// 无 closeness/阵营/target-id,节点按 role 上色,边按 name-join、kind 作中文标签。 +// 无 DOM 依赖,可在 node 环境单测;React Flow 组件只做渲染,不含此处逻辑。 + +// 节点尺寸(dagre 布局与 React Flow 渲染共用;命名常量非魔法值)。 +export const NODE_WIDTH = 176; +export const NODE_HEIGHT = 56; +// dagre 间距。 +export const DAGRE_RANK_SEP = 88; +export const DAGRE_NODE_SEP = 48; +// kind 缺失时的关系标签回退(命名常量非魔法值)。 +export const DEFAULT_RELATION_LABEL = "关系"; + +// 角色定位分组(role 是自由中文文本,无固定枚举 → 关键词映射到有限分组)。 +export type RoleGroup = + | "protagonist" + | "antagonist" + | "mentor" + | "ally" + | "supporting" + | "other"; + +export interface RoleStyle { + group: RoleGroup; + // 纸感主题 CSS 变量名(随亮/暗主题自适应,不写死色值)。 + colorVar: string; +} + +// role→分组关键词规则;按序匹配,首个命中生效,否则回退 other。 +const ROLE_RULES: ReadonlyArray<{ + keywords: readonly string[]; + style: RoleStyle; +}> = [ + { + keywords: ["主角", "主人公", "男主", "女主"], + style: { group: "protagonist", colorVar: "--color-cinnabar" }, + }, + { + keywords: ["反派", "对手", "宿敌", "敌", "boss"], + style: { group: "antagonist", colorVar: "--color-conflict" }, + }, + { + keywords: ["导师", "师父", "师傅", "师尊", "老师"], + style: { group: "mentor", colorVar: "--color-info" }, + }, + { + keywords: ["cp", "恋", "爱人", "伴侣", "挚友", "知己"], + style: { group: "ally", colorVar: "--color-pass" }, + }, + { + keywords: ["配角", "工具人", "路人", "龙套"], + style: { group: "supporting", colorVar: "--color-ink-soft" }, + }, +]; + +const DEFAULT_ROLE_STYLE: RoleStyle = { + group: "other", + colorVar: "--color-ink-soft", +}; + +// 把角色定位映射到分组/配色(大小写不敏感,子串匹配)。 +export function roleStyle(role: string): RoleStyle { + const normalized = role.trim().toLowerCase(); + if (!normalized) { + return DEFAULT_ROLE_STYLE; + } + for (const rule of ROLE_RULES) { + if (rule.keywords.some((keyword) => normalized.includes(keyword))) { + return rule.style; + } + } + return DEFAULT_ROLE_STYLE; +} + +export interface RelationNodeData { + label: string; + role: string; + group: RoleGroup; + colorVar: string; +} + +export interface GraphNode { + id: string; // = 角色名(name-join 主键) + position: { x: number; y: number }; + data: RelationNodeData; +} + +export interface GraphEdge { + id: string; + source: string; + target: string; + label: string; +} + +export interface RelationshipGraphData { + nodes: GraphNode[]; + edges: GraphEdge[]; + // 引用未入库角色而被略过的关系条数(供 UI 提示)。 + danglingCount: number; +} + +// 每个唯一角色一个节点;空名/重名(先到先得)跳过。位置初始 {0,0},由 layoutGraph 赋值。 +export function buildGraphNodes( + characters: readonly CharacterCardView[], +): GraphNode[] { + const seen = new Set(); + const nodes: GraphNode[] = []; + for (const character of characters) { + const name = character.name.trim(); + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + const style = roleStyle(character.role); + nodes.push({ + id: name, + position: { x: 0, y: 0 }, + data: { + label: name, + role: character.role, + group: style.group, + colorVar: style.colorVar, + }, + }); + } + return nodes; +} + +// 无向对键:与方向无关,用于 A↔B 去重。 +function pairKey(a: string, b: string): string { + return [a, b].sort().join(""); +} + +// 按 name-join 构边:kind→标签;悬空边(目标不在 knownNames)与自指跳过;互指无向去重。 +export function buildGraphEdges( + characters: readonly CharacterCardView[], + knownNames: ReadonlySet, +): GraphEdge[] { + const edges: GraphEdge[] = []; + const seenPairs = new Set(); + let index = 0; + for (const character of characters) { + const source = character.name.trim(); + if (!source || !knownNames.has(source)) { + continue; + } + for (const relation of character.relations ?? []) { + const target = relation.name.trim(); + if (!target || !knownNames.has(target) || target === source) { + continue; + } + const key = pairKey(source, target); + if (seenPairs.has(key)) { + continue; + } + seenPairs.add(key); + const label = relation.kind.trim() || DEFAULT_RELATION_LABEL; + edges.push({ + id: `e-${index}-${source}-${target}`, + source, + target, + label, + }); + index += 1; + } + } + return edges; +} + +// 统计引用未入库角色(悬空)的关系条数——供 UI「N 条关系已略过」提示。 +export function countDanglingRelations( + characters: readonly CharacterCardView[], + knownNames: ReadonlySet, +): number { + let count = 0; + for (const character of characters) { + const source = character.name.trim(); + if (!source || !knownNames.has(source)) { + continue; + } + for (const relation of character.relations ?? []) { + const target = relation.name.trim(); + if (target && target !== source && !knownNames.has(target)) { + count += 1; + } + } + } + return count; +} + +interface LaidOutNode { + x: number; + y: number; +} + +// 跑一次性 dagre 布局(确定性:同输入同坐标)。dagre 给中心坐标 → 转 React Flow 左上角。 +// 不可变:返回新节点数组,不修改入参。 +export function layoutGraph( + nodes: readonly GraphNode[], + edges: readonly GraphEdge[], +): GraphNode[] { + const graph = new dagre.graphlib.Graph(); + graph.setGraph({ + rankdir: "LR", + nodesep: DAGRE_NODE_SEP, + ranksep: DAGRE_RANK_SEP, + }); + graph.setDefaultEdgeLabel(() => ({})); + for (const node of nodes) { + graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + for (const edge of edges) { + graph.setEdge(edge.source, edge.target); + } + dagre.layout(graph); + return nodes.map((node) => { + const laid = graph.node(node.id) as LaidOutNode | undefined; + const centerX = laid?.x ?? 0; + const centerY = laid?.y ?? 0; + return { + ...node, + position: { + x: centerX - NODE_WIDTH / 2, + y: centerY - NODE_HEIGHT / 2, + }, + }; + }); +} + +// 端到端:角色列表 → 布局后的节点 + 有效边 + 悬空计数(组件里 useMemo 缓存)。 +export function buildRelationshipGraph( + characters: readonly CharacterCardView[], +): RelationshipGraphData { + const nodes = buildGraphNodes(characters); + const knownNames = new Set(nodes.map((node) => node.id)); + const edges = buildGraphEdges(characters, knownNames); + const danglingCount = countDanglingRelations(characters, knownNames); + const laidOut = layoutGraph(nodes, edges); + return { nodes: laidOut, edges, danglingCount }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 632606c..9f5b285 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,8 @@ "gen:api": "node scripts/gen-api.mjs" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@xyflow/react": "^12.11.2", "lucide-react": "^1.21.0", "next": "15.1.3", "openapi-fetch": "^0.13.0", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index d177a0f..faaa66a 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 + '@xyflow/react': + specifier: ^12.11.2 + version: 12.11.2(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) lucide-react: specifier: ^1.21.0 version: 1.21.0(react@19.0.0) @@ -166,6 +172,12 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -790,6 +802,24 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1025,6 +1055,22 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@xyflow/react@12.11.2': + resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.79': + resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1236,6 +1282,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -1279,6 +1328,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2662,6 +2749,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2798,6 +2890,21 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2878,6 +2985,12 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -3322,6 +3435,27 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -3559,6 +3693,31 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@xyflow/react@12.11.2(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@xyflow/system': 0.0.79 + classcat: 5.0.5 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + zustand: 4.5.7(@types/react@19.0.0)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.0 + '@types/react-dom': 19.0.0 + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.79': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -3790,6 +3949,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + classcat@5.0.5: {} + client-only@0.0.1: {} color-convert@2.0.1: @@ -3831,6 +3992,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + damerau-levenshtein@1.0.8: {} data-urls@7.0.0: @@ -5532,6 +5729,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.0.0): + dependencies: + react: 19.0.0 + util-deprecate@1.0.2: {} vite-node@2.1.9(@types/node@22.19.21)(supports-color@10.2.2): @@ -5686,3 +5887,10 @@ snapshots: yargs-parser@21.1.1: {} yocto-queue@0.1.0: {} + + zustand@4.5.7(@types/react@19.0.0)(react@19.0.0): + dependencies: + use-sync-external-store: 1.6.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.0 + react: 19.0.0 diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml index 00a80a3..a9b318b 100644 --- a/apps/web/pnpm-workspace.yaml +++ b/apps/web/pnpm-workspace.yaml @@ -4,6 +4,9 @@ allowBuilds: esbuild: true sharp: true unrs-resolver: true +minimumReleaseAgeExclude: + - '@xyflow/react@12.11.2' + - '@xyflow/system@0.0.79' onlyBuiltDependencies: - esbuild - sharp diff --git a/memory/contracts.md b/memory/contracts.md index 931de89..ca36973 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -365,3 +365,5 @@ - [2026-06-20] @backend 再扩 C3(B0 **可控版**,接上条):新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** ← `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` → 返回 `InjectionResponse`(同上,**新增回显字段 `pinned`/`excluded`**,`selected[].reasons` 可含新值 **`author_pin`**)。语义:pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除(**优先于 pin**)/ recent_n 覆盖近况回看章数。GET injection 与 **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`,复用 outline 行已否决);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参、`assemble` 加 `override` 关键字参;新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。→ 影响 @frontend(已 `pnpm gen:api`;F1 加 pin/排除控件 + recent_n 步进器)。 - [2026-06-18] @llm 改 C1:`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` 在 schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed`;无 schema 时 `parsed is None`、纯文本路径不变;记账仍 **1 条 usage_ledger**/调用(usage 从 raw completion 提取)。`ProviderResult` 新增 `parsed` 字段。结构化路径经可注入 `StructuredClient` Protocol 注 fake(测试不联网)。→ 影响 T2.2(续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent。 + +- [2026-07-06] @frontend 前端依赖新增(灵感⑥ 关系图谱缩减版 / D4,**无后端契约变更、无 gen:api、无迁移**):`apps/web/package.json` 加 `@xyflow/react@^12.11.2`(React Flow,MIT,peer react>=17 兼容 R19)+ `@dagrejs/dagre@^3.0.0`(一次性初始布局,自带 TS 类型)。`pnpm install` 后 `pnpm-workspace.yaml` 被 pnpm 自动追加 `minimumReleaseAgeExclude`(@xyflow/react/@xyflow/system 供应链豁免)+ `pnpm-lock.yaml` 更新。新增 `lib/characters/graphLayout.ts`(纯逻辑:角色+relations→节点/边+dagre 布局,仅用现有 `CharacterCardView.relations={name,kind,note}`——**边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营**,悬空边跳过并计数)+ `components/characters/RelationshipGraph.tsx`(`'use client'`,经 CodexPage `dynamic(ssr:false)` 加载,显式容器高度 + import RF CSS + prefers-reduced-motion 关 fitView 动画 + 纸感配色)。消费方:无(叶子特性,挂在设定库人物 tab)。