Skip to content

Commit 3cc1f91

Browse files
committed
fix(feynman,flashcards,pomodoro,ai-gateway): 修复代码审查发现的创新功能缺陷
资源泄漏与竞态(费曼录音器): - fix(feynman): 启动中途卸载/ASR 抛异常时回收麦克风采集,杜绝残留占用 阻塞其他音频功能(startingRef + captureStarted 双保险) - fix(feynman): startingRef 同步上锁防双击重复启动(监听器泄漏/文本重复) - fix(feynman): stop 先 await ASR flush 再退订,最后一段转写不再丢失 - fix(feynman): playbackUrlRef 修复卸载闭包捕获 stale null 导致的 URL 泄漏 - feat(feynman): 录音 10 分钟时长上限,防止 PCM 内存无限增长 数据正确性: - fix(flashcards): 恢复包记忆回响改用 TipTap JSON 递归提取纯文本 (原 HTML 正则对 JSON content 无效,AI 收到 JSON 噪声) - fix(ai-gateway): 5 条新路由 fallback 空结果不再写入 Redis 缓存, 避免一次解析失败导致该请求 1 小时内持续静默失效 - fix(feynman): 苏格拉底薄弱点写入独立静默失败,笔记已落库后不再 误报保存失败(原逻辑重试会创建重复笔记) - fix(flashcards): 恢复包 totalDue 统计全部到期卡,与 UI 文案一致 体验修复: - fix(pomodoro): 锚点提醒计时器在会话停止时重置,下一个番茄不再 立即触发提醒;专注场景锚点生成静默(不播放音效) - fix(feynman): 概念网络图禁用无效的受控节点拖拽 - fix(feynman): 预检卡关闭条件移入 AnimatePresence 以播放 exit 动画; dismiss 记录改存时间戳并在超 100 条时淘汰最旧 - fix(notes): 复制核心概念失败时给出错误提示 - refactor(notes): extractText 提取为共享工具 extractNoteText 验证:客户端 vitest 731/731、tsc+vite 构建、oxlint 0 errors、网关 pytest 181 passed
1 parent 73d1d6b commit 3cc1f91

16 files changed

Lines changed: 192 additions & 76 deletions

File tree

‎client/src/features/feynman/components/ConceptPrecheckCard.tsx‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import { getGoldenErrorRecords } from '@/features/flashcards/lib/goldenErrorQuer
1717
const WEAK_POINT_LIMIT = 20;
1818
/** 黄金错误取最近 N 条参与摘要 */
1919
const GOLDEN_ERROR_LIMIT = 10;
20+
/** dismiss key 前缀与保留上限:避免 localStorage 按 noteId 无限累积 */
21+
const DISMISS_PREFIX = 'feynman_precheck_dismissed_';
22+
const DISMISS_KEEP = 100;
2023

2124
interface ConceptPrecheckCardProps {
2225
concept: string;
@@ -39,8 +42,8 @@ async function buildWeakHistory(): Promise<string> {
3942
}
4043

4144
export function ConceptPrecheckCard({ concept, noteId }: ConceptPrecheckCardProps) {
42-
const dismissKey = `feynman_precheck_dismissed_${noteId}`;
43-
const [dismissed, setDismissed] = useState(() => localStorage.getItem(dismissKey) === '1');
45+
const dismissKey = `${DISMISS_PREFIX}${noteId}`;
46+
const [dismissed, setDismissed] = useState(() => localStorage.getItem(dismissKey) !== null);
4447
const [intentOpen, setIntentOpen] = useState(false);
4548
const { result, loading, error, precheck } = useAIConceptPrecheck();
4649

@@ -54,16 +57,32 @@ export function ConceptPrecheckCard({ concept, noteId }: ConceptPrecheckCardProp
5457
}, [concept, dismissed, precheck]);
5558

5659
const dismiss = () => {
57-
localStorage.setItem(dismissKey, '1');
60+
localStorage.setItem(dismissKey, String(Date.now()));
5861
setDismissed(true);
62+
// 控制总量:超出保留上限时淘汰最旧的 dismiss 记录
63+
const entries: Array<{ key: string; ts: number }> = [];
64+
for (let i = 0; i < localStorage.length; i++) {
65+
const key = localStorage.key(i);
66+
if (key?.startsWith(DISMISS_PREFIX)) {
67+
entries.push({ key, ts: Number(localStorage.getItem(key)) || 0 });
68+
}
69+
}
70+
if (entries.length > DISMISS_KEEP) {
71+
entries
72+
.sort((a, b) => a.ts - b.ts)
73+
.slice(0, entries.length - DISMISS_KEEP)
74+
.forEach((e) => localStorage.removeItem(e.key));
75+
}
5976
};
6077

6178
const questions = result?.questions ?? [];
62-
// 静默场景:已关闭 / 加载中 / 失败 / 无问题 → 不渲染(可选增强不打扰)
63-
if (dismissed || loading || !!error || questions.length === 0) return null;
79+
// 静默场景:已关闭 / 加载中 / 失败 / 无问题 → 不渲染(可选增强不打扰)。
80+
// 条件置于 AnimatePresence 内部,关闭时才能播放 exit 动画
81+
const hidden = dismissed || loading || !!error || questions.length === 0;
6482

6583
return (
6684
<AnimatePresence>
85+
{!hidden && (
6786
<motion.div
6887
initial={{ opacity: 0, y: -8 }}
6988
animate={{ opacity: 1, y: 0 }}
@@ -121,6 +140,7 @@ export function ConceptPrecheckCard({ concept, noteId }: ConceptPrecheckCardProp
121140
</ul>
122141
)}
123142
</motion.div>
143+
)}
124144
</AnimatePresence>
125145
);
126146
}

