Skip to content

Commit 0ab8af8

Browse files
committed
fix(classroom): ASR 未沉淀行多行展示不覆盖丢失
1 parent 50c8b1e commit 0ab8af8

1 file changed

Lines changed: 82 additions & 40 deletions

File tree

‎app/src/components/LiveActivityPanel.tsx‎

Lines changed: 82 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
* LiveActivityPanel — 实时活动面板(采集期间右侧核心反馈,简要设计)。
33
*
44
* @ai-context: 用户要求"转写流要简要"——设计原则:单行紧凑卡片、色点区分来源
5-
* (字幕=绿、语音=灰、画面=蓝)、无冗余装饰;partial 仅显示当前行;
6-
* 列表超限截断(计数保留);新内容自动跟随滚动。
5+
* (字幕=绿、语音=灰、画面=蓝)、无冗余装饰;列表超限截断(计数保留);
6+
* 新内容自动跟随滚动。
7+
* @ai-context: 2026-08 用户需求:ASR 流式返回显示**所有未沉淀行**(识别中 partial +
8+
* 已定稿待沉淀 committed),不再折叠为单行——连续定稿(快速短句)
9+
* 时各行并存不被覆盖丢失;新句首个 partial 到达时统一沉淀。
710
* @ai-context: 状态机:初始化(模型加载)→ 采集中 → 停止中 → 融合中 → 完成/失败,
811
* 由 live:status / session:fusing/fused/failed 事件推导,父组件控制显隐。
912
*/
@@ -22,11 +25,15 @@ interface TranscriptLine {
2225
}
2326

