Skip to content

Commit 8b56d2c

Browse files
committed
feat(classroom): P0 止血——错误四分类、降级/丢弃可见性与启动配置记忆
- 错误四分类与重试引导:analysisErrors 错误归因 + ClassroomStatusBanners + AnalysisErrorCard/AnalysisPreview 错误态呈现 - 降级可见性:local-concat 徽章与「重新合并」入口(Toast 新增可选 action 按钮,带 action 时默认时长 6s) - ASR 丢弃可观测与性能卫生:vadMarker 清理 TEMP DIAGNOSTIC 日志、useClassroomAudio setState 降频 - classroomPreference 启动配置记忆(keban_classroom_launch_pref) - 评审修复:finalizeSmartSession 竞态防护等 F 项修复并入本批 hooks 改动 - docs(Foresight): 课堂助手竞品分析与优化路线图
1 parent 1d9c491 commit 8b56d2c

19 files changed

Lines changed: 1257 additions & 205 deletions

‎client/src/components/ui/Toast.tsx‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,34 @@ import { soundPlayer } from '@/lib/audio/SoundPlayer';
1010
// Types
1111
export type ToastType = 'success' | 'error' | 'warning' | 'info';
1212

13+
/**
14+
* 可选操作按钮(如降级提示的「重新合并」)。
15+
* @ai-context: Optional inline action button rendered before the close
16+
* button; clicking it dismisses the toast first, then runs onClick in a
17+
* try/catch so a throwing callback can never keep the toast on screen.
18+
*/
19+
export interface ToastAction {
20+
label: string;
21+
onClick: () => void;
22+
}
23+
1324
interface ToastItem {
1425
id: number;
1526
type: ToastType;
1627
message: string;
1728
duration: number;
29+
action?: ToastAction;
1830
}
1931

2032
interface ToastContextValue {
21-
toast: (options: { type: ToastType; message: string; duration?: number; silent?: boolean }) => void;
33+
toast: (options: {
34+
type: ToastType;
35+
message: string;
36+
duration?: number;
37+
silent?: boolean;
38+
/** 可选操作按钮;含 action 时默认时长延长至 6000ms */
39+
action?: ToastAction;
40+
}) => void;
2241
}
2342

2443
// Context
@@ -46,6 +65,9 @@ const defaultDuration: Record<ToastType, number> = {
4665
warning: 3000,
4766
};
4867

