@@ -33,9 +33,11 @@ import { detectCourseFromFrame } from '@/lib/ai/courseDetector';
3333import { remapKeyframeMarkers } from '../utils/tipTapImageUtils' ;
3434import { persistKeyframeImage } from '../utils/keyframePersistence' ;
3535import { transcribeWithRetry , toAsrLanguage , useAsrSemaphore , isLocalAsrReady , setOnAsrFallback } from '../utils/asrTranscriber' ;
36- import { applySessionReplaces , getSessionHotwordsString , addDynamicBoosts } from '../utils/hotwordRuntime' ;
36+ import { applySessionReplaces , getSessionHotwordsString , addDynamicBoosts , addSessionReplace } from '../utils/hotwordRuntime' ;
3737import { cleanAsrResult , dedupeAcrossFinals , estimateAsrConfidence } from '@/lib/capture/asrFilters' ;
3838import { classifyContent , hasCommandCue , type ContentKind } from '@/lib/capture/contentClassifier' ;
39+ import { hotwordStore } from '@/lib/storage/hotwordStore' ;
40+ import { extractCorrection } from '../utils/transcriptCorrection' ;
3941
4042/** 触发一次增量分析所需的关键帧数 */
4143const INCREMENTAL_BATCH_SIZE = 5 ;
@@ -67,6 +69,8 @@ interface UseClassroomEventsOptions {
6769 streamingAsrActive : boolean ;
6870 /** P1-6:目标窗口标题(内容类型分类的标题信号) */
6971 windowTitle ?: string ;
72+ /** P1-3:当前课程名(修正回写词库的 courseId 绑定) */
73+ courseName ?: string ;
7074}
7175
7276/** P1-6 分类尝试上限(转写样本仍无信号则停止,unknown 不再重试) */
@@ -75,10 +79,12 @@ const MAX_CLASSIFY_ATTEMPTS = 5;
7579const FORCE_CAPTURE_MIN_INTERVAL_MS = 1000 ;
7680/** P1-6 分类转写样本上限(字符,FIFO 追加防无界) */
7781const MAX_CLASSIFY_SAMPLE_CHARS = 500 ;
82+ /** P1-8 漏捕检测延迟:指令句后该时长内无新关键帧 → 提示(ms) */
83+ const MISSED_CAPTURE_CHECK_MS = 3000 ;
7884
7985export function useClassroomEvents ( {
8086 captureManager, status, capturePath, language, aiDetectEnabled, setCourseMeta, onNotify,
81- streamingAsrActive, windowTitle,
87+ streamingAsrActive, windowTitle, courseName ,
8288} : UseClassroomEventsOptions ) {
8389 const [ segments , setSegments ] = useState < ExtractedSegment [ ] > ( [ ] ) ;
8490 const [ stats , setStats ] = useState ( { frames : 0 , extracted : 0 } ) ;
@@ -105,6 +111,8 @@ export function useClassroomEvents({
105111 const transcriptSampleRef = useRef ( '' ) ;
106112 /** P1-7 指令补帧节流时间戳 */
107113 const lastForceCaptureAtRef = useRef ( 0 ) ;
114+ /** P1-8 最近一次关键帧时间戳(漏捕检测依据:指令后无新帧即提示) */
115+ const latestKeyframeTsRef = useRef ( 0 ) ;
108116 /** @ai -context 会话时间基准(epoch ms):记录首帧 timestamp,供 analyzePartial 换算相对秒数 */
109117 const sessionStartMsRef = useRef < number | null > ( null ) ;
110118 /** 采集会话 ID(smart:keyframe 事件携带),供笔记持久化关联与关键帧图片清理 */
@@ -148,13 +156,20 @@ export function useClassroomEvents({
148156 console . info ( `[useClassroomCapture] 内容类型识别: ${ result . kind } (${ result . source } )` ) ;
149157 }
150158 }
151- // 指令句补帧(技能场景操作瞬间捕捉)
159+ // 指令句补帧(技能场景操作瞬间捕捉)+ 漏捕检测
152160 if ( hasCommandCue ( text ) ) {
153161 const now = Date . now ( ) ;
154162 if ( now - lastForceCaptureAtRef . current >= FORCE_CAPTURE_MIN_INTERVAL_MS ) {
155163 lastForceCaptureAtRef . current = now ;
156164 captureManager . requestForceCapture ( ) ;
157165 }
166+ // P1-8 漏捕检测:指令后 3s 无新关键帧 → 提示手动补截
167+ const commandAt = now ;
168+ window . setTimeout ( ( ) => {
169+ if ( latestKeyframeTsRef . current < commandAt ) {
170+ toast ( { type : 'warning' , silent : true , message : '这一步的画面可能没捕捉到,可按 C 键手动截图' } ) ;
171+ }
172+ } , MISSED_CAPTURE_CHECK_MS ) ;
158173 }
159174 } ;
160175
@@ -176,7 +191,7 @@ export function useClassroomEvents({
176191 useEffect ( ( ) => {
177192 const offCompleted = captureEventBus . on < {
178193 sessionId : string | null ;
179- result : { text : string ; confidence : number ; source : 'vision' | 'audio' | 'ui_automation' } ;
194+ result : { text : string ; confidence : number ; source : 'vision' | 'audio' | 'ui_automation' ; structured ?: Record < string , unknown > } ;
180195 segment : { id : string ; timestamp : Date } ;
181196 extractedCount : number ;
182197 } > ( 'extraction:completed' , ( data ) => {
@@ -190,6 +205,20 @@ export function useClassroomEvents({
190205 setSegments ( ( prev ) => [ ...prev , uiSegment ] ) ;
191206 setStats ( ( prev ) => ( { ...prev , extracted : data . extractedCount } ) ) ;
192207 setExtractionError ( null ) ;
208+
209+ // P1-2 动态热词闭环:视觉提取的概念术语注入会话热词(弱 boost,
210+ // 不持久化);流式运行中同步更新主进程会话热词(下一断句生效)
211+ if ( data . result . source === 'vision' ) {
212+ const concepts = data . result . structured ?. concepts ;
213+ if ( Array . isArray ( concepts ) ) {
214+ const terms = concepts . filter ( ( c ) : c is string => typeof c === 'string' ) ;
215+ if ( terms . length > 0 ) {
216+ addDynamicBoosts ( terms ) ;
217+ window . electronAPI ?. local_asr_stream_set_hotwords ( { hotwords : getSessionHotwordsString ( ) } )
218+ . catch ( ( ) => { /* 静默 */ } ) ;
219+ }
220+ }
221+ }
193222 } ) ;
194223
195224 const offError = captureEventBus . on < { message : string } > (
@@ -209,6 +238,8 @@ export function useClassroomEvents({
209238 if ( sessionStartMsRef . current === null ) {
210239 sessionStartMsRef . current = data . keyframe . timestamp ;
211240 }
241+ // P1-8:更新最近关键帧时间戳(漏捕检测依据)
242+ latestKeyframeTsRef . current = data . keyframe . timestamp ;
212243 captureSessionIdRef . current = data . sessionId ;
213244 setSmartBundle ( ( prev ) => ( {
214245 ...prev ,
@@ -512,7 +543,8 @@ export function useClassroomEvents({
512543 return off ;
513544 } , [ status , captureManager ] ) ;
514545
515- // P1-2:转写编辑回调——修正直播转录文本,audioText 存修正后文本供下游消费
546+ // P1-2:转写编辑回调——修正直播转录文本,audioText 存修正后文本供下游消费;
547+ // P1-3:修正差异自动回写本地词库(replace 词条,课程维度绑定,下次识别生效)
516548 const handleEditTranscript = useCallback ( ( id : string , newText : string ) => {
517549 setLiveTranscripts ( ( prev ) =>
518550 prev . map ( ( t ) => ( t . id === id ? { ...t , editedText : newText } : t ) ) ,
@@ -524,7 +556,32 @@ export function useClassroomEvents({
524556 s . id === id ? { ...s , audioText : newText , audioTextRaw : s . audioText } : s ,
525557 ) ,
526558 } ) ) ;
527- } , [ ] ) ;
559+
560+ // ── P1-3 修正回写(讯飞「用户修正回写」/飞书「改字同步」闭环)──
561+ const original = liveTranscripts . find ( ( t ) => t . id === id ) ?. text ?? '' ;
562+ const correction = extractCorrection ( original , newText ) ;
563+ if ( correction ) {
564+ // 会话内立即生效(后续转写应用替换)
565+ addSessionReplace ( correction . term , correction . target ) ;
566+ // 持久化词库(去重后写入;失败静默,不影响编辑结果)
567+ hotwordStore . listForCourse ( courseName ) . then ( ( entries ) => {
568+ const exists = entries . some (
569+ ( e ) => e . kind === 'replace' && e . term === correction . term && ( e . target ?? '' ) === correction . target ,
570+ ) ;
571+ if ( ! exists ) {
572+ return hotwordStore . add ( {
573+ term : correction . term ,
574+ target : correction . target ,
575+ kind : 'replace' ,
576+ courseId : courseName ,
577+ enabled : true ,
578+ } ) ;
579+ }
580+ return null ;
581+ } ) . catch ( ( ) => { /* 词库写入失败静默 */ } ) ;
582+ toast ( { type : 'info' , silent : true , message : `已记住修正:「${ correction . term } 」→「${ correction . target } 」` } ) ;
583+ }
584+ } , [ liveTranscripts , courseName , toast ] ) ;
528585
529586 return {
530587 segments, setSegments, stats, setStats, extractionError, setExtractionError,
0 commit comments