‎client/src/features/feynman/components/FeynmanRecorder.tsx‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ interface FeynmanRecorderProps {
1616
onExplanationChange: (v: string) => void;
1717
}
1818

19+
/** 单次录音时长上限:防止 PCM 累积无限增长(10 分钟 ≈ 19MB) */
20+
const MAX_RECORD_MS = 10 * 60 * 1000;
21+
1922
/** base64 → Blob(会话内回放用) */
2023
function base64ToBlob(base64: string, type: string): Blob {
2124
const binary = atob(base64);
@@ -38,9 +41,13 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
3841
onChangeRef.current = onExplanationChange;
3942

4043
const recordingRef = useRef(false);
44+
const startingRef = useRef(false);
4145
const samplesRef = useRef<Float32Array[]>([]);
4246
const sampleRateRef = useRef(16000);
4347
const unsubsRef = useRef<Array<() => void>>([]);
48+
const recordLimitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
49+
// ref 同步最新 playback URL:卸载闭包只捕获初始值,必须经 ref 才能正确 revoke
50+
const playbackUrlRef = useRef<string | null>(null);
4451

4552
// 可用性探测:ASR 模型未就绪则隐藏入口
4653
useEffect(() => {
@@ -56,13 +63,28 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
5663
unsubsRef.current = [];
5764
}, []);
5865

66+
/** 更新回放 URL 并回收旧的(ref 同步,保证卸载时可回收) */
67+
const updatePlaybackUrl = useCallback((next: string | null) => {
68+
if (playbackUrlRef.current) URL.revokeObjectURL(playbackUrlRef.current);
69+
playbackUrlRef.current = next;
70+
setPlaybackUrl(next);
71+
}, []);
72+
5973
const stop = useCallback(async () => {
6074
if (!recordingRef.current) return;
61-
recordingRef.current = false;
62-
cleanupIpc();
75+
if (recordLimitTimerRef.current) {
76+
clearTimeout(recordLimitTimerRef.current);
77+
recordLimitTimerRef.current = null;
78+
}
6379
const api = window.electronAPI;
6480
try {
81+
// 先停 ASR:主进程会 flush 最后一段 final,此时订阅仍在且
82+
// recordingRef 仍为 true,最后一句话能正常追加进讲解文本
6583
await api?.invoke('local_asr_stream_stop');
84+
} catch { /* 静默降级 */ }
85+
recordingRef.current = false;
86+
cleanupIpc();
87+
try {
6688
await api?.invoke('audio_capture_stop');
6789
} catch { /* 静默降级 */ }
6890
setRecording(false);
@@ -78,18 +100,18 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
78100
for (const c of chunks) { merged.set(c, offset); offset += c.length; }
79101
try {
80102
const wavBase64 = encodeWavBase64(merged, sampleRateRef.current, 1);
81-
setPlaybackUrl((prev) => {
82-
if (prev) URL.revokeObjectURL(prev);
83-
return URL.createObjectURL(base64ToBlob(wavBase64, 'audio/wav'));
84-
});
103+
updatePlaybackUrl(URL.createObjectURL(base64ToBlob(wavBase64, 'audio/wav')));
85104
} catch { /* 编码失败不影响转写文本 */ }
86105
}
87-
}, [cleanupIpc]);
106+
}, [cleanupIpc, updatePlaybackUrl]);
88107

