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;
4350const SHOW_OCR_LINES = 4 ;
4451/** 内存保留上限(计数独立累加,截断只影响可显示的历史) */
4552const MAX_KEPT = 100 ;
53+ /** 未沉淀行显示上限(防御极端连续定稿;超限先沉淀已定稿行) */
54+ const MAX_PENDING_LINES = 8 ;
4655
4756let seq = 0 ;
4857const nextId = ( ) => ++ seq ;
58+ /** 句子序(未沉淀行 id;跨会话单调递增即可,不重置) */
59+ let pendingSeq = 0 ;
60+ const nextPendingId = ( ) => ++ pendingSeq ;
4961
5062function 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