68+
// 含 action 按钮时的默认时长(给用户足够的阅读与点击时间)
69+
const ACTION_DEFAULT_DURATION = 6000;
70+
4971
// Radix toast type mapping
5072
const radixTypeMap: Record<ToastType, RadixToast.ToastProps['type']> = {
5173
success: 'foreground',
@@ -95,6 +117,24 @@ const ToastItemCard: React.FC<{
95117
<RadixToast.Title className="text-b2 text-text-primary flex-1 font-normal">
96118
{item.message}
97119
</RadixToast.Title>
120+
{/* Optional action button (left of close button); click → dismiss first,
121+
then run the callback (try/catch 防回调抛错导致 toast 滞留) */}
122+
{item.action && (
123+
<button
124+
onClick={() => {
125+
onOpenChange(item.id, false);
126+
try { item.action?.onClick(); } catch (err) {
127+
console.warn('[Toast] action 回调执行失败:', err);
128+
}
129+
}}
130+
className={cn(
131+
'flex-shrink-0 px-2 py-1 rounded-kb-sm text-b3 font-medium',
132+
'text-brand-500 hover:bg-bg-secondary/60 transition-colors',
133+
)}
134+
>
135+
{item.action.label}
136+
</button>
137+
)}
98138
{/* Close button */}
99139
<RadixToast.Close asChild>
100140
<button
@@ -113,11 +153,14 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
113153
const [toasts, setToasts] = useState<ToastItem[]>([]);
114154
const idRef = useRef(0);
115155

116-
const toast = useCallback(({ type, message, duration, silent }: { type: ToastType; message: string; duration?: number; silent?: boolean }) => {
156+
const toast = useCallback(({ type, message, duration, silent, action }: {
157+
type: ToastType; message: string; duration?: number; silent?: boolean; action?: ToastAction;
158+
}) => {
117159
const id = ++idRef.current;
118-
const d = duration ?? defaultDuration[type];
160+
// 含 action 时默认延长展示时长(未显式指定 duration 才生效)
161+
const d = duration ?? (action ? ACTION_DEFAULT_DURATION : defaultDuration[type]);
119162
if (!silent && toastSoundMap[type]) soundPlayer.play(toastSoundMap[type]!);
120-
setToasts((prev) => [...prev, { id, type, message, duration: d }]);
163+
setToasts((prev) => [...prev, { id, type, message, duration: d, action }]);
121164
}, []);
122165

123166
const handleOpenChange = useCallback((id: number, open: boolean) => {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* ClassroomStatusBanners — 课堂 smart 路径运行态状态横幅组
3+
*
4+
* @ai-context: 从 ClassroomPage 纯搬移(P0-5 装配腾出行数预算):
5+
* 增量分析进度 / 转写进度 / 音频健康警告 / VAD 校准提示四块横幅,
6+
* 渲染条件与文案逐字保留,零逻辑变更。
7+
* @ai-context: Banners extracted verbatim from ClassroomPage to keep the
8+
* assembly page under the 300-line budget; conditions and copy unchanged.
9+
*/
10+
import { Mic, CheckCircle2, Loader2, AlertTriangle } from 'lucide-react';
11+
import type { SessionStatus } from '@/lib/capture';
12+
import type { AudioHealth } from '../hooks/useClassroomAudio';
13+
import type { VADStats } from '@/lib/capture/vadMarker';
14+
15+
interface ClassroomStatusBannersProps {
16+
status: SessionStatus;
17+
partialCount: number;
18+
transcribedCount: number;
19+
audioHealth: AudioHealth;
20+
vadStats: VADStats | null;
21+
}
22+
23+
export function ClassroomStatusBanners({
24+
status, partialCount, transcribedCount, audioHealth, vadStats,
25+
}: ClassroomStatusBannersProps) {
26+
return (
27+
<>
28+
{partialCount > 0 && (
29+
<div className="mx-4 mt-2 flex items-center gap-2 px-3 py-2 rounded-kb-md bg-brand-50/50 border border-brand-100/50">
30+
<CheckCircle2 className="w-4 h-4 text-brand-500" strokeWidth={1.5} />
31+
<span className="text-b3 text-brand-600">已增量分析 {partialCount} 段,课后将快速合并生成笔记</span>
32+
</div>
33+
)}
34+
{transcribedCount > 0 && (
35+
<div className="mx-4 mt-2 flex items-center gap-2 px-3 py-2 rounded-kb-md bg-emerald-50/50 border border-emerald-100/50">
36+
<Mic className="w-4 h-4 text-emerald-500" strokeWidth={1.5} />
37+
<span className="text-b3 text-emerald-600">已转写 {transcribedCount} 段语音</span>
38+
</div>
39+
)}
40+
{/* 音频健康警告:区分"从未启动"与"中途中断" */}
41+
{status === 'capturing' && !audioHealth.isHealthy && (
42+
<div className="mx-4 mt-2 flex items-center gap-2 px-3 py-2 rounded-kb-md bg-semantic-error/5 border border-semantic-error/15">
43+
<AlertTriangle className="w-4 h-4 text-semantic-error flex-shrink-0" strokeWidth={1.5} />
44+
<span className="text-b3 text-semantic-error">
45+
{audioHealth.chunkCount === 0
46+
? '未检测到音频输入,音频采集可能未启动,请停止后重新开始'
47+
: '音频输入中断,请检查系统音频设置'}
48+
</span>
49+
</div>
50+
)}
51+
{/* VAD 状态(校准中提示) */}
52+
{status === 'capturing' && vadStats && !vadStats.calibrated && (
53+
<div className="mx-4 mt-2 flex items-center gap-2 px-3 py-2 rounded-kb-md bg-amber-50/50 border border-amber-100/50">
54+
<Loader2 className="w-4 h-4 text-amber-500 animate-spin" strokeWidth={1.5} />
55+
<span className="text-b3 text-amber-600">正在校准音频阈值 ({vadStats.processedChunks}/10 块)...</span>
56+
</div>
57+
)}
58+
</>
59+
);
60+
}
61+
62+
export default ClassroomStatusBanners;

‎client/src/features/classroom/hooks/useClassroomAnalysis.ts‎

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,12 @@
77
* 拼接片段(零网络);完全没有增量片段时才回退 analyzeSession 全量分析。
88
* 分析完成后清空 keyframe.imageBase64 释放内存(单帧 base64 可达数百 KB)。
99
*/
10-
import { useState, useCallback } from 'react';
10+
import { useState, useCallback, useRef } from 'react';
1111
import { analyzeSession, analyzeVideo, mergeNotes } from '@/lib/ai/sessionAnalyzer';
1212
import type { AnalyzeResult } from '@/lib/ai/sessionAnalyzer';
1313
import type { SessionBundle, CaptureSidebarConfig, RecordingStatus } from '@/lib/capture';
14-
15-
/** 分析异常统一分类为用户可读提示 */
16-
function classifyAnalysisError(err: unknown): string {
17-
if (err instanceof TypeError && err.message.includes('fetch')) {
18-
return '无法连接AI网关,请检查网络';
19-
}
20-
if (err instanceof DOMException && err.name === 'AbortError') {
21-
return '分析超时,请重试或缩短录制时长';
22-
}
23-
if (err instanceof Error && err.message.includes('HTTP')) {
24-
return '服务端错误:' + err.message;
25-
}
26-
return err instanceof Error ? err.message : '未知分析错误';
27-
}
14+
import { classifyAnalysisError } from '../utils/analysisErrors';
15+
import type { AnalysisErrorInfo } from '../utils/analysisErrors';
2816

2917
interface UseClassroomAnalysisOptions {
3018
language: CaptureSidebarConfig['language'];
@@ -34,15 +22,19 @@ interface UseClassroomAnalysisOptions {
3422
recordingStatus: RecordingStatus | null;
3523
/** 真实采集会话 ID(关联笔记与关键帧图片目录),缺省时持久化处退化为随机 UUID */
3624
captureSessionIdRef?: React.MutableRefObject<string | null>;
37-
onWarn: (message: string) => void;
25+
/** 警告提示;action 可选,用于降级态附带操作按钮(如「重新合并」) */
26+
onWarn: (message: string, action?: { label: string; onClick: () => void }) => void;
3827
}
3928

4029
export function useClassroomAnalysis({
4130
language, smartBundle, setSmartBundle, videoFilePath, recordingStatus, captureSessionIdRef, onWarn,
4231
}: UseClassroomAnalysisOptions) {
4332
const [isAnalyzing, setIsAnalyzing] = useState(false);
4433
const [analysisResult, setAnalysisResult] = useState<AnalyzeResult | null>(null);
45-
const [analysisError, setAnalysisError] = useState<string | null>(null);
34+
/** P0-1:结构化错误(类别+文案+操作建议),取代裸字符串 */
35+
const [analysisError, setAnalysisError] = useState<AnalysisErrorInfo | null>(null);
36+
/** P0-2:最近一次合并的 partials 快照,供 local-concat 降级后重试 merge */
37+
const lastPartialsRef = useRef<{ partials: string[]; durationMs: number; keyframeCount: number } | null>(null);
4638

4739
/** Path B:全量分析(无增量片段时的回退路径) */
4840
const handleAnalyze = useCallback(async () => {
@@ -98,6 +90,8 @@ export function useClassroomAnalysis({
9890
const mergePartialNotes = useCallback(async (partials: string[], durationMs: number, keyframeCount: number) => {
9991
setIsAnalyzing(true);
10092
setAnalysisError(null);
93+
// 留存快照:local-concat 降级后支持「重新合并」重试(P0-2)
94+
lastPartialsRef.current = { partials, durationMs, keyframeCount };
10195
try {
10296
const result = await mergeNotes(partials, {
10397
duration: durationMs / 1000,
@@ -115,12 +109,22 @@ export function useClassroomAnalysis({
115109
keyframesAnalyzed: keyframeCount,
116110
modelUsed: 'local-concat',
117111
});
118-
onWarn('AI 合并不可用,已直接拼接片段笔记');
112+
onWarn('云端不可用,本次笔记为本地拼接,联网后可重新生成', {
113+
label: '重新合并',
114+
onClick: () => { void mergePartialNotes(partials, durationMs, keyframeCount); },
115+
});
119116
} finally {
120117
setIsAnalyzing(false);
121118
}
122119
}, [language, onWarn, captureSessionIdRef]);
123120

121+
/** 用最近一次 partials 快照重跑合并(local-concat 降级后的重试入口) */
122+
const handleRetryMerge = useCallback(() => {
123+
const snapshot = lastPartialsRef.current;
124+
if (!snapshot) return;
125+
void mergePartialNotes(snapshot.partials, snapshot.durationMs, snapshot.keyframeCount);
126+
}, [mergePartialNotes]);
127+
124128
const handleDismissAnalysis = useCallback(() => {
125129
setAnalysisResult(null);
126130
setAnalysisError(null);
@@ -129,6 +133,6 @@ export function useClassroomAnalysis({
129133

130134
return {
131135
isAnalyzing, analysisResult, analysisError,
132-
handleAnalyze, handleVideoAnalyze, mergePartialNotes, handleDismissAnalysis,
136+
handleAnalyze, handleVideoAnalyze, mergePartialNotes, handleRetryMerge, handleDismissAnalysis,
133137
};
134138
}

‎client/src/features/classroom/hooks/useClassroomAudio.ts‎

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -148,29 +148,19 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us
148148
if (!window.electronAPI || status !== 'capturing') return;
149149
const off = window.electronAPI.on('audio_capture_chunk', (...args: unknown[]) => {
150150
const chunk = args[0] as AudioChunkData;
151-
// TEMP DIAGNOSTIC(ASR 静默排查):验证音频块是否到达渲染端及其能量/类型
152-
try {
153-
const diagSamples = new Float32Array(chunk.audioBuffer);
154-
let diagSum = 0;
155-
for (let i = 0; i < diagSamples.length; i++) diagSum += diagSamples[i] * diagSamples[i];
156-
const diagRms = diagSamples.length > 0 ? Math.sqrt(diagSum / diagSamples.length) : 0;
157-
console.info(
158-
`[ASR-DIAG] 渲染端收到音频块: ${diagSamples.length} 样本, RMS=${diagRms.toFixed(6)}, ` +
159-
`buffer类型=${Object.prototype.toString.call(chunk.audioBuffer)}, byteLength=${(chunk.audioBuffer as ArrayBuffer).byteLength}`,
160-
);
161-
} catch (diagErr) {
162-
console.warn('[ASR-DIAG] 音频块检查失败:', diagErr);
163-
}
164151
captureManager.pushAudioChunk(chunk);
165-
// 更新音频健康状态:收到音频块即视为健康
152+
// healthRef 保持逐块更新(watchdog 只读 ref,精度不受影响)
166153
healthRef.current = {
167154
lastChunkTime: Date.now(),
168155
chunkCount: healthRef.current.chunkCount + 1,
169156
};
170-
setAudioHealth({
171-
lastChunkTime: healthRef.current.lastChunkTime,
172-
chunkCount: healthRef.current.chunkCount,
173-
isHealthy: true,
157+
// 性能卫生:仅每 10 块或健康态翻转(不健康→健康)时才 setState,
158+
// 消除流式 400ms 块下约 2.5Hz 的整页重渲染
159+
const { lastChunkTime, chunkCount } = healthRef.current;
160+
setAudioHealth((prev) => {
161+
if (!prev.isHealthy) return { lastChunkTime, chunkCount, isHealthy: true };
162+
if (chunkCount % 10 === 0) return { lastChunkTime, chunkCount, isHealthy: true };
163+
return prev;
174164
});
175165
});
176166
return off;
@@ -201,8 +191,13 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us
201191
}
202192
}
203193
} else if (Date.now() - lastChunkTime > CHUNK_GAP_TIMEOUT_MS) {
204-
// 场景二:音频曾正常但中断超过 10s
205-
setAudioHealth((prev) => ({ ...prev, isHealthy: false }));
194+
// 场景二:音频曾正常但中断超过 10s。chunkCount 从 healthRef 取真实
195+
// 逐块计数——state 因降频可能滞后(前 9 块恒为 0),沿用会让横幅
196+
// 把"音频中断"误判为"从未检测到音频"(ClassroomStatusBanners 按
197+
// chunkCount === 0 分支)
198+
setAudioHealth((prev) => (prev.isHealthy
199+
? { ...prev, chunkCount: healthRef.current.chunkCount, isHealthy: false }
200+
: prev));
206201
if (!warnedStopped) {
207202
warnedStopped = true;
208203
onNotify('warning', '音频输入中断超过 10s,请检查系统音频设置');

0 commit comments

Comments
 (0)