Skip to content

Commit 9f2cf56

Browse files
committed
feat: v0.17.0 — 课堂模块组件化、课程智能检测与多模态分析增强
课堂模块增强: - 新增 classroom/components/ 组件目录 - useClassroomCapture / ClassroomPage 优化 课程智能检测: - 新增 courseDetector.ts 前端课程识别 - 新增 course_detect.py 后端检测路由 多模态分析增强: - multimodal_analyze_chain 分析链优化 - multimodal 路由扩展 - session_analyze 提示词更新 捕获模块: - captureTypes / AnalysisPreview / capture index 重构 全量版本号同步至 v0.17.0(README / client / website) 新增课堂.md 文档
1 parent 5eb27cf commit 9f2cf56

15 files changed

Lines changed: 618 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<p align="center"><strong>你的 AI 智能学习伙伴 — 让每一分钟学习都高效有价值</strong></p>
44
<p align="center">
55
<img src="https://img.shields.io/badge/status-beta-green.svg" alt="Status" />
6-
<img src="https://img.shields.io/badge/version-v0.16.0-blue.svg" alt="Version" />
6+
<img src="https://img.shields.io/badge/version-v0.17.0-blue.svg" alt="Version" />
77
<img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
88
</p>
99
</p>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* LiveTranscript — 实时 ASR 转录滚动面板
3+
* 课中逐句显示语音转写结果,供用户确认采集质量
4+
*/
5+
import { useRef, useEffect } from 'react';
6+
import { Mic } from 'lucide-react';
7+
import { cn } from '@/lib/utils';
8+
9+
interface TranscriptEntry {
10+
id: string;
11+
text: string;
12+
timestamp: number;
13+
}
14+
15+
interface LiveTranscriptProps {
16+
transcripts: TranscriptEntry[];
17+
isActive: boolean;
18+
className?: string;
19+
}
20+
21+
export function LiveTranscript({ transcripts, isActive, className }: LiveTranscriptProps) {
22+
const scrollRef = useRef<HTMLDivElement>(null);
23+
const lastCountRef = useRef(0);
24+
25+
// 新转录到达时自动滚到底部
26+
useEffect(() => {
27+
if (scrollRef.current && transcripts.length > lastCountRef.current) {
28+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
29+
}
30+
lastCountRef.current = transcripts.length;
31+
}, [transcripts.length]);
32+
33+
if (transcripts.length === 0 && !isActive) return null;
34+
35+
return (
36+
<div className={cn('flex flex-col border-t border-border/20', className)}>
37+
{/* 标题栏 */}
38+
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/10">
39+
<Mic className={cn('w-3.5 h-3.5', isActive ? 'text-emerald-500' : 'text-text-tertiary')} strokeWidth={1.5} />
40+
<span className="text-[11px] font-medium text-text-tertiary">实时转录</span>
41+
{isActive && (
42+
<span className="ml-auto flex items-center gap-1">
43+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
44+
<span className="text-[10px] text-emerald-600">监听中</span>
45+
</span>
46+
)}
47+
<span className={cn('text-[10px] text-text-tertiary', isActive && 'ml-2')}>
48+
{transcripts.length} 句
49+
</span>
50+
</div>
51+
52+
{/* 转录内容滚动区 */}
53+
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 space-y-1 max-h-40 min-h-[60px]">
54+
{transcripts.length === 0 && (
55+
<p className="text-[11px] text-text-tertiary text-center py-3 opacity-60">
56+
{isActive ? '等待语音输入...' : '采集开始后转录将在此显示'}
57+
</p>
58+
)}
59+
{transcripts.map((entry, idx) => {
60+
const isLatest = idx === transcripts.length - 1;
61+
const time = new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
62+
return (
63+
<div
64+
key={entry.id}
65+
className={cn(
66+
'flex gap-2 text-[12px] leading-relaxed transition-opacity duration-300',
67+
isLatest ? 'text-text-primary opacity-100' : 'text-text-secondary opacity-80',
68+
)}
69+
>
70+
<span className="text-[10px] text-text-tertiary flex-shrink-0 mt-0.5 tabular-nums">{time}</span>
71+
<span className={cn(isLatest && 'font-medium')}>{entry.text}</span>
72+
</div>
73+
);
74+
})}
75+
</div>
76+
</div>
77+
);
78+
}

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

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ import type {
2020
KeyFrame,
2121
RecordingStatus,
2222
VideoRecording,
23+
CourseMeta,
2324
} from '@/lib/capture';
2425
import { analyzeSession, analyzeVideo, analyzePartial, mergeNotes } from '@/lib/ai/sessionAnalyzer';
2526
import type { AnalyzeResult } from '@/lib/ai/sessionAnalyzer';
27+
import { detectCourseFromFrame } from '@/lib/ai/courseDetector';
2628
import { aiClient } from '@/lib/http/apiClient';
2729

