|
| 1 | +/** |
| 2 | + * MobileAssistantPanel — 课堂助手移动端面板(视频知识笔记提取) |
| 3 | + * |
| 4 | + * @ai-context: 仅 Capacitor 壳内渲染(isCapacitor 门控,ClassroomPage 装配)。 |
| 5 | + * 两条采集路径:① 导入视频 → 抽音频 → 本地/云端转写 → 知识笔记; |
| 6 | + * ② 录屏采集(小布式边看边录)→ 麦克风流式采集 → 本地 ASR 实时字幕 → |
| 7 | + * 停止后结构化。完成后提供:转写预览、基于转写的课堂问答、一键生成闪卡。 |
| 8 | + * @ai-context EN: mobile-only classroom panel — video import and |
| 9 | + * screen-recording pipelines, then transcript QA and one-click flashcards. |
| 10 | + */ |
| 11 | +import { useCallback, useRef, useState } from 'react'; |
| 12 | +import { useToast } from '@/components/ui'; |
| 13 | +import { |
| 14 | + Upload, MonitorPlay, Square, Loader2, AlertCircle, CheckCircle2, MessageCircleQuestion, Layers, |
| 15 | +} from 'lucide-react'; |
| 16 | +import { cn } from '@/lib/utils'; |
| 17 | +import { |
| 18 | + runVideoImportPipeline, |
| 19 | + runRecordingFlow, |
| 20 | + structureAndSave, |
| 21 | + generateFlashcardsFromTranscript, |
| 22 | + askSessionQuestion, |
| 23 | + type PipelineProgress, |
| 24 | +} from '../lib/mobilePipeline'; |
| 25 | + |
| 26 | +const STAGE_LABELS: Record<PipelineProgress['stage'], string> = { |
| 27 | + idle: '待命', |
| 28 | + picking: '选择视频', |
| 29 | + extracting: '抽取音频', |
| 30 | + transcribing: '语音转写', |
| 31 | + structuring: '生成笔记', |
| 32 | + recording: '录制中', |
| 33 | + done: '完成', |
| 34 | + error: '失败', |
| 35 | +}; |
| 36 | + |
| 37 | +export function MobileAssistantPanel() { |
| 38 | + const { toast } = useToast(); |
| 39 | + const [progress, setProgress] = useState<PipelineProgress>({ stage: 'idle', message: '选择一种采集方式开始' }); |
| 40 | + const [subtitle, setSubtitle] = useState(''); |
| 41 | + const [transcript, setTranscript] = useState(''); |
| 42 | + const [busy, setBusy] = useState(false); |
| 43 | + const [recording, setRecording] = useState(false); |
| 44 | + const [qaQuestion, setQaQuestion] = useState(''); |
| 45 | + const [qaAnswer, setQaAnswer] = useState(''); |
| 46 | + const [qaLoading, setQaLoading] = useState(false); |
| 47 | + const [cardsLoading, setCardsLoading] = useState(false); |
| 48 | + const recordingRef = useRef<Awaited<ReturnType<typeof runRecordingFlow>> | null>(null); |
| 49 | + |
| 50 | + const fail = useCallback((e: unknown) => { |
| 51 | + // 用户取消选择/授权不是错误:回到待命态 |
| 52 | + if (e instanceof Error && e.message === '已取消') { |
| 53 | + setProgress({ stage: 'idle', message: '选择一种采集方式开始' }); |
| 54 | + return; |
| 55 | + } |
| 56 | + setProgress({ stage: 'error', message: e instanceof Error ? e.message : '处理失败,请重试' }); |
| 57 | + }, []); |
| 58 | + |
| 59 | + /** ① 导入视频 → 全流程流水线 */ |
| 60 | + const handleImportVideo = useCallback(async () => { |
| 61 | + if (busy) return; |
| 62 | + setBusy(true); |
| 63 | + setSubtitle(''); |
| 64 | + setQaAnswer(''); |
| 65 | + try { |
| 66 | + await runVideoImportPipeline(setProgress, setTranscript); |
| 67 | + setProgress({ stage: 'done', message: '视频知识笔记已生成,可在笔记中查看' }); |
| 68 | + toast({ type: 'success', message: '视频知识笔记已生成' }); |
| 69 | + } catch (e) { |
| 70 | + fail(e); |
| 71 | + } finally { |
| 72 | + setBusy(false); |
| 73 | + } |
| 74 | + }, [busy, fail, toast]); |
| 75 | + |
| 76 | + /** ② 开始录屏采集(画面 + 麦克风实时字幕) */ |
| 77 | + const handleStartRecording = useCallback(async () => { |
| 78 | + if (busy) return; |
| 79 | + setBusy(true); |
| 80 | + setSubtitle(''); |
| 81 | + setQaAnswer(''); |
| 82 | + setProgress({ stage: 'recording', message: '正在发起录屏授权…' }); |
| 83 | + try { |
| 84 | + const handle = await runRecordingFlow(setProgress, setSubtitle); |
| 85 | + recordingRef.current = handle; |
| 86 | + setRecording(true); |
| 87 | + } catch (e) { |
| 88 | + fail(e); |
| 89 | + } finally { |
| 90 | + setBusy(false); |
| 91 | + } |
| 92 | + }, [busy, fail]); |
| 93 | + |
| 94 | + /** 停止录屏 → 转写收尾 → 结构化 */ |
| 95 | + const handleStopRecording = useCallback(async () => { |
| 96 | + const handle = recordingRef.current; |
| 97 | + if (!handle || busy) return; |
| 98 | + setBusy(true); |
| 99 | + try { |
| 100 | + setProgress({ stage: 'transcribing', message: '整理转写…' }); |
| 101 | + const { text } = await handle.stop(); |
| 102 | + setRecording(false); |
| 103 | + recordingRef.current = null; |
| 104 | + if (text.trim()) { |
| 105 | + setTranscript(text); |
| 106 | + setProgress({ stage: 'structuring', message: '生成知识笔记…' }); |
| 107 | + await structureAndSave(text, 0, 'video'); |
| 108 | + setProgress({ stage: 'done', message: '录屏知识笔记已生成,可在笔记中查看' }); |
| 109 | + toast({ type: 'success', message: '录屏笔记已生成' }); |
| 110 | + } else { |
| 111 | + setProgress({ |
| 112 | + stage: 'error', |
| 113 | + message: '未识别到语音内容。录屏可能无法收录视频原声,建议靠近麦克风讲解或改用「导入视频」', |
| 114 | + }); |
| 115 | + } |
| 116 | + } catch (e) { |
| 117 | + setRecording(false); |
| 118 | + recordingRef.current = null; |
| 119 | + fail(e); |
| 120 | + } finally { |
| 121 | + setBusy(false); |
| 122 | + } |
| 123 | + }, [busy, fail, toast]); |
| 124 | + |
| 125 | + /** 基于转写的课堂问答 */ |
| 126 | + const handleAsk = useCallback(async () => { |
| 127 | + const q = qaQuestion.trim(); |
| 128 | + if (!q || !transcript.trim() || qaLoading) return; |
| 129 | + setQaLoading(true); |
| 130 | + setQaAnswer(''); |
| 131 | + try { |
| 132 | + const { answer } = await askSessionQuestion(transcript, q); |
| 133 | + setQaAnswer(answer); |
| 134 | + } catch (e) { |
| 135 | + setQaAnswer(e instanceof Error ? e.message : '问答失败,请检查网关连通性'); |
| 136 | + } finally { |
| 137 | + setQaLoading(false); |
| 138 | + } |
| 139 | + }, [qaQuestion, transcript, qaLoading]); |
| 140 | + |
| 141 | + /** 一键生成闪卡 */ |
| 142 | + const handleGenerateCards = useCallback(async () => { |
| 143 | + if (!transcript.trim() || cardsLoading) return; |
| 144 | + setCardsLoading(true); |
| 145 | + try { |
| 146 | + const count = await generateFlashcardsFromTranscript(transcript); |
| 147 | + toast({ type: 'success', message: `已生成 ${count} 张闪卡(新牌组)` }); |
| 148 | + } catch (e) { |
| 149 | + toast({ type: 'error', message: e instanceof Error ? e.message : '闪卡生成失败' }); |
| 150 | + } finally { |
| 151 | + setCardsLoading(false); |
| 152 | + } |
| 153 | + }, [transcript, cardsLoading, toast]); |
| 154 | + |
| 155 | + const inProgress = busy || recording; |
| 156 | + const isError = progress.stage === 'error'; |
| 157 | + const hasTranscript = transcript.trim().length > 0; |
| 158 | + |
| 159 | + return ( |
| 160 | + <div className="space-y-2.5"> |
| 161 | + {/* 状态横幅 */} |
| 162 | + <div |
| 163 | + className={cn( |
| 164 | + 'px-3 py-2 rounded-kb-lg text-b3 border', |
| 165 | + isError |
| 166 | + ? 'bg-semantic-error/5 border-semantic-error/15 text-semantic-error' |
| 167 | + : progress.stage === 'done' |
| 168 | + ? 'bg-brand-500/5 border-brand-400/20 text-brand-600' |
| 169 | + : 'bg-bg-elevated/60 border-border/30 text-text-secondary', |
| 170 | + )} |
| 171 | + > |
| 172 | + <div className="flex items-center gap-1.5"> |
| 173 | + {isError ? ( |
| 174 | + <AlertCircle className="w-3.5 h-3.5 flex-shrink-0" /> |
| 175 | + ) : progress.stage === 'done' ? ( |
| 176 | + <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> |
| 177 | + ) : inProgress ? ( |
| 178 | + <Loader2 className="w-3.5 h-3.5 animate-spin flex-shrink-0" /> |
| 179 | + ) : null} |
| 180 | + <span className="font-medium">{STAGE_LABELS[progress.stage]}:</span> |
| 181 | + <span className="truncate">{progress.message}</span> |
| 182 | + </div> |
| 183 | + {progress.total != null && progress.current != null && ( |
| 184 | + <div className="mt-1.5 h-1 rounded-full bg-bg-tertiary overflow-hidden"> |
| 185 | + <div |
| 186 | + className="h-full bg-brand-500 transition-all" |
| 187 | + style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }} |
| 188 | + /> |
| 189 | + </div> |
| 190 | + )} |
| 191 | + </div> |
| 192 | + |
| 193 | + {/* 实时字幕(录屏期间) */} |
| 194 | + {recording && ( |
| 195 | + <div className="px-3 py-2 rounded-kb-lg bg-black/30 border border-white/10 text-b3 text-text-primary min-h-[3rem] max-h-24 overflow-y-auto"> |
| 196 | + <p className="text-[11px] text-text-tertiary mb-1">实时字幕(本地 ASR)</p> |
| 197 | + <p className="leading-relaxed">{subtitle || '等待语音…'}</p> |
| 198 | + </div> |
| 199 | + )} |
| 200 | + |
| 201 | + {/* 操作按钮 */} |
| 202 | + <button |
| 203 | + onClick={handleImportVideo} |
| 204 | + disabled={inProgress} |
| 205 | + className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg border border-dashed border-border/60 text-text-secondary hover:text-brand-600 hover:border-brand-400 active:scale-[0.98] transition-all text-b3 disabled:opacity-60" |
| 206 | + > |
| 207 | + <Upload className="w-4 h-4" /> 导入视频生成笔记(≤60 分钟) |
| 208 | + </button> |
| 209 | + |
| 210 | + {recording ? ( |
| 211 | + <button |
| 212 | + onClick={handleStopRecording} |
| 213 | + disabled={busy} |
| 214 | + className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg bg-semantic-error/10 border border-semantic-error/30 text-semantic-error active:scale-[0.98] transition-all text-b3 disabled:opacity-60" |
| 215 | + > |
| 216 | + <Square className="w-4 h-4" /> 停止录屏并生成笔记 |
| 217 | + </button> |
| 218 | + ) : ( |
| 219 | + <button |
| 220 | + onClick={handleStartRecording} |
| 221 | + disabled={inProgress} |
| 222 | + className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg bg-brand-600/90 text-white hover:bg-brand-600 active:scale-[0.98] transition-all text-b3 disabled:opacity-60" |
| 223 | + > |
| 224 | + <MonitorPlay className="w-4 h-4" /> 录屏采集(边看网课边记) |
| 225 | + </button> |
| 226 | + )} |
| 227 | + |
| 228 | + {/* 转写后增强:问答 + 闪卡 */} |
| 229 | + {hasTranscript && !recording && ( |
| 230 | + <div className="space-y-2 pt-1 border-t border-border/20"> |
| 231 | + <p className="text-[11px] text-text-tertiary">转写完成({transcript.length} 字),可继续:</p> |
| 232 | + <div className="flex gap-2"> |
| 233 | + <input |
| 234 | + value={qaQuestion} |
| 235 | + onChange={(e) => setQaQuestion(e.target.value)} |
| 236 | + onKeyDown={(e) => e.key === 'Enter' && handleAsk()} |
| 237 | + placeholder="向这节课提问,如:核心概念是什么?" |
| 238 | + className="flex-1 px-2.5 py-1.5 rounded-kb-md border border-border/50 text-[12px] bg-bg-elevated/50 focus:outline-none focus:border-brand-400" |
| 239 | + /> |
| 240 | + <button |
| 241 | + onClick={handleAsk} |
| 242 | + disabled={qaLoading || !qaQuestion.trim()} |
| 243 | + className="flex items-center gap-1 px-3 py-1.5 rounded-kb-md bg-brand-500/10 border border-brand-400/30 text-brand-600 text-[12px] active:scale-95 transition-all disabled:opacity-50" |
| 244 | + > |
| 245 | + {qaLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MessageCircleQuestion className="w-3.5 h-3.5" />} |
| 246 | + 提问 |
| 247 | + </button> |
| 248 | + </div> |
| 249 | + {qaAnswer && ( |
| 250 | + <div className="px-3 py-2 rounded-kb-lg bg-bg-elevated/60 border border-border/30 text-b3 leading-relaxed max-h-40 overflow-y-auto"> |
| 251 | + {qaAnswer} |
| 252 | + </div> |
| 253 | + )} |
| 254 | + <button |
| 255 | + onClick={handleGenerateCards} |
| 256 | + disabled={cardsLoading} |
| 257 | + className="w-full flex items-center justify-center gap-2 py-2 rounded-kb-lg border border-dashed border-border/60 text-text-secondary hover:text-brand-600 hover:border-brand-400 active:scale-[0.98] transition-all text-b3 disabled:opacity-60" |
| 258 | + > |
| 259 | + {cardsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Layers className="w-4 h-4" />} |
| 260 | + 一键生成间隔重复闪卡 |
| 261 | + </button> |
| 262 | + </div> |
| 263 | + )} |
| 264 | + |
| 265 | + {isError && ( |
| 266 | + <p className="text-[11px] text-text-tertiary text-center"> |
| 267 | + 可重试,或检查 AI 网关连通性后再次尝试 |
| 268 | + </p> |
| 269 | + )} |
| 270 | + </div> |
| 271 | + ); |
| 272 | +} |
0 commit comments