Skip to content

Commit f824547

Browse files
committed
feat(feynman): AI 反馈持久化 + 用户反馈五问题修复
- feat: AI 评估/追问/回答评估结果持久化到 Dexie(feynmanAIResults 表),返回列表后重新进入可恢复;新增重置按钮 - fix: AI 请求增加 75s 前端超时兜底(withTimeout),避免永远卡在加载态 - fix: AI 内容长度校验差异化——救援/主题类非空即可,评估类>=5,生成类保持>=10 - fix: 费曼已完成状态下隐藏卡壳了/AI评估按钮,消除状态矛盾 - fix: 闪卡创建 deckId 缺失时报错而非静默跳过,createCard 失败展示错误 - fix: 牌组卡片无描述时预留一行高度保持尺寸一致,长描述显示展开符号按钮 - chore: phase-aware 音频配置优化
1 parent 09a3ee7 commit f824547

18 files changed

Lines changed: 330 additions & 90 deletions

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 评估的综合评分/维度进度条/优势/待改进/建议。数据来自 useAIEvaluate,
66
* 评分阈值文案(≥80 出色 / ≥60 较好)为产品定义,勿随意调整。
77
*/
8-
import { X, Sparkles, CheckCircle2, Circle } from 'lucide-react';
8+
import { X, Sparkles, CheckCircle2, Circle, RotateCcw } from 'lucide-react';
99
import { AIThinkingIndicator } from '@/components/ui/AIThinkingIndicator';
1010
import { cn } from '@/lib/utils';
1111
import type { EvaluateResult } from '@/lib/ai/types';
@@ -17,10 +17,12 @@ interface AIEvaluationResultProps {
1717
data: EvaluateResult | null;
1818
onClose: () => void;
1919
onGoSettings: () => void;
20+
/** v0.30: 重置 AI 反馈(用户建议) */
21+
onReset?: () => void;
2022
}
2123