2830
interface IPCAudioStartResult {
@@ -59,6 +61,14 @@ export function useClassroomCapture() {
5961
const [analysisResult, setAnalysisResult] = useState<AnalyzeResult | null>(null);
6062
const [analysisError, setAnalysisError] = useState<string | null>(null);
6163

64+
// ── 实时转录 ──
65+
const [liveTranscripts, setLiveTranscripts] = useState<{ id: string; text: string; timestamp: number }[]>([]);
66+
67+
// ── 课程上下文 ──
68+
const [courseMeta, setCourseMeta] = useState<CourseMeta>({});
69+
const [aiDetectEnabled, setAiDetectEnabled] = useState(false);
70+
const courseDetectedRef = useRef(false);
71+
6272
// ── Path C 全程录制 ──
6373
const [recordingStatus, setRecordingStatus] = useState<RecordingStatus | null>(null);
6474
const [videoFilePath, setVideoFilePath] = useState<string | null>(null);
@@ -149,6 +159,22 @@ export function useClassroomCapture() {
149159
keyframes: [...(prev.keyframes ?? []), data.keyframe],
150160
}));
151161

162+
// AI 课程识别:仅第 1 帧触发一次
163+
if (aiDetectEnabled && !courseDetectedRef.current) {
164+
courseDetectedRef.current = true;
165+
detectCourseFromFrame(data.keyframe.imageBase64)
166+
.then((detected) => {
167+
if (detected) {
168+
setCourseMeta((prev) => ({
169+
...prev,
170+
...detected,
171+
detectedBy: 'ai',
172+
}));
173+
}
174+
})
175+
.catch(() => { /* 静默降级到规则模式 */ });
176+
}
177+
152178
// 增量分析:累积到缓冲区,达到批次大小时触发后台分析
153179
pendingKeyframesRef.current.push(data.keyframe);
154180
if (pendingKeyframesRef.current.length >= INCREMENTAL_BATCH_SIZE && !isPartialAnalyzingRef.current) {
@@ -173,7 +199,7 @@ export function useClassroomCapture() {
173199
(data) => setSmartBundle(data.bundle),
174200
);
175201
return () => { offKeyframe(); offBundleReady(); };
176-
}, [config.language]);
202+
}, [config.language, aiDetectEnabled]);
177203

