feat(web): 角色关系图谱(灵感⑥缩减版/D4)

设定库人物 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。
This commit is contained in:
Yaojia Wang
2026-07-06 17:38:58 +02:00
parent 7babb4854b
commit 3d2a8b8cee
9 changed files with 896 additions and 1 deletions

View File

@@ -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余 4rem6 个固定高度页改用 `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。TDDai-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/reactCodexPage 经 `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。TDDgraphLayout **+22 测**role 映射/节点去空重名/kind 标签+默认/悬空跳过/自指跳过/无向去重/边 id 唯一/悬空计数/布局确定性+不可变/端到端);组件走人工/E2E。前端门禁全绿lint 干净 · typecheck 干净 · **vitest 565 passed** · **coverage lib 95.4%graphLayout.ts 97.6%** · **build OK**codex 路由 7.58 kBReact Flow 懒加载独立 chunk。注`pnpm build` 门禁与运行中的 `next dev`(3001) 共享 `.next` → 触发 dev 服务临时 500stale chunk非代码缺陷、dev watcher 下次重编自愈,未重启服务。

View File

@@ -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 (
<div className="min-w-0">
<p className="truncate font-serif text-sm text-ink">{name}</p>
{role ? <p className="truncate text-2xs text-ink-soft">{role}</p> : null}
</div>
);
}
function toReactFlowNodes(nodes: GraphNode[]): Node[] {
return nodes.map((node) => ({
id: node.id,
position: node.position,
data: { label: <NodeLabel name={node.data.label} role={node.data.role} /> },
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 (
<EmptyState
icon={Users}
title="暂无可视化的人物"
description="先入库角色并登记彼此关系,这里会绘制出关系图谱。"
/>
);
}
return (
<div className="flex flex-col gap-2">
{/* React Flow 必须有显式尺寸的容器,否则画布高度为 0。 */}
<div className="h-[28rem] w-full overflow-hidden rounded border border-line bg-bg">
<ReactFlow
nodes={nodes}
edges={edges}
fitView
fitViewOptions={{ padding: 0.2, duration: reducedMotion ? 0 : 400 }}
nodesConnectable={false}
edgesFocusable={false}
proOptions={{ hideAttribution: false }}
minZoom={0.2}
maxZoom={1.75}
defaultEdgeOptions={{ animated: false }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-line)"
/>
<Controls showInteractive={false} />
</ReactFlow>
</div>
{graph.danglingCount > 0 ? (
<p className="text-2xs text-ink-soft">
{graph.danglingCount}
</p>
) : null}
</div>
);
}

View File

@@ -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: () => (
<div className="flex h-[28rem] items-center justify-center rounded border border-line bg-bg text-sm text-ink-soft">
</div>
),
},
);
// 设定库 CodexUX §6.5):管理人物 / 世界观 / 时间线。
// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显,
// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards
@@ -143,6 +161,15 @@ export function CodexPage({
/>
)}
</Card>
{characters.length > 0 ? (
<Card as="section" className="p-4">
<h3 className="mb-3 flex items-center gap-2 font-serif text-sm text-ink">
<Share2 className="h-4 w-4 text-cinnabar" aria-hidden="true" />
</h3>
<RelationshipGraph characters={characters} />
</Card>
) : null}
<div id="character-generator">
<CharacterGenerator
projectId={project.id}

View File

@@ -0,0 +1,258 @@
import { describe, expect, it } from "vitest";
import type { CharacterCardView } from "@/lib/api/types";
import {
buildGraphEdges,
buildGraphNodes,
buildRelationshipGraph,
countDanglingRelations,
DEFAULT_RELATION_LABEL,
layoutGraph,
roleStyle,
} from "./graphLayout";
// 最小角色卡工厂CharacterCardView 多字段必填,测试只关心 name/role/relations。
function ch(
name: string,
role: string,
relations: { name: string; kind?: string; note?: string | null }[] = [],
): CharacterCardView {
return {
name,
role,
traits: [],
appearance: "",
motive: "",
backstory: "",
arc: "",
speech_tics: [],
relations: relations.map((r) => ({
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=namerole 决定分组/配色", () => {
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);
});
});

View File

@@ -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<string>();
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<string>,
): GraphEdge[] {
const edges: GraphEdge[] = [];
const seenPairs = new Set<string>();
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<string>,
): 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 };
}

View File

@@ -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",

208
apps/web/pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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

View File

@@ -365,3 +365,5 @@
- [2026-06-20] @backend 再扩 C3B0 **可控版**接上条新增 **`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 FlowMITpeer 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