2427
/**
25-
* 识别中行(M3/REQ-038 流式先行 + 静默修正):
26-
* committed=false = partial 上屏(灰色斜体"识别中");
27-
* committed=true = SenseVoice 重打分定稿(原位转黑,无闪烁无跳动)。
28+
* 识别中行(M3/REQ-038 流式先行 + 静默修正;2026-08 扩展为多行挂起):
29+
* committed=false = partial 上屏(灰色斜体"识别中",同句流式更新原位替换);
30+
* committed=true = SenseVoice 重打分定稿(原位转黑,待新句开始统一沉淀入列表)。
31+
* id=句子序:同句 partial→final 复用同一 id(保证 React key 稳定不跳动)。
2832
*/
29-
interface PartialLine {
33+
interface PendingLine {
34+
id: number;
35+
/** 定稿时刻(会话纪元 ms;未定稿阶段 0——展示用实时时钟) */
36+
time: number;
3037
text: string;
3138
committed: boolean;
3239
}
@@ -43,9 +50,14 @@ const SHOW_TRANSCRIPT_LINES = 6;
4350
const SHOW_OCR_LINES = 4;
4451
/** 内存保留上限(计数独立累加,截断只影响可显示的历史) */
4552
const MAX_KEPT = 100;
53+
/** 未沉淀行显示上限(防御极端连续定稿;超限先沉淀已定稿行) */
54+
const MAX_PENDING_LINES = 8;
4655

4756
let seq = 0;
4857
const nextId = () => ++seq;
58+
/** 句子序(未沉淀行 id;跨会话单调递增即可,不重置) */
59+
let pendingSeq = 0;
60+
const nextPendingId = () => ++pendingSeq;
4961

5062
function fmtTime(ms: number): string {
5163
const s = Math.floor(ms / 1000);
@@ -57,16 +69,16 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
5769
// 状态机(简要徽标文本)
5870
const [phase, setPhase] = useState<string>("正在初始化…");
5971
const [transcripts, setTranscripts] = useState<TranscriptLine[]>([]);
60-
const [partial, setPartial] = useState<PartialLine | null>(null);
72+
// 未沉淀行列表(识别中 partial + 已定稿待沉淀 committed;2026-08 多行挂起)
73+
const [partials, setPartials] = useState<PendingLine[]>([]);
6174
const [ocrLines, setOcrLines] = useState<OcrLine[]>([]);
6275
// 累计计数(列表截断后仍保留)
6376
const countsRef = useRef({ subtitle: 0, asr: 0, ocr: 0 });
6477
const [counts, setCounts] = useState({ subtitle: 0, asr: 0, ocr: 0 });
6578
const startedAtRef = useRef<number | null>(null);
6679
const [, setTick] = useState(0);
67-
// TD-053 修复:partial 以 ref 镜像(事件回调读最新值),沉淀副作用移出 setState updater
68-
const partialRef = useRef<PartialLine | null>(null);
69-
const lastFinalTimeRef = useRef(0);
80+
// TD-053 修复:partials 以 ref 镜像(事件回调读最新值),沉淀副作用移出 setState updater
81+
const partialsRef = useRef<PendingLine[]>([]);
7082

7183
// 时长计时(1s tick,仅展示)
7284
useEffect(() => {
@@ -85,11 +97,25 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
8597
countsRef.current.asr += 1;
8698
setCounts({ ...countsRef.current });
8799
};
88-
// 事件回调统一入口:更新 partial 状态并同步 ref 镜像(TD-053:副作用在回调,
100+
// 事件回调统一入口:更新未沉淀行列表并同步 ref 镜像(TD-053:副作用在回调,
89101
// 不在 updater——StrictMode 双调用不再导致计数双加/ID 跳号)
90-
const applyPartial = (next: PartialLine | null) => {
91-
partialRef.current = next;
92-
setPartial(next);
102+
const applyPartials = (next: PendingLine[]) => {
103+
let list = next;
104+
// 防御极端连续定稿:超上限先沉淀已定稿行(剩余通常 ≤1 条识别中行)
105+
if (list.length > MAX_PENDING_LINES) {
106+
list = settleCommitted(list);
107+
}
108+
partialsRef.current = list;
109+
setPartials(list);
110+
};
111+
/** 沉淀全部已定稿行入转写列表(按各自定稿时刻),返回剩余未定稿行 */
112+
const settleCommitted = (list: PendingLine[]): PendingLine[] => {
113+
for (const line of list) {
114+
if (line.committed && line.text.trim()) {
115+
settleAsrLine(line.text, line.time);
116+
}
117+
}
118+
return list.filter((l) => !l.committed);
93119
};
94120

95121
const unlisteners: Promise<() => void>[] = [
@@ -99,35 +125,46 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
99125
setPhase("● 采集中");
100126
startedAtRef.current = startedAtRef.current ?? Date.now();
101127
} else if (e.payload === "stopped") {
102-
// 停止:把已定稿未沉淀的行并入列表(防末句丢失,T2 语义)
103-
const prev = partialRef.current;
104-
if (prev?.committed && prev.text.trim()) {
105-
settleAsrLine(prev.text, lastFinalTimeRef.current);
106-
}
107-
applyPartial(null);
128+
// 停止:全部已定稿行沉淀入列表(防末句丢失,T2 语义);识别中残余清空
129+
applyPartials(settleCommitted(partialsRef.current));
108130
setPhase("⏹ 已停止");
109131
} else if (e.payload === "failed") {
110132
setPhase("⚠ 采集异常");
111133
}
112134
}),
113135
listen<string>("live:asr-partial", (e) => {
114-
// 新 partial 到来:若上一行已定稿未沉淀 → 先沉淀再显示新识别行
115-
const prev = partialRef.current;
116-
if (prev?.committed && prev.text.trim()) {
117-
settleAsrLine(prev.text, lastFinalTimeRef.current);
136+
// 流式更新分两种:同句(末行未定稿 → 原位替换文本);新句(末行已定稿或
137+
// 无行 → 先沉淀全部已定稿行,再开新行)。后端单流保证:final 之后的
138+
// partial 必属新句(端点已重建流)——无需显式句 id 协议
139+
const list = partialsRef.current;
140+
const last = list[list.length - 1];
141+
if (last && !last.committed) {
142+
applyPartials([...list.slice(0, -1), { ...last, text: e.payload }]);
143+
} else {
144+
applyPartials([
145+
...settleCommitted(list),
146+
{ id: nextPendingId(), time: 0, text: e.payload, committed: false },
147+
]);
118148
}
119-
applyPartial({ text: e.payload, committed: false });
120149
}),
121150
listen<AsrFinalEvent>("live:asr-final", (e) => {
122-
lastFinalTimeRef.current = e.payload.timestampMs;
123151
// M3/REQ-038 静默修正:partial 行原位灰→黑(无闪烁无跳动);
124-
// 无 partial(快速断句)时直接沉淀为定稿行
125-
const prev = partialRef.current;
126-
if (prev && prev.text.trim()) {
127-
applyPartial({ text: e.payload.text, committed: true });
152+
// 无 partial(快速断句)时直接沉淀为定稿行;
153+
// 连续定稿(上一行已定稿未沉淀)→ 新行追加——修复原实现互相覆盖丢失
154+
const list = partialsRef.current;
155+
const last = list[list.length - 1];
156+
if (last && !last.committed) {
157+
applyPartials([
158+
...list.slice(0, -1),
159+
{ ...last, text: e.payload.text, time: e.payload.timestampMs, committed: true },
160+
]);
161+
} else if (last) {
162+
applyPartials([
163+
...list,
164+
{ id: nextPendingId(), time: e.payload.timestampMs, text: e.payload.text, committed: true },
165+
]);
128166
} else {
129167
settleAsrLine(e.payload.text, e.payload.timestampMs);
130-
applyPartial(null);
131168
}
132169
}),
133170
listen<SubtitleEvent>("live:subtitle", (e) => {
@@ -223,7 +260,7 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
223260
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
224261
{/* 2026-08 用户需求:实时图片数据(最近画面条;独立区域,图片更新不引起转写行跳动) */}
225262
<LiveImageStrip sessionId={sessionId ?? null} />
226-
{shownTranscripts.length === 0 && !partial && (
263+
{shownTranscripts.length === 0 && partials.length === 0 && (
227264
<p style={{ fontSize: 12, color: "#9ca3af" }}>等待识别…(说话或屏幕出现字幕时显示)</p>
228265
)}
229266
{shownTranscripts.map((t) => (
@@ -250,32 +287,37 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
250287
⋯ 共 {totalTranscript} 段,仅显示最近 {SHOW_TRANSCRIPT_LINES} 条(会话页可看全部)
251288
</p>
252289
)}
253-
{partial && (
290+
{/* 2026-08 用户需求:ASR 未沉淀行全部展示(识别中灰斜 + 已定稿待沉淀黑),
291+
连续定稿各行并存;新句首个 partial 到达时统一沉淀入上方列表 */}
292+
{partials.map((p) => (
254293
<div
294+
key={p.id}
255295
style={{
256296
display: "flex",
257297
gap: 8,
258298
alignItems: "baseline",
259299
fontSize: 13,
260-
color: partial.committed ? "#374151" : "#9ca3af",
261-
fontStyle: partial.committed ? "normal" : "italic",
300+
color: p.committed ? "#374151" : "#9ca3af",
301+
fontStyle: p.committed ? "normal" : "italic",
262302
}}
263303
>
264-
<span style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>{fmtTime(elapsedMs)}</span>
304+
<span style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
305+
{p.committed ? fmtTime(p.time) : fmtTime(elapsedMs)}
306+
</span>
265307
<span
266-
title={partial.committed ? "已定稿" : "识别中"}
308+
title={p.committed ? "已定稿待沉淀" : "识别中"}
267309
style={{
268310
width: 8,
269311
height: 8,
270312
borderRadius: 4,
271313
flexShrink: 0,
272314
alignSelf: "center",
273-
background: partial.committed ? "#9ca3af" : "#d1d5db",
315+
background: p.committed ? "#9ca3af" : "#d1d5db",
274316
}}
275317
/>
276-
<span>{partial.text}{partial.committed ? "" : "…"}</span>
318+
<span>{p.text}{p.committed ? "" : "…"}</span>
277319
</div>
278-
)}
320+
))}
279321
</div>
280322
) : (
281323
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>

0 commit comments

Comments
 (0)