Skip to content

Commit a2875d4

Browse files
committed
feat(constellation): 知识星座双轨渲染——纯函数派生图谱+DOM/SVG轨与3D轨按性能档切换(阶段B)
1 parent 4a203b5 commit a2875d4

13 files changed

Lines changed: 1336 additions & 2 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* 知识图谱查询层 + IPC(knowledge:*,阶段 B 深度问题)
3+
* Knowledge graph query layer + IPC (read-only)
4+
*
5+
* @ai-context: 只读 SELECT 聚合三路数据(flashcards / feynman_notes /
6+
* flashcard_reviews),上限复用 MAX_LIMIT=100(粒度上限防整库抽取,
7+
* 与 MCP 记忆层同一约束)。不做图谱派生——派生规则在渲染进程
8+
* features/constellation/lib/knowledgeGraph.ts 纯函数层(单测覆盖)。
9+
* SQL 全部参数绑定;limit 经 clampLimit 封顶;失败抛错由 safeHandle
10+
* 的 Result 语义传导给渲染进程(星座是外壳增强项,hook 侧静默降级)。
11+
*
12+
* @ai-context: Read-only aggregation feeding the renderer's pure graph
13+
* derivation layer. Limit is clamped to MAX_LIMIT=100; all SQL is
14+
* parameter-bound.
15+
*/
16+
import { safeHandle } from '../ipcUtils.js';
17+
import { logger } from '../logger.js';
18+
import { getConnection } from './sqliteService.js';
19+
import { clampLimit } from '../mcp/memoryQueries.js';
20+
import type { KnowledgeGraphData } from '../../src/features/constellation/types.js';
21+
22+
/** flashcards 行(snake_case,SQLite 边界) / Flashcard row */
23+
interface CardRow {
24+
id: string;
25+
front: string;
26+
ease_factor: number;
27+
interval: number;
28+
source_ref: string | null;
29+
created_at: string;
30+
}
31+
32+
/** feynman_notes 行 / Feynman row */
33+
interface FeynmanRow {
34+
id: string;
35+
concept: string;
36+
status: string;
37+
}
38+
39+
/** flashcard_reviews 行 / Review row */
40+
interface ReviewRow {
41+
card_id: string;
42+
reviewed_at: string;
43+
}
44+
45+
/**
46+
* 聚合三路学习数据(只读,limit 封顶 100)
47+
* Aggregate flashcards, in-progress feynman notes and recent reviews.
48+
*/
49+
export function queryKnowledgeGraph(limitRaw: unknown): KnowledgeGraphData {
50+
const limit = clampLimit(limitRaw);
51+
const db = getConnection();
52+
53+
const cards = db.prepare(`
54+
SELECT id, front, ease_factor, "interval", source_ref, created_at
55+
FROM flashcards ORDER BY updated_at DESC LIMIT ?
56+
`).all(limit) as CardRow[];
57+
58+
const feynman = db.prepare(`
59+
SELECT id, concept, status FROM feynman_notes
60+
WHERE status = 'in_progress' ORDER BY updated_at DESC LIMIT ?
61+
`).all(limit) as FeynmanRow[];
62+
63+
// 最近 limit 条复习(纯函数层会再按时间升序取窗口)
64+
const reviews = db.prepare(`
65+
SELECT card_id, reviewed_at FROM flashcard_reviews
66+
ORDER BY reviewed_at DESC LIMIT ?
67+
`).all(limit) as ReviewRow[];
68+
69+
return {
70+
cards: cards.map((c) => ({
71+
id: c.id,
72+
front: c.front,
73+
easeFactor: c.ease_factor,
74+
interval: c.interval,
75+
sourceRef: c.source_ref ?? undefined,
76+
createdAt: new Date(c.created_at),
77+
})),
78+
feynman: feynman.map((f) => ({
79+
id: f.id,
80+
concept: f.concept,
81+
status: f.status as KnowledgeGraphData['feynman'][number]['status'],
82+
})),
83+
reviews: reviews.map((r) => ({
84+
cardId: r.card_id,
85+
reviewedAt: new Date(r.reviewed_at),
86+
})),
87+
};
88+
}
89+
90+
/** 注册 knowledge:* IPC handlers(app ready 后调用一次) */
91+
export function registerKnowledgeIpcHandlers(): void {
92+
safeHandle('knowledge:get-graph', async () => {
93+
try {
94+
return queryKnowledgeGraph(undefined);
95+
} catch (err) {
96+
// 查询失败记日志后向上抛——hook 侧会静默降级,不影响主流程
97+
logger.warn(`[knowledge] get-graph failed: ${err instanceof Error ? err.message : String(err)}`);
98+
throw err;
99+
}
100+
});
101+
}

