|
| 1 | +/** |
| 2 | + * useClassroomHints — 课堂助手页级提示 / 融合编排 / 流式模型与预热状态(批 0-C2 |
| 3 | + * Task 4 步 2 自 ClassroomPage.tsx 抽出,纯搬运、行为等价)。 |
| 4 | + * |
| 5 | + * @ai-context: 职责 = 页面级提示横幅与状态行文案的**唯一状态源**:三条采集期横幅 |
| 6 | + * (ASR 降级 / 目标窗口丢失 / 画面停更)+ 融合三事件对右栏直达卡片与 |
| 7 | + * 状态行的影响 + 流式模型四态与下载进度 + 引擎预热状态(prepareState)。 |
| 8 | + * 采集生命周期(active/starting/pending/notice)由页面从 |
| 9 | + * useCaptureControl 注入——本 hook 不自持、也不再订阅 live:status。 |
| 10 | + * @ai-context: ★ D1(最高危隐性耦合):`warmUp` 必须由页面**注入**而非本 hook 自建 |
| 11 | + * ——`model:download-done` 监听里要调它(下载完成即预热,用户无需重进 |
| 12 | + * 页面);`warmedRef` 的 StrictMode 一次性守卫留在页面与 warmUp 同处。 |
| 13 | + * `prepareState` 由本 hook 自持并**导出 setter**:页面的 startLive 需按 |
| 14 | + * start 返回的 prepare 重同步(v0.19.3 审查 LOW-2)。少任何一条都不会 |
| 15 | + * 编译报错,而是"下载完成即预热"或"点击时重同步"静默消失。 |
| 16 | + * @ai-context: 副作用 = 12 条 listen(live:error / asr-degraded / asr-recovered / |
| 17 | + * window-lost / frame-stalled / frame-recovered / session:fusing / |
| 18 | + * fused / fusion-failed / model:download-progress / done / failed)+ |
| 19 | + * 2 条启动查询(asr_streaming_model_status / model_download_status)+ |
| 20 | + * 3 条状态镜像 effect(会话结束复位横幅 / starting 收口文案 / 看门狗结论)。 |
| 21 | + * @ai-context: 边界(本批只搬不改)——`status`/`lastNote` 留页面(D5),本 hook 只经 |
| 22 | + * `onStatus` 写回,避免状态行双源;三条横幅渲染在 ClassroomBanners、 |
| 23 | + * 模型卡在 ClassroomCapturePanel;B1/G6/G7 既有缺陷原样保留。 |
| 24 | + * @ai-context: effect deps 逐字保留原值(`[]` / `[active]` / `[starting, active]` / |
| 25 | + * `[notice]`)——`onStatus`(=setStatus)与 `warmUp`(useCallback []) |
| 26 | + * 身份恒定,故不入 deps 不改变重订阅时机(与拆分前完全一致)。 |
| 27 | + */ |
| 28 | +import { useCallback, useEffect, useRef, useState } from "react"; |
| 29 | +import { invoke } from "@tauri-apps/api/core"; |
| 30 | +import { listen } from "@tauri-apps/api/event"; |
| 31 | +import type { WatchdogNotice } from "./liveCaptureState"; |
| 32 | +import type { DownloadProgress, DownloadStatus, StreamingModelStatus } from "../types"; |
| 33 | + |
| 34 | +/** P3:引擎预热状态(与 Rust PrepareStatus 的 camelCase 契约一致) */ |
| 35 | +export type PrepareState = "idle" | "loading" | "ready" | "failed"; |
| 36 | + |
| 37 | +interface Options { |
| 38 | + /** 采集中(会话结束 → 清空三条页面级提示横幅) */ |
| 39 | + active: boolean; |
| 40 | + /** 启动过渡态(starting 结束且已 active → 覆写状态行"实时捕获已开始") */ |
| 41 | + starting: boolean; |
| 42 | + /** 看门狗结论(start-unconfirmed → 状态行镜像) */ |
| 43 | + notice: WatchdogNotice | null; |
| 44 | + /** 页面持有的预热回调(D1:`model:download-done` 依赖它,不得在本 hook 内重建) */ |
| 45 | + warmUp: () => void; |
| 46 | + /** 状态行写入(D5:status 状态源在页面) */ |
| 47 | + onStatus: (message: string) => void; |
| 48 | +} |
| 49 | + |
| 50 | +export function useClassroomHints({ active, starting, notice, warmUp, onStatus }: Options) { |
| 51 | + // ── 实时捕获页面级提示与编排(v0.2.0;采集生命周期状态见 useCaptureControl)── |
| 52 | + // 后台融合期(session:fusing 期间,右侧面板显示"融合中") |
| 53 | + const [fusionActive, setFusionActive] = useState(false); |
| 54 | + // 2026-08 A4:最近融合完成的会话 id(右侧"查看时间轴"直达卡片;切换窗口/新会话时清除) |
| 55 | + const [fusedSessionId, setFusedSessionId] = useState<number | null>(null); |
| 56 | + const [modelStatus, setModelStatus] = useState<StreamingModelStatus | null>(null); |
| 57 | + const [modelDownloading, setModelDownloading] = useState(false); |
| 58 | + const [modelProgress, setModelProgress] = useState<DownloadProgress | null>(null); |
| 59 | + const [modelError, setModelError] = useState(""); |
| 60 | + const [liveError, setLiveError] = useState(""); |
| 61 | + // M7/REQ-042 F5:ASR 降级提示(流式引擎静默失效可见化) |
| 62 | + const [asrDegraded, setAsrDegraded] = useState<string | null>(null); |
| 63 | + // TD-2026-08-20-I 清偿:目标窗口丢失横幅(采集中画面源不可见提示) |
| 64 | + const [windowLost, setWindowLost] = useState(false); |
| 65 | + // REQ-281(v0.19.6):画面源停更提示(WGC 长时间无新帧——区别于窗口关闭; |
| 66 | + // 恢复帧/停止采集自动清除;null=未停更) |
| 67 | + const [frameStalledSecs, setFrameStalledSecs] = useState<number | null>(null); |
| 68 | + // P3:引擎预热状态(选窗口阶段后台加载;与 Rust PrepareStatus 契约一致) |
| 69 | + const [prepareState, setPrepareState] = useState<PrepareState>("idle"); |
| 70 | + |
| 71 | + // 页面级提示与融合编排监听(v0.2.0)。批 2b:live:status / live:paused / |
| 72 | + // live:resumed / live:media-* 不再在此订阅——采集生命周期(含随播随停随前台 |
| 73 | + // 自动暂停)收敛于 CaptureStatusProvider(useCaptureControl),防同窗双监听 |
| 74 | + useEffect(() => { |
| 75 | + const unlisteners: Promise<() => void>[] = [ |
| 76 | + // 引擎错误横幅(页面级文案;starting 退出由控制 hook 的 engine-error 收敛) |
| 77 | + listen<string>("live:error", (e) => setLiveError(e.payload)), |
| 78 | + // M7/REQ-042 F5:ASR 降级提示(静默失败可见化;会话停止时清除) |
| 79 | + listen<string>("live:asr-degraded", (e) => setAsrDegraded(e.payload)), |
| 80 | + // 降级恢复(审查修复):清除降级横幅,避免残留误导 |
| 81 | + listen("live:asr-recovered", () => setAsrDegraded(null)), |
| 82 | + // TD-2026-08-20-I 清偿:目标窗口丢失提示(采集中画面源不可见的唯一信号; |
| 83 | + // 会话停止时清除) |
| 84 | + listen("live:window-lost", () => setWindowLost(true)), |
| 85 | + // REQ-281(v0.19.6):画面停更提示(帧恢复/停止时清除——不复原真实播放器 |
| 86 | + // 状态,仅提示画面源未出新帧;伴随 WGC 会话自愈重试) |
| 87 | + listen<{ silentSecs: number }>("live:frame-stalled", (e) => setFrameStalledSecs(e.payload.silentSecs)), |
| 88 | + listen("live:frame-recovered", () => setFrameStalledSecs(null)), |
| 89 | + // 后台融合事件(REQ-031):面板显示"融合中",完成后提示并回退 |
| 90 | + // 2026-08 A4:记录融合完成会话 id(右侧"查看时间轴"直达卡片)。 |
| 91 | + // (fusing 同时是采集停止的兜底信号——其状态收敛在控制 hook,此处只管 |
| 92 | + // 面板显示与直达卡片,非重复状态机) |
| 93 | + listen<number>("session:fusing", (e) => { |
| 94 | + setFusionActive(true); |
| 95 | + setFusedSessionId(e.payload); |
| 96 | + }), |
| 97 | + listen<number>("session:fused", (e) => { |
| 98 | + setFusionActive(false); |
| 99 | + setFusedSessionId(e.payload); |
| 100 | + onStatus("融合完成,可到「会话」页查看融合时间轴"); |
| 101 | + }), |
| 102 | + listen<string>("session:fusion-failed", (e) => { |
| 103 | + setFusionActive(false); |
| 104 | + // 审查修复:融合失败不得残留"✅ 融合完成"直达卡片(fusing 已预置 id) |
| 105 | + setFusedSessionId(null); |
| 106 | + onStatus(`融合失败(原始段保留): ${e.payload}`); |
| 107 | + }), |
| 108 | + // 模型自动下载进度(ADR-003) |
| 109 | + listen<DownloadProgress>("model:download-progress", (e) => setModelProgress(e.payload)), |
| 110 | + listen<boolean>("model:download-done", () => { |
| 111 | + setModelDownloading(false); |
| 112 | + setModelProgress(null); |
| 113 | + // 审查补充:状态复查失败不能产生 unhandled rejection(与 TD-016 同口径) |
| 114 | + void invoke<StreamingModelStatus>("asr_streaming_model_status") |
| 115 | + .then(setModelStatus) |
| 116 | + .catch((e) => setModelError(`模型状态复查失败: ${e}`)); |
| 117 | + // P3:模型就绪后立即预热(下载完成即可开始即录,无需重进页面) |
| 118 | + warmUp(); |
| 119 | + }), |
| 120 | + // 下载失败:重置"下载中"态并展示错误(审查 M4 修复) |
| 121 | + listen<string>("model:download-failed", (e) => { |
| 122 | + setModelDownloading(false); |
| 123 | + setModelProgress(null); |
| 124 | + setModelError(`下载失败: ${e.payload}(可重试或手动放置模型)`); |
| 125 | + }), |
| 126 | + ]; |
| 127 | + return () => { |
| 128 | + unlisteners.forEach((p) => void p.then((fn) => fn())); |
| 129 | + }; |
| 130 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 131 | + }, []); |
| 132 | + |
| 133 | + // 会话结束侧效复位(原 live:status stopped/failed 分支职责——停止信号现由 |
| 134 | + // 控制 hook 收敛为 active=false;此处只清页面级提示横幅,不留双状态) |
| 135 | + useEffect(() => { |
| 136 | + if (!active) { |
| 137 | + setAsrDegraded(null); |
| 138 | + setWindowLost(false); |
| 139 | + setFrameStalledSecs(null); |
| 140 | + } |
| 141 | + }, [active]); |
| 142 | + |
| 143 | + // recording 收口文案:starting 结束时若已 active → 覆写"实时捕获已开始" |
| 144 | + // (engineReady=false 受理路径的"引擎就绪中…"在此被真实开录事件替换; |
| 145 | + // 与 v0.19.3 MED-1 的语义一致——starting 迁移不得残留就绪中文案) |
| 146 | + const prevStartingRef = useRef(false); |
| 147 | + useEffect(() => { |
| 148 | + const prev = prevStartingRef.current; |
| 149 | + prevStartingRef.current = starting; |
| 150 | + if (prev && !starting && active) onStatus("实时捕获已开始"); |
| 151 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 152 | + }, [starting, active]); |
| 153 | + |
| 154 | + // 看门狗结论镜像到状态行(unconfirmed=引擎未开录可重试——20s 未确认的单一 |
| 155 | + // 去向,见 liveCaptureState.WatchdogNotice 注释;"start-restored" 已删无产出 |
| 156 | + // 分支,审查 P3-6——恢复成功路径由轮询快照逐拍收敛,无独立文案可镜像) |
| 157 | + useEffect(() => { |
| 158 | + if (notice === "start-unconfirmed") onStatus("启动状态未确认——引擎未开录;可直接重试(就绪即秒开)"); |
| 159 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 160 | + }, [notice]); |
| 161 | + |
| 162 | + // 启动时检查流式模型状态 + 下载状态恢复。 |
| 163 | + // 批 2b:活动会话恢复(live_session_status 拉取)已下沉 CaptureStatusProvider |
| 164 | + // 挂载兜底——本页不再独立查询(防双查询/双状态机) |
| 165 | + // TD-016:invoke 失败不再静默——展示错误并允许重试(此前按钮永久禁用且无提示) |
| 166 | + useEffect(() => { |
| 167 | + void invoke<StreamingModelStatus>("asr_streaming_model_status") |
| 168 | + .then(setModelStatus) |
| 169 | + .catch((e) => setModelError(`模型状态查询失败: ${e}`)); |
| 170 | + void invoke<DownloadStatus>("model_download_status").then((d) => { |
| 171 | + setModelDownloading(d.state === "downloading"); |
| 172 | + if (d.state === "failed" && d.error) setModelError(d.error); |
| 173 | + }); |
| 174 | + }, []); |
| 175 | + |
| 176 | + /** 重试模型状态检查(TD-016:查询失败后手动恢复按钮可用性) */ |
| 177 | + const retryModelStatus = async () => { |
| 178 | + setModelError(""); |
| 179 | + try { |
| 180 | + const s = await invoke<StreamingModelStatus>("asr_streaming_model_status"); |
| 181 | + setModelStatus(s); |
| 182 | + } catch (e) { |
| 183 | + setModelError(`模型状态查询失败: ${e}`); |
| 184 | + } |
| 185 | + }; |
| 186 | + |
| 187 | + /** 一键下载流式 ASR 模型(应用内自动配置) */ |
| 188 | + const downloadModel = async () => { |
| 189 | + setModelError(""); |
| 190 | + setModelDownloading(true); |
| 191 | + try { |
| 192 | + await invoke("download_streaming_model"); |
| 193 | + } catch (e) { |
| 194 | + setModelError(`下载启动失败: ${e}`); |
| 195 | + setModelDownloading(false); |
| 196 | + } |
| 197 | + }; |
| 198 | + |
| 199 | + /** 「知道了」关闭目标窗口丢失横幅(渲染在 ClassroomBanners) */ |
| 200 | + const dismissWindowLost = useCallback(() => setWindowLost(false), []); |
| 201 | + |
| 202 | + return { |
| 203 | + fusionActive, |
| 204 | + fusedSessionId, |
| 205 | + setFusedSessionId, |
| 206 | + modelStatus, |
| 207 | + modelDownloading, |
| 208 | + modelProgress, |
| 209 | + modelError, |
| 210 | + liveError, |
| 211 | + setLiveError, |
| 212 | + asrDegraded, |
| 213 | + windowLost, |
| 214 | + dismissWindowLost, |
| 215 | + frameStalledSecs, |
| 216 | + prepareState, |
| 217 | + setPrepareState, |
| 218 | + retryModelStatus, |
| 219 | + downloadModel, |
| 220 | + }; |
| 221 | +} |
0 commit comments