89108
const start = useCallback(async () => {
90109
const api = window.electronAPI;
91-
if (!api || recordingRef.current) return;
110+
// startingRef 同步上锁:防止异步启动期间双击重复启动(监听器泄漏/文本重复追加)
111+
if (!api || recordingRef.current || startingRef.current) return;
112+
startingRef.current = true;
92113
setError(null);
114+
let captureStarted = false;
93115
try {
94116
const status = await api.invoke('audio_capture_status') as { active: boolean };
95117
if (status?.active) {
@@ -103,15 +125,20 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
103125
setError(capture?.error || '麦克风启动失败,请检查设备与权限');
104126
return;
105127
}
128+
captureStarted = true;
106129
const asrStart = await api.invoke('local_asr_stream_start', { sampleRate: 16000 }) as { success: boolean };
107130
if (!asrStart?.success) {
108-
await api.invoke('audio_capture_stop');
131+
await api.invoke('audio_capture_stop').catch(() => {});
109132
setError('语音识别启动失败,请重试');
110133
return;
111134
}
112135
} catch {
136+
// 异常若发生在本组件已启动采集之后,必须回收麦克风,避免残留占用阻塞其他音频功能
137+
if (captureStarted) api.invoke('audio_capture_stop').catch(() => {});
113138
setError('录音启动失败,请重试');
114139
return;
140+
} finally {
141+
startingRef.current = false;
115142
}
116143

117144
samplesRef.current = [];
@@ -141,13 +168,19 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
141168

142169
recordingRef.current = true;
143170
setRecording(true);
144-
}, []);
171+
// 时长上限自动停止:避免长时间录音 PCM 内存无限增长
172+
recordLimitTimerRef.current = setTimeout(() => { void stop(); }, MAX_RECORD_MS);
173+
}, [stop]);
145174