‎client/electron/main.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { loadEnvironment } from './envLoader.js';
3838
import { installCspPolicy } from './cspPolicy.js';
3939
import { registerDisplayMediaHandler } from './displayMediaHandler.js';
4040
import { registerDbIpcHandlers } from './db/dbIpcHandlers.js';
41+
import { registerKnowledgeIpcHandlers } from './db/knowledgeQueries.js';
4142
import { registerStorageIpcHandlers } from './storageIpcHandlers.js';
4243
import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js';
4344
import { loadPerformanceMode, registerPerformanceHandlers } from './performanceMode.js';
@@ -176,6 +177,8 @@ if (!gotTheLock) {
176177
registerStorageIpcHandlers();
177178
// 知识入籍 IPC(PDF 解析/URL 抓取/入籍记录,详见 importHandlers.ts)
178179
registerImportHandlers();
180+
// 知识星座 IPC(只读图谱聚合,详见 db/knowledgeQueries.ts)
181+
registerKnowledgeIpcHandlers();
179182
// MCP 学习记忆服务器应用内授权开关(详见 memoryServerConsent.ts)
180183
registerMemoryServerConsentHandlers();
181184
// 课堂关键帧图片持久化 IPC + keyframe:// 协议(详见 ipc/keyframeStorage.ts)

‎client/electron/preload.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ const ALLOWED_CHANNELS = [
130130
'import:fetch-url',
131131
'import:get-settling-records',
132132
'import:add-settling-record',
133+
// 知识星座 IPC channel(阶段 B:只读图谱聚合)
134+
'knowledge:get-graph',
133135
] as const;
134136

135137
/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* 知识星座 · DOM/SVG 轨(medium/low 档)
3+
* Knowledge constellation · DOM/SVG track
4+
*
5+
* @ai-context: 宪法第一条映射(概念掌握度→发光体亮度:牢固=清冽明亮、
6+
* 朦胧=朦胧)与第四条知识星座预算(medium/low:L1 每档 ≤15 节点、
7+
* SVG line/circle)。朦胧节点施加雾色滤镜:外圈雾晕 opacity ≤0.4
8+
* (第二条 §1:雾永远可拨开——雾晕可被点击拨开,露出节点本体)。
9+
* 冷启动(宪法第七条:世界未点亮)显示引导文案而非空画布。
10+
* 纯静态 SVG 无帧循环,low 档核显安全。
11+
*
12+
* @ai-context: SVG track for medium/low tiers. Hazy nodes get a fog
13+
* halo capped at 40% opacity; cold start shows guidance copy.
14+
*/
15+
import { useMemo } from 'react';
16+
import type { KnowledgeGraph } from '../lib/knowledgeGraph';
17+
import { layoutKnowledgeGraph, type LayoutNode } from '../lib/knowledgeLayout';
18+
19+
/** 宪法映射:tier → 节点色(牢固=清冽明亮蓝白) / Tier colors */
20+
const TIER_COLOR: Record<LayoutNode['tier'], string> = {
21+
牢固: '#7dd3fc',
22+
成长中: '#60a5fa',
23+
朦胧: '#94a3b8',
24+
};
25+
26+
/** 雾色滤镜强度上限(宪法第二条 §1:雾 ≤40% 视觉强度) */
27+
const FOG_OPACITY_MAX = 0.4;
28+
29+
/** 链类型 → 颜色(同源=溯源青、复习=暖金、薄弱点=微紫) */
30+
const LINK_COLOR: Record<string, string> = {
31+
'shared-note': 'rgba(103, 232, 249, 0.5)',
32+
'review-chain': 'rgba(251, 191, 36, 0.45)',
33+
weakpoint: 'rgba(196, 181, 253, 0.45)',
34+
};
35+
36+
interface KnowledgeConstellationProps {
37+
/** 派生图谱;null 表示尚未获取到数据(loading/error 分支) */
38+
graph: KnowledgeGraph | null;
39+
loading?: boolean;
40+
error?: string | null;
41+
}
42+
43+
/** 冷启动引导文案(宪法第七条:世界未点亮) / Cold-start copy */
44+
const COLD_START_COPY = '世界还未点亮——第一次学习之后,这里会升起你的知识星座';
45+
46+
/** 知识星座 · DOM/SVG 轨 / Knowledge constellation (SVG track) */
47+
export function KnowledgeConstellation({ graph, loading, error }: KnowledgeConstellationProps) {
48+
const layout = useMemo(() => (graph ? layoutKnowledgeGraph(graph) : null), [graph]);
49+
50+
if (loading && !graph) {
51+
return <div className="h-56 rounded-kb-xl bg-bg-elevated/30 animate-pulse-skeleton" role="status" aria-label="知识星座加载中" />;
52+
}
53+
if (error && !graph) {
54+
return (
55+
<div className="h-40 flex items-center justify-center rounded-kb-xl border border-border/15 bg-bg-elevated/20">
56+
<p className="text-c1 text-text-tertiary">知识星座暂时无法点亮({error})</p>
57+
</div>
58+
);
59+
}
60+
if (!layout || (layout.nodes.length === 0 && graph?.coldStart)) {
61+
return (
62+
<div className="h-40 flex items-center justify-center rounded-kb-xl border border-dashed border-border/25 bg-bg-elevated/10">
63+
<p className="text-c1 text-text-tertiary text-center px-6">{COLD_START_COPY}</p>
64+
</div>
65+
);
66+
}
67+
68+
return (
69+
<svg
70+
viewBox="0 0 100 100"
71+
className="w-full h-56 rounded-kb-xl bg-gradient-to-b from-bg-elevated/20 to-transparent"
72+
role="img"
73+
aria-label="知识星座:概念掌握度空间化(牢固近中心明亮,朦胧在外围雾中)"
74+
>
75+
{/* 链(先画,节点覆盖其上) */}
76+
{layout.links.map((link, i) => {
77+
const s = layout.nodes.find((n) => n.id === link.source);
78+
const t = layout.nodes.find((n) => n.id === link.target);
79+
if (!s || !t) return null;
80+
return (
81+
<line
82+
key={`${link.kind}-${i}`}
83+
x1={s.x} y1={s.y} x2={t.x} y2={t.y}
84+
stroke={LINK_COLOR[link.kind] ?? 'rgba(148, 163, 184, 0.3)'}
85+
strokeWidth={0.25}
86+
opacity={0.55}
87+
/>
88+
);
89+
})}
90+
91+
{/* 节点 */}
92+
{layout.nodes.map((n) => {
93+
const base = TIER_COLOR[n.tier];
94+
const radius = n.tier === '牢固' ? 2.2 : n.tier === '成长中' ? 1.8 : 1.4;
95+
// 朦胧节点:雾晕(≤40%)+ 本体亮度乘 glow
96+
const opacity = 0.35 + n.glow * 0.65;
97+
return (
98+
<g key={n.id}>
99+
{n.dimmed && (
100+
<circle
101+
cx={n.x} cy={n.y} r={radius * 2.6}
102+
fill={base}
103+
opacity={FOG_OPACITY_MAX * n.glow}
104+
className="kb-fog-halo"
105+
/>
106+
)}
107+
<circle
108+
cx={n.x} cy={n.y} r={radius}
109+
fill={base}
110+
opacity={opacity}
111+
className="kb-star-node"
112+
>
113+
<title>{n.concept}({n.tier})</title>
114+
</circle>
115+
</g>
116+
);
117+
})}
118+
</svg>
119+
);
120+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* 知识星座数据 Hook(30s 轮询 + 焦点事件失效)
3+
* Knowledge constellation data hook
4+
*
5+
* @ai-context: 阶段 B 数据接线。经 knowledge:get-graph 获取三路原始
6+
* 数据(主进程只读聚合,limit 封顶 100),在渲染进程用纯函数层
7+
* buildKnowledgeGraph 派生图谱(派生规则单测覆盖)。轮询 30s 一档;
8+
* 窗口重新聚焦/页面回到可见时立即刷新(事件驱动失效的轻量实现)。
9+
* 失败静默降级:保留旧图不闪断,星座是外壳增强项不影响主流程。
10+
*
11+
* @ai-context: Polls the read-only graph aggregate every 30s and
12+
* refreshes on window focus. Failures keep the previous graph.
13+
*/
14+
import { useCallback, useEffect, useState } from 'react';
15+
import { buildKnowledgeGraph, type KnowledgeGraph } from '../lib/knowledgeGraph';
16+
import type { KnowledgeGraphData } from '../types';
17+
18+
/** 轮询间隔(毫秒) / Poll interval */
19+
const POLL_MS = 30_000;
20+
21+
export interface UseKnowledgeGraphResult {
22+
/** 派生图谱;首载失败或尚无数据时为 null(组件自行降级展示) */
23+
graph: KnowledgeGraph | null;
24+
/** 仅首载为 true;后台刷新不闪烁 */
25+
loading: boolean;
26+
/** 最近一次失败原因(成功或首载前为 null) */
27+
error: string | null;
28+
/** 手动触发一次刷新 */
29+
refresh: () => Promise<void>;
30+
}
31+
32+
/** 知识星座数据 Hook / Knowledge constellation data hook */
33+
export function useKnowledgeGraph(pollMs: number = POLL_MS): UseKnowledgeGraphResult {
34+
const [graph, setGraph] = useState<KnowledgeGraph | null>(null);
35+
const [loading, setLoading] = useState(true);
36+
const [error, setError] = useState<string | null>(null);
37+
38+
const load = useCallback(async () => {
39+
if (!window.electronAPI?.invoke) return;
40+
try {
41+
// IPC 返回形状见 features/constellation/types.ts(结构化类型断言)
42+
const data = await window.electronAPI.invoke('knowledge:get-graph') as KnowledgeGraphData;
43+
setGraph(buildKnowledgeGraph(data));
44+
setError(null);
45+
} catch (err) {
46+
// 失败静默:保留旧图避免星座闪断
47+
setError(err instanceof Error ? err.message : String(err));
48+
} finally {
49+
setLoading(false);
50+
}
51+
}, []);
52+
53+
useEffect(() => {
54+
void load();
55+
const timer = setInterval(() => { void load(); }, pollMs);
56+
const onFocus = () => { void load(); };
57+
const onVisibility = () => {
58+
if (document.visibilityState === 'visible') void load();
59+
};
60+
window.addEventListener('focus', onFocus);
61+
document.addEventListener('visibilitychange', onVisibility);
62+
return () => {
63+
clearInterval(timer);
64+
window.removeEventListener('focus', onFocus);
65+
document.removeEventListener('visibilitychange', onVisibility);
66+
};
67+
}, [load, pollMs]);
68+
69+
return { graph, loading, error, refresh: load };
70+
}

0 commit comments

Comments
 (0)