178204
// ── Path B:流式 ASR — 语音段完成后立即转写 ──
179205
useEffect(() => {
@@ -205,7 +231,11 @@ export function useClassroomCapture() {
205231
s.id === seg.id ? { ...s, audioText: text } : s,
206232
),
207233
}));
208-
if (text) setTranscribedCount((c) => c + 1);
234+
if (text) {
235+
setTranscribedCount((c) => c + 1);
236+
// 实时转录上屏
237+
setLiveTranscripts((prev) => [...prev, { id: seg.id, text, timestamp: seg.timestampStart }]);
238+
}
209239
})
210240
.catch((err) => {
211241
console.warn('[useClassroomCapture] 流式 ASR 转写失败:', err);
@@ -337,6 +367,17 @@ export function useClassroomCapture() {
337367

338368
useEffect(() => { refreshWindows(); }, [refreshWindows]);
339369

370+
// ── 窗口选中时自动提取课程名(规则模式) ──
371+
const COURSE_KEYWORDS = /((?:高等数学|线性代数|概率论|大学物理|数据结构|操作系统|编译原理|离散数学|复变函数|英语|高数|大物|C语言|Python|Java|机器学习|深度学习|人工智能|计算机网络|数据库)[^\s|]*)/;
372+
373+
useEffect(() => {
374+
if (!selectedWindow) return;
375+
const match = selectedWindow.title.match(COURSE_KEYWORDS);
376+
if (match && !courseMeta.courseName) {
377+
setCourseMeta((prev) => ({ ...prev, courseName: match[1], detectedBy: 'window_title' }));
378+
}
379+
}, [selectedWindow]); // eslint-disable-line react-hooks/exhaustive-deps
380+
340381
// ── 窗口变化监听(后台轮询 + 最小化容错) ──
341382
const windowMissingCountRef = useRef(0);
342383
const WINDOW_MISSING_THRESHOLD = 10; // 连续消失 10 次轮询(~30s)才判定为真正关闭
@@ -396,6 +437,7 @@ export function useClassroomCapture() {
396437
setStats({ frames: 0, extracted: 0 });
397438
setSegments([]);
398439
setSelectedIds(new Set());
440+
setLiveTranscripts([]);
399441

400442
// 预检网关
401443
try {
@@ -442,6 +484,7 @@ export function useClassroomCapture() {
442484
language: config.language,
443485
autoInsert: config.autoInsert,
444486
path: capturePath,
487+
courseMeta: courseMeta.courseName ? courseMeta : undefined,
445488
});
446489
soundPlayer.play('capture_start');
447490

@@ -639,6 +682,34 @@ export function useClassroomCapture() {
639682
setIsAnalyzing(false);
640683
}, []);
641684

685+
// ── 笔记→闪卡一键生成 ──
686+
const handleGenerateCards = useCallback(async (content: string) => {
687+
if (!window.electronAPI) return;
688+
try {
689+
toast({ type: 'info', message: '正在从笔记生成闪卡...' });
690+
await window.electronAPI.invoke('ai_generate_cards', { content });
691+
toast({ type: 'success', message: '闪卡已生成,可在闪卡模块查看' });
692+
} catch (err) {
693+
console.error('[useClassroomCapture] 生成闪卡失败:', err);
694+
toast({ type: 'error', message: '闪卡生成失败,请重试' });
695+
}
696+
}, [toast]);
697+
698+
// ── 课中重点标记 ──
699+
const [bookmarks, setBookmarks] = useState<{ timestamp: number; label?: string }[]>([]);
700+
701+
const handleBookmark = useCallback(() => {
702+
if (status !== 'capturing') return;
703+
const now = Date.now();
704+
setBookmarks((prev) => [...prev, { timestamp: now }]);
705+
// 同时插入 smartBundle timeline
706+
setSmartBundle((prev) => ({
707+
...prev,
708+
timeline: [...(prev.timeline ?? []), { timestamp: now, type: 'bookmark' as const }],
709+
}));
710+
toast({ type: 'success', message: `已标记重点 (${new Date(now).toLocaleTimeString()})` });
711+
}, [status, toast]);
712+
642713
return {
643714
// 窗口
644715
windows, windowsLoading, selectedWindow, setSelectedWindow, refreshWindows,
@@ -648,12 +719,17 @@ export function useClassroomCapture() {
648719
capturePath, setCapturePath, smartBundle,
649720
// 分析
650721
isAnalyzing, analysisResult, analysisError, partialCount, transcribedCount,
722+
// 实时转录
723+
liveTranscripts,
724+
// 课程上下文
725+
courseMeta, setCourseMeta, aiDetectEnabled, setAiDetectEnabled,
651726
// 录制
652727
recordingStatus, videoFilePath,
653728
// 操作
654729
handleStart, handlePause, handleStop, handleModeChange,
655730
handleToggleSelect, handleConfigChange,
656-
handleAnalyze, handleVideoAnalyze, handleDismissAnalysis,
731+
handleAnalyze, handleVideoAnalyze, handleDismissAnalysis, handleGenerateCards,
732+
handleBookmark, bookmarks,
657733
// 派生
658734
canStart: !!selectedWindow,
659735
};

0 commit comments

Comments
 (0)