146175
// 卸载兜底:释放麦克风与 ASR 流、回收 blob URL
147176
useEffect(() => {
148177
return () => {
149-
if (recordingRef.current) {
178+
if (recordLimitTimerRef.current) clearTimeout(recordLimitTimerRef.current);
179+
// startingRef 覆盖"启动中途卸载"竞态:capture 已在主进程启动但
180+
// recordingRef 尚未置 true,此时也必须释放,否则麦克风残留占用
181+
if (recordingRef.current || startingRef.current) {
150182
recordingRef.current = false;
183+
startingRef.current = false;
151184
cleanupIpc();
152185
const api = window.electronAPI;
153186
api?.invoke('local_asr_stream_stop').catch(() => {});
@@ -156,8 +189,7 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
156189
};
157190
}, [cleanupIpc]);
158191
useEffect(() => {
159-
return () => { if (playbackUrl) URL.revokeObjectURL(playbackUrl); };
160-
// eslint-disable-next-line react-hooks/exhaustive-deps
192+
return () => { if (playbackUrlRef.current) URL.revokeObjectURL(playbackUrlRef.current); };
161193
}, []);
162194

163195
// 可选增强:ASR 不可用时隐藏入口
@@ -189,7 +221,7 @@ export function FeynmanRecorder({ explanation, onExplanationChange }: FeynmanRec
189221
<Play className="w-3.5 h-3.5" strokeWidth={1.5} />
190222
<audio src={playbackUrl} controls className="h-7 max-w-[200px]" />
191223
<button
192-
onClick={() => { URL.revokeObjectURL(playbackUrl); setPlaybackUrl(null); }}
224+
onClick={() => updatePlaybackUrl(null)}
193225
className="p-1 rounded hover:text-red-500 transition-colors"
194226
title="删除录音"
195227
>

‎client/src/features/feynman/hooks/useSocraticFlow.tsx‎

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -249,35 +249,41 @@ export function useSocraticFlow() {
249249

250250
const handleDeepeningSubmit = useCallback(async (answers: Record<string, string>) => {
251251
setSavingNote(true);
252-
try {
253-
const explanation = [
254-
`## 苏格拉底式学习:${topic}\n`,
255-
`### 发散思考`,
256-
...selected.map(s => `- ${s}`),
257-
'',
258-
`### 追问对话摘要`,
259-
...rounds.map(r => `**Q${r.roundNumber}**: ${r.aiQuestion}\n**A**: ${r.userAnswer}`),
260-
'',
261-
`### 深化理解`,
262-
...Object.entries(answers).map(([key, val]) => `**${key}**: ${val}`),
263-
].join('\n');
252+
const explanation = [
253+
`## 苏格拉底式学习:${topic}\n`,
254+
`### 发散思考`,
255+
...selected.map(s => `- ${s}`),
256+
'',
257+
`### 追问对话摘要`,
258+
...rounds.map(r => `**Q${r.roundNumber}**: ${r.aiQuestion}\n**A**: ${r.userAnswer}`),
259+
'',
260+
`### 深化理解`,
261+
...Object.entries(answers).map(([key, val]) => `**${key}**: ${val}`),
262+
].join('\n');
264263

265-
const noteId = await createNote(topic);
264+
let noteId: string;
265+
try {
266+
noteId = await createNote(topic);
266267
await updateNote(noteId, { explanation, status: 'completed', currentStep: 4 });
268+
} catch {
269+
toast({ type: 'error', message: '保存失败,请稍后重试' });
270+
setSavingNote(false);
271+
return;
272+
}
267273

268-
// E4 苏格拉底-费曼数据互通:对话评估中得分 <6 的维度写入该笔记薄弱点
274+
// E4 苏格拉底-费曼数据互通:对话评估中得分 <6 的维度写入该笔记薄弱点。
275+
// 独立静默失败:薄弱点是可选增强,若混入主 try 会在笔记已落库后
276+
// 误报"保存失败",用户重试将创建重复笔记
277+
try {
269278
for (const text of collectWeakDimensions(rounds)) {
270279
await addWeakPoint(noteId, { text, position: { start: 0, end: 0 }, mastered: false });
271280
}
281+
} catch { /* 可选增强静默降级 */ }
272282

273-
toast({ type: 'success', message: '已保存为浮出水面概念!' });
283+
toast({ type: 'success', message: '已保存为浮出水面概念!' });
274284

275-
setExiting(true);
276-
phaseTimerRef.current = setTimeout(() => { navigate(`/feynman/${noteId}`); }, PHASE_TRANSITION_MS);
277-
} catch {
278-
toast({ type: 'error', message: '保存失败,请稍后重试' });
279-
setSavingNote(false);
280-
}
285+
setExiting(true);
286+
phaseTimerRef.current = setTimeout(() => { navigate(`/feynman/${noteId}`); }, PHASE_TRANSITION_MS);
281287
}, [topic, selected, rounds, createNote, updateNote, addWeakPoint, toast, navigate]);
282288

283289
const handleGoBack = useCallback(() => {

‎client/src/features/feynman/pages/FeynmanGraphPage.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ function FeynmanGraphInner() {
8080
edges={edges}
8181
nodeTypes={nodeTypes}
8282
onNodeClick={(_e, node: Node) => navigate(`/feynman/${node.id}`)}
83-
nodesDraggable
83+
nodesDraggable={false}
8484
nodesConnectable={false}
8585
fitView
8686
fitViewOptions={{ padding: 0.2 }}

‎client/src/features/flashcards/components/RecoveryPackPanel.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Sparkles, X, PlayCircle } from 'lucide-react';
1212
import { Button } from '@/components/ui';
1313
import { loadRecoveryPack, type RecoveryPack } from '../lib/recoveryPack';
1414
import { fetchRecallQuestion } from '@/features/dashboard/lib/ritualRecallService';
15+
import { extractNoteText } from '@/features/notes/lib/extractNoteText';
1516
import { noteStore } from '@/lib/storage';
1617

1718
/** 收起后本次应用会话内不再展示 */
@@ -45,7 +46,8 @@ export default function RecoveryPackPanel() {
4546
const latest = [...notes].sort(
4647
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
4748
)[0];
48-
const plain = latest.content.replace(/<[^>]+>/g, ' ').slice(0, 1500);
49+
// content 为 TipTap JSON 字符串,需递归提取 text 节点(HTML 正则无效)
50+
const plain = extractNoteText(latest.content).slice(0, 1500);
4951
const q = await fetchRecallQuestion(latest.id, latest.title, plain);
5052
if (!cancelled && q) setEchoQuestion(q.question);
5153
} catch {

‎client/src/features/flashcards/lib/recoveryPack.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,14 @@ export async function loadRecoveryPack(): Promise<RecoveryPack | null> {
7272
const selected = selectRecoveryCards(allCards, now);
7373
if (selected.length === 0) return null;
7474

75-
// 统计到期卡最多的牌组作为 CTA 跳转目标
75+
// 到期总数含新卡(与 UI 文案"共 X 张卡片到了复习时间"一致);
76+
// 牌组 CTA 仍只统计旧卡(repetitions>0,恢复场景针对旧知识)
77+
let totalDue = 0;
7678
const dueByDeck = new Map<string, number>();
7779
for (const c of allCards) {
78-
if (c.repetitions > 0 && new Date(c.dueDate).getTime() <= now) {
80+
if (new Date(c.dueDate).getTime() > now) continue;
81+
totalDue += 1;
82+
if (c.repetitions > 0) {
7983
dueByDeck.set(c.deckId, (dueByDeck.get(c.deckId) ?? 0) + 1);
8084
}
8185
}
@@ -88,7 +92,7 @@ export async function loadRecoveryPack(): Promise<RecoveryPack | null> {
8892
return {
8993
gapDays,
9094
cards: selected,
91-
totalDue: maxDue > 0 ? [...dueByDeck.values()].reduce((a, b) => a + b, 0) : 0,
95+
totalDue,
9296
topDeckId,
9397
};
9498
}

‎client/src/features/notes/components/ContentTierModal.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ export function ContentTierModal({ open, onClose, noteText }: ContentTierModalPr
4747
const copyCore = async () => {
4848
if (!tier) return;
4949
const text = tier.core.map((item, i) => `${i + 1}. ${item.text}`).join('\n');
50-
await navigator.clipboard.writeText(text);
51-
toast({ type: 'success', message: '核心概念已复制到剪贴板' });
50+
try {
51+
await navigator.clipboard.writeText(text);
52+
toast({ type: 'success', message: '核心概念已复制到剪贴板' });
53+
} catch {
54+
toast({ type: 'error', message: '复制失败,请手动选择复制' });
55+
}
5256
};
5357

5458
const renderTier = (key: 'core' | 'support' | 'detail', items: ContentTierItem[]) => {

‎client/src/features/notes/hooks/useConceptConflict.ts‎

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
99
import type { Note } from '@/types/models';
1010
import type { ConceptConflict } from '@/lib/ai/types';
1111
import { useAIConflictDetect } from '@/lib/ai/hooks/useAIConflictDetect';
12+
import { extractNoteText } from '../lib/extractNoteText';
1213

1314
/** 触发检测的最少当前笔记字数 */
1415
const MIN_NOTE_LEN = 100;
@@ -19,23 +20,6 @@ const HISTORY_NOTE_COUNT = 5;
1920
/** 单篇历史笔记截取长度 */
2021
const HISTORY_SLICE_LEN = 600;
2122

22-
/** 从 TipTap JSON 或纯文本中提取纯文本 */
23-
function extractText(content: string): string {
24-
try {
25-
const json = JSON.parse(content);
26-
if (json?.content) {
27-
const walk = (nodes: unknown[]): string => nodes.map((n) => {
28-
const node = n as { text?: string; content?: unknown[] };
29-
return (node.text ?? '') + (node.content ? walk(node.content) : '');
30-
}).join('');
31-
return walk(json.content);
32-
}
33-
return '';
34-
} catch {
35-
return content;
36-
}
37-
}
38-
3923
/**
4024
* 概念冲突自动检测 hook
4125
*/
@@ -60,7 +44,7 @@ export function useConceptConflict(noteId: string | null, noteText: string, allN
6044
.filter((n) => n.id !== noteId)
6145
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
6246
.slice(0, HISTORY_NOTE_COUNT)
63-
.map((n) => `【${n.title || '未命名'}】\n${extractText(n.content).slice(0, HISTORY_SLICE_LEN)}`)
47+
.map((n) => `【${n.title || '未命名'}】\n${extractNoteText(n.content).slice(0, HISTORY_SLICE_LEN)}`)
6448
.join('\n\n');
6549
if (history.trim().length < 30) return; // 无历史可比对
6650

0 commit comments

Comments
 (0)