2224
export function AIEvaluationResult({
23-
loading, error, needsConfig, data, onClose, onGoSettings,
25+
loading, error, needsConfig, data, onClose, onGoSettings, onReset,
2426
}: AIEvaluationResultProps) {
2527
return (
2628
<div className={cn(
@@ -32,12 +34,24 @@ export function AIEvaluationResult({
3234
<Sparkles className="w-icon-sm h-icon-sm text-brand-500" strokeWidth={1.5} />
3335
AI 评估结果
3436
</h3>
35-
<button
36-
onClick={onClose}
37-
className="p-1 rounded-kb-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary transition-all duration-kb-fast"
38-
>
39-
<X className="w-4 h-4" strokeWidth={1.5} />
40-
</button>
37+
<div className="flex items-center gap-1">
38+
{onReset && data && !loading && (
39+
<button
40+
onClick={onReset}
41+
className="inline-flex items-center gap-1 px-2 py-1 rounded-kb-md text-c1 text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary transition-all duration-kb-fast"
42+
title="清除评估结果,可重新评估"
43+
>
44+
<RotateCcw className="w-3.5 h-3.5" strokeWidth={1.5} />
45+
重置
46+
</button>
47+
)}
48+
<button
49+
onClick={onClose}
50+
className="p-1 rounded-kb-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary transition-all duration-kb-fast"
51+
>
52+
<X className="w-4 h-4" strokeWidth={1.5} />
53+
</button>
54+
</div>
4155
</div>
4256

4357
{loading && (

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export function FeynmanTopBar({
6868
<h1 className="text-b1 font-semibold text-text-primary flex-1 truncate">
6969
{concept || '浮出水面'}
7070
</h1>
71-
{hasExplanation && (
71+
{/* v0.30: 已完成状态下隐藏卡壳救援与 AI 评估入口,避免状态矛盾 */}
72+
{hasExplanation && !isCompleted && (
7273
<button
7374
onClick={onRescue}
7475
className={cn(
@@ -83,7 +84,7 @@ export function FeynmanTopBar({
8384
卡壳了
8485
</button>
8586
)}
86-
{hasExplanation && (
87+
{hasExplanation && !isCompleted && (
8788
<AIButton
8889
size="sm"
8990
loading={aiEvalLoading}

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

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* (Ctrl+Shift+H 快捷键 + 停滞计时)及讲解文本右键 AI 操作菜单。
77
* @ai-context: 接收 note 作为参数(由 useFeynmanSession 提供);反问/评估
88
* 均要求 concept+explanation 已填写,否则 toast 提示。
9+
* @ai-context: v0.30 — AI 结果持久化:评估/追问/回答评估成功后写入
10+
* feynmanAIResults 表,重新进入会话时自动恢复;“重置 AI 反馈”删除记录。
911
*/
1012
import { useEffect, useState, useCallback, useMemo } from 'react';
1113
import { MessageCircle, Lightbulb, SearchCheck } from 'lucide-react';
@@ -14,7 +16,9 @@ import { useContextMenu } from '@/lib/contextMenu';
1416
import { useAIEvaluate, useAIFeynmanQuestion, useAIFeynmanEvaluateAnswers } from '@/lib/ai/useAI';
1517
import { useAIErrorHandler } from '@/lib/ai/hooks/useAIErrorHandler';
1618
import { useStuckTimer } from '@/hooks/useStuckTimer';
17-
import type { FeynmanNote } from '@/types/models';
19+
import { feynmanAIResultStore } from '@/lib/storage';
20+
import { createWithLog, updateWithLog } from '@/lib/storage/writeWithLog';
21+
import type { FeynmanNote, FeynmanAIResult } from '@/types/models';
1822

1923
export function useFeynmanAI(note: FeynmanNote | null) {
2024
const { toast } = useToast();
@@ -26,6 +30,7 @@ export function useFeynmanAI(note: FeynmanNote | null) {
2630
error: aiEvalError,
2731
needsConfig: aiEvalNeedsConfig,
2832
evaluate: aiEvaluate,
33+
clear: clearEval,
2934
} = useAIEvaluate();
3035

3136
// AI 反问
@@ -35,6 +40,7 @@ export function useFeynmanAI(note: FeynmanNote | null) {
3540
error: aiQuestionError,
3641
needsConfig: aiQuestionNeedsConfig,
3742
generateQuestions,
43+
clear: clearQuestion,
3844
} = useAIFeynmanQuestion();
3945

4046
// AI 回答评估
@@ -44,6 +50,7 @@ export function useFeynmanAI(note: FeynmanNote | null) {
4450
error: aiAnswerEvalError,
4551
needsConfig: aiAnswerEvalNeedsConfig,
4652
evaluateAnswers: aiEvaluateAnswers,
53+
clear: clearAnswerEval,
4754
} = useAIFeynmanEvaluateAnswers();
4855

4956
const handleQuestionError = useAIErrorHandler('AI 追问生成失败');
@@ -54,6 +61,66 @@ export function useFeynmanAI(note: FeynmanNote | null) {
5461
const [showQuestionPanel, setShowQuestionPanel] = useState(false);
5562
const [localAnswers, setLocalAnswers] = useState<string[]>([]);
5663

64+
// ── v0.30: AI 结果持久化 ────────────────────────────────
65+
const [persisted, setPersisted] = useState<FeynmanAIResult | null>(null);
66+
67+
/** 会话加载时恢复持久化的 AI 结果 */
68+
useEffect(() => {
69+
if (!note?.id) { setPersisted(null); return; }
70+
let cancelled = false;
71+
feynmanAIResultStore.where('noteId', note.id).then((records) => {
72+
if (cancelled) return;
73+
const record = records[0] ?? null;
74+
setPersisted(record);
75+
if (record) {
76+
if (record.evalResult) setShowAIEval(true);
77+
if (record.questionData) setShowQuestionPanel(true);
78+
if (record.answers?.length) setLocalAnswers(record.answers);
79+
}
80+
}).catch(() => { /* 恢复失败不阻塞主流程 */ });
81+
return () => { cancelled = true; };
82+
}, [note?.id]); // eslint-disable-line react-hooks/exhaustive-deps
83+
84+
/** 将 AI 结果增量写入持久层(upsert by noteId) */
85+
const persistAIResult = useCallback(async (patch: Partial<FeynmanAIResult>) => {
86+
if (!note?.id) return;
87+
const noteId = note.id;
88+
try {
89+
const existing = (await feynmanAIResultStore.where('noteId', noteId))[0];
90+
if (existing) {
91+
const updated = { ...existing, ...patch, updatedAt: new Date() };
92+
await updateWithLog(feynmanAIResultStore, 'feynmanAIResults', existing.id, updated);
93+
setPersisted(updated);
94+
} else {
95+
const now = new Date();
96+
const data = { noteId, ...patch, createdAt: now, updatedAt: now };
97+
const id = await createWithLog(feynmanAIResultStore, 'feynmanAIResults', data);
98+
setPersisted({ id, ...data } as FeynmanAIResult);
99+
}
100+
} catch { /* 持久化失败不阻塞主流程 */ }
101+
}, [note?.id]);
102+
103+
/** 重置 AI 反馈:删除持久化记录 + 清空内存状态 */
104+
const resetAIResults = useCallback(async () => {
105+
if (!note?.id) return;
106+
try {
107+
const records = await feynmanAIResultStore.where('noteId', note.id);
108+
for (const r of records) await feynmanAIResultStore.delete(r.id);
109+
} catch { /* 忽略 */ }
110+
setPersisted(null);
111+
setShowAIEval(false);
112+
setShowQuestionPanel(false);
113+
setLocalAnswers([]);
114+
clearEval();
115+
clearQuestion();
116+
clearAnswerEval();
117+
toast({ type: 'success', message: 'AI 反馈已重置' });
118+
}, [note?.id, clearEval, clearQuestion, clearAnswerEval, toast]);
119+
120+
/** 是否存在 AI 结果(内存或持久层)——用于展示重置按钮 */
121+
const hasAIResults = !!(aiEvalData || aiQuestionData || aiAnswerEvalData
122+
|| persisted?.evalResult || persisted?.questionData || persisted?.answerEvalData);
123+
57124
// 卡壳救援
58125
const [rescueOpen, setRescueOpen] = useState(false);
59126
const stuckTimer = useStuckTimer({
@@ -81,8 +148,10 @@ export function useFeynmanAI(note: FeynmanNote | null) {
81148
return;
82149
}
83150
setShowAIEval(true);
84-
aiEvaluate(note.concept, note.explanation).catch(handleEvalError);
85-
}, [note, toast, aiEvaluate, handleEvalError]);
151+
aiEvaluate(note.concept, note.explanation)
152+
.then((result) => { persistAIResult({ evalResult: result }); })
153+
.catch(handleEvalError);
154+
}, [note, toast, aiEvaluate, handleEvalError, persistAIResult]);
86155

87156
const handleGenerateQuestions = useCallback(() => {
88157
if (!note?.concept || !note?.explanation) {
@@ -91,8 +160,10 @@ export function useFeynmanAI(note: FeynmanNote | null) {
91160
}
92161
setShowQuestionPanel(true);
93162
setLocalAnswers([]);
94-
generateQuestions(note.concept, note.explanation).catch(handleQuestionError);
95-
}, [note, toast, generateQuestions, handleQuestionError]);
163+
generateQuestions(note.concept, note.explanation)
164+
.then((result) => { persistAIResult({ questionData: result, answers: undefined, answerEvalData: undefined }); })
165+
.catch(handleQuestionError);
166+
}, [note, toast, generateQuestions, handleQuestionError, persistAIResult]);
96167

97168
const handleSubmitAnswers = useCallback(async () => {
98169
if (!note?.concept || !aiQuestionData) return;
@@ -102,8 +173,11 @@ export function useFeynmanAI(note: FeynmanNote | null) {
102173
toast({ type: 'warning', message: '请至少回答一个追问' });
103174
return;
104175
}
105-
await aiEvaluateAnswers(note.concept, questions, answers).catch(handleEvalError);
106-
}, [note, aiQuestionData, localAnswers, toast, aiEvaluateAnswers, handleEvalError]);
176+
const evalResult = await aiEvaluateAnswers(note.concept, questions, answers).catch(handleEvalError);
177+
if (evalResult) {
178+
persistAIResult({ answers, answerEvalData: evalResult });
179+
}
180+
}, [note, aiQuestionData, localAnswers, toast, aiEvaluateAnswers, handleEvalError, persistAIResult]);
107181

108182
// ── 右键 AI 操作菜单 ──
109183

@@ -171,13 +245,21 @@ export function useFeynmanAI(note: FeynmanNote | null) {
171245
return {
172246
rescueOpen, setRescueOpen, stuckTimer,
173247
showAIEval, setShowAIEval,
174-
aiEvalLoading, aiEvalData, aiEvalError, aiEvalNeedsConfig, handleAIEval,
248+
aiEvalLoading,
249+
// v0.30: 内存数据优先,回退持久化记录(返回列表后重新进入可恢复)
250+
aiEvalData: aiEvalData ?? persisted?.evalResult ?? null,
251+
aiEvalError, aiEvalNeedsConfig, handleAIEval,
175252
showQuestionPanel, setShowQuestionPanel,
176253
localAnswers, setLocalAnswers,
177-
aiQuestionLoading, aiQuestionData, aiQuestionError, aiQuestionNeedsConfig,
254+
aiQuestionLoading,
255+
aiQuestionData: aiQuestionData ?? persisted?.questionData ?? null,
256+
aiQuestionError, aiQuestionNeedsConfig,
178257
handleGenerateQuestions,
179-
aiAnswerEvalLoading, aiAnswerEvalData, aiAnswerEvalError, aiAnswerEvalNeedsConfig,
258+
aiAnswerEvalLoading,
259+
aiAnswerEvalData: aiAnswerEvalData ?? persisted?.answerEvalData ?? null,
260+
aiAnswerEvalError, aiAnswerEvalNeedsConfig,
180261
handleSubmitAnswers,
262+
resetAIResults, hasAIResults,
181263
menuOpen, menuPosition, menuContext, closeMenu,
182264
aiMenuGroups, handleMenuSelect, handleNoteContextMenu,
183265
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export default function FeynmanSessionPage() {
5656
handleGenerateQuestions,
5757
aiAnswerEvalLoading, aiAnswerEvalData, aiAnswerEvalError, aiAnswerEvalNeedsConfig,
5858
handleSubmitAnswers,
59+
resetAIResults,
5960
menuOpen, menuPosition, menuContext, closeMenu,
6061
aiMenuGroups, handleMenuSelect, handleNoteContextMenu,
6162
} = useFeynmanAI(note);
@@ -198,6 +199,7 @@ export default function FeynmanSessionPage() {
198199
data={aiEvalData}
199200
onClose={() => setShowAIEval(false)}
200201
onGoSettings={() => navigate('/settings')}
202+
onReset={resetAIResults}
201203
/>
202204
)}
203205

‎client/src/features/flashcards/hooks/useDeckCards.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,18 @@ export function useDeckCards({
6666

6767
const handleSaveCard = useCallback(async () => {
6868
if (!cardFront.trim() || !cardBack.trim()) return;
69+
// v0.30: deckId 缺失时明确报错,禁止静默跳过(用户反馈“有时看不见创建的闪卡”)
70+
if (editingCardId === null && !deckId) {
71+
toast({ type: 'error', message: '牌组信息异常,无法创建卡片,请返回重试' });
72+
return;
73+
}
6974
setSaving(true);
7075
try {
7176
if (editingCardId !== null) {
7277
await updateCard(editingCardId, { front: cardFront.trim(), back: cardBack.trim() });
73-
} else if (deckId) {
78+
} else {
7479
await createCard({
75-
deckId,
80+
deckId: deckId!,
7681
front: cardFront.trim(),
7782
back: cardBack.trim(),
7883
type: 'basic',
@@ -81,10 +86,12 @@ export function useDeckCards({
8186
setCardModalOpen(false);
8287
setCardFront('');
8388
setCardBack('');
89+
} catch {
90+
toast({ type: 'error', message: '保存卡片失败,请重试' });
8491
} finally {
8592
setSaving(false);
8693
}
87-
}, [cardFront, cardBack, editingCardId, deckId, createCard, updateCard]);
94+
}, [cardFront, cardBack, editingCardId, deckId, createCard, updateCard, toast]);
8895

8996
const handleDeleteCard = useCallback(async () => {
9097
if (deleteCardId === null) return;

‎client/src/features/flashcards/pages/FlashcardsPage.tsx‎

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* @ai-context: flashcards 功能模块页面:FlashcardsPage。
33
*/
4-
import { useEffect, useState, useCallback, useRef } from 'react';
4+
import { useEffect, useState, useCallback, useRef, useLayoutEffect } from 'react';
55
import { motion, AnimatePresence } from 'framer-motion';
6-
import { Card, Button, Tag, Modal, Input, EmptyState, Skeleton, useToast } from '@/components/ui';
6+
import { Button, Tag, Modal, Input, EmptyState, Skeleton, useToast } from '@/components/ui';
77
import { ContextMenu, type ContextMenuGroup } from '@/components/ui/ContextMenu';
88
import { Plus, Layers, Clock, Trash2, Layers3, Upload, BookOpen, Pencil, Share2 } from 'lucide-react';
99
import { useNavigate } from 'react-router-dom';
@@ -20,6 +20,60 @@ import { soundPlayer } from '@/lib/audio/SoundPlayer';
2020

2121
const LONG_PRESS_THRESHOLD_MS = 600;
2222

23+
/**
24+
* 牌组描述行(v0.30)
25+
*
26+
* 无描述时始终预留一行高度,保证卡片尺寸一致;
27+
* 描述超过一行时截断并展示“⋯”符号按钮,点击展开完整内容。
28+
*/
29+
function DeckCardDescription({ description }: { description?: string }) {
30+
const [expanded, setExpanded] = useState(false);
31+
const [overflows, setOverflows] = useState(false);
32+
const textRef = useRef<HTMLParagraphElement>(null);
33+
34+
// 收起状态下测量是否溢出一行(垂直换行或水平不可断行溢出均算)
35+
useLayoutEffect(() => {
36+
if (expanded) return;
37+
const el = textRef.current;
38+
if (el) {
39+
setOverflows(
40+
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1,
41+
);
42+
}
43+
}, [description, expanded]);
44+
45+
if (!description) {
46+
// 无描述:占位一行高度(12px × 1.5 行高),确保卡片尺寸一致
47+
return <div className="mt-0.5 min-h-[1.125rem]" aria-hidden="true" />;
48+
}
49+
50+
return (
51+
<div className="flex items-start gap-1 mt-0.5">
52+
<p
53+
ref={textRef}
54+
className={cn(
55+
// break-words:无空格长串(如 URL/连续字符)也能正常断行,
56+
// 收起时配合 line-clamp-1 转为垂直溢出以便检测,展开时不撑出卡片
57+
'text-b3 text-text-tertiary flex-1 min-w-0 min-h-[1.125rem] break-words',
58+
!expanded && 'line-clamp-1',
59+
)}
60+
>
61+
{description}
62+
</p>
63+
{overflows && (
64+
<button
65+
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v); }}
66+
className="flex-shrink-0 text-b3 leading-[1.125rem] text-text-tertiary hover:text-text-primary transition-colors"
67+
title={expanded ? '收起描述' : '查看完整描述'}
68+
aria-label={expanded ? '收起描述' : '查看完整描述'}
69+
>
70+
{expanded ? '▴' : '⋯'}
71+
</button>
72+
)}
73+
</div>
74+
);
75+
}
76+
2377
interface DeckLocalStats {
2478
total: number;
2579
due: number;
@@ -380,9 +434,7 @@ export default function FlashcardsPage() {
380434
)}
381435
<h3 className="text-b1 font-semibold text-text-primary truncate">{deck.name}</h3>
382436
</div>
383-
{deck.description && (
384-
<p className="text-b3 text-text-tertiary mt-0.5 line-clamp-2">{deck.description}</p>
385-
)}
437+
<DeckCardDescription description={deck.description} />
386438
</div>
387439
<Tag color="flashcard">{stats.total} 卡</Tag>
388440
</div>

0 commit comments

Comments
 (0)