Skip to content

Commit 747cd8c

Browse files
committed
refactor(components): 采集期转写行渲染拆至 TranscriptRow
1 parent 17d3309 commit 747cd8c

3 files changed

Lines changed: 179 additions & 136 deletions

File tree

‎app/src/components/LiveTranscriptStream.tsx‎

Lines changed: 6 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,13 @@ import { useEffect, useRef, useState } from "react";
1313
import { listen } from "@tauri-apps/api/event";
1414
// 2026-08 用户需求:实时转写中显示图片数据(转写 Tab 顶部"最近画面"条,独立区域不跳动)
1515
import LiveImageStrip from "./LiveImageStrip";
16+
// 批 7 T20(§C9.19 先拆后改):**行**渲染拆至 `TranscriptRow.tsx`(DOM 与文案逐字未动);
17+
// 本件只留状态、四个事件订阅与两处「列表级」文案(空态行 / 共 N 段提示行)。
18+
import { PendingRows, TranscriptRow } from "./TranscriptRow";
19+
import type { PendingLine, TranscriptLine } from "./TranscriptRow";
1620
import type { AsrFinalEvent, SubtitleEvent } from "../types";
1721
import { Text } from "../ui/primitives";
1822

19-
/** 定稿转写行(字幕或语音) */
20-
interface TranscriptLine {
21-
id: number;
22-
time: number; // 会话相对毫秒(前端按事件到达估算,展示 mm:ss)
23-
source: "subtitle" | "asr";
24-
text: string;
25-
}
26-
27-
/**
28-
* 识别中行(M3/REQ-038 流式先行 + 静默修正;2026-08 扩展为多行挂起):
29-
* committed=false = partial 上屏(灰色斜体"识别中",同句流式更新原位替换);
30-
* committed=true = SenseVoice 重打分定稿(原位转黑,待新句开始统一沉淀入列表)。
31-
* id=句子序:同句 partial→final 复用同一 id(保证 React key 稳定不跳动)。
32-
*/
33-
interface PendingLine {
34-
id: number;
35-
/** 定稿时刻(会话纪元 ms;未定稿阶段 0——展示用实时时钟) */
36-
time: number;
37-
text: string;
38-
committed: boolean;
39-
}
40-
4123
/** 显示条数(简要:只显示最近几条,总数在状态行计数) */
4224
const SHOW_TRANSCRIPT_LINES = 6;
4325
/** 未沉淀行显示上限(防御极端连续定稿;超限先沉淀已定稿行) */
@@ -47,35 +29,6 @@ const MAX_PENDING_LINES = 8;
4729
let pendingSeq = 0;
4830
const nextPendingId = () => ++pendingSeq;
4931

50-
/**
51-
* 中文句读切分(识别中行展示用,2026-08 用户需求:识别中的灰斜体内容全部显示)。
52-
*
53-
* @ai-context: 流式 partial 是整句候选且可能含多个句子(zipformer 中文模型输出
54-
* 带句读;asr_clean 的跨标点重复/纯标点幻觉处理即依赖此事实)——
55-
* 现状整句挤在一行灰斜里越滚越长;按句读切分后每句一行,全部可见。
56-
* 句读保留在句尾;无句读尾段为"残余"(调用方加 … 表示仍在识别)。
57-
* 不切英文句点(Mr./U.S. 缩写防误切)与逗号(句内成分不拆行)。
58-
* 连续句读("结束。。")切出的纯标点段过滤(防垃圾行)。
59-
*/
60-
function splitBySentence(text: string): string[] {
61-
const parts: string[] = [];
62-
let buf = "";
63-
for (const ch of text) {
64-
buf += ch;
65-
if ("。!?!?…".includes(ch)) {
66-
if (hasText(buf)) parts.push(buf);
67-
buf = "";
68-
}
69-
}
70-
if (hasText(buf)) parts.push(buf);
71-
return parts;
72-
}
73-
74-
/** 段是否含实质内容(过滤纯标点/空白段——连续句读切出的垃圾段不展示) */
75-
function hasText(seg: string): boolean {
76-
return seg.trim().length > 0 && !/^[。!?!?…\s]+$/.test(seg);
77-
}
78-
7932
export default function LiveTranscriptStream({
8033
active, sessionId, counts, onBump, onPhase, onStarted, elapsedMs, fmtTime, nextId, maxKept,
8134
}: {
@@ -207,91 +160,14 @@ export default function LiveTranscriptStream({
207160
<Text as="p" size={5} tone="ink-3">等待识别…(说话或屏幕出现字幕时显示)</Text>
208161
)}
209162
{shownTranscripts.map((t) => (
210-
<div key={t.id} style={{ display: "flex", gap: 8, alignItems: "baseline", fontSize: 13, lineHeight: 1.6 }}>
211-
<Text tone="ink-3" style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
212-
{fmtTime(t.time)}
213-
</Text>
214-
<span
215-
title={t.source === "subtitle" ? "字幕" : "语音"}
216-
style={{
217-
width: 8,
218-
height: 8,
219-
borderRadius: 4,
220-
flexShrink: 0,
221-
alignSelf: "center",
222-
background: t.source === "subtitle" ? "#0d9488" : "#9ca3af",
223-
}}
224-
/>
225-
<span style={{ color: t.source === "subtitle" ? "#0f766e" : "#374151" }}>{t.text}</span>
226-
</div>
163+
<TranscriptRow key={t.id} line={t} fmtTime={fmtTime} />
227164
))}
228165
{totalTranscript > SHOW_TRANSCRIPT_LINES && (
229166
<Text as="p" tone="ink-3" style={{ fontSize: 11, margin: "4px 0 0", paddingLeft: 52 }}>
230167
⋯ 共 {totalTranscript} 段,仅显示最近 {SHOW_TRANSCRIPT_LINES} 条(会话页可看全部)
231168
</Text>
232169
)}
233-
{/* 2026-08 用户需求:ASR 未沉淀行全部展示——识别中(灰斜)按句读拆多行
234-
全部显示;已定稿待沉淀(黑)一行;连续定稿各行并存;新句首个
235-
partial 到达时统一沉淀入上方列表 */}
236-
{partials.map((p) => {
237-
if (p.committed) {
238-
return (
239-
<div
240-
key={p.id}
241-
style={{
242-
display: "flex",
243-
gap: 8,
244-
alignItems: "baseline",
245-
fontSize: 13,
246-
color: "#374151",
247-
}}
248-
>
249-
<span style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
250-
{fmtTime(p.time)}
251-
</span>
252-
<span
253-
title="已定稿待沉淀"
254-
style={{
255-
width: 8,
256-
height: 8,
257-
borderRadius: 4,
258-
flexShrink: 0,
259-
alignSelf: "center",
260-
background: "#9ca3af",
261-
}}
262-
/>
263-
<span>{p.text}</span>
264-
</div>
265-
);
266-
}
267-
// 识别中:整句候选按句读切分多行灰斜体——"识别中的内容全部显示";
268-
// 首行带时间,后续行对齐留空;残余段(无句读尾段)加 …
269-
const segs = splitBySentence(p.text);
270-
return segs.map((seg, i) => (
271-
<Text as="div" size={4} tone="ink-3" key={`${p.id}-${i}`} style={{
272-
display: "flex",
273-
gap: 8,
274-
alignItems: "baseline",
275-
fontStyle: "italic",
276-
}}>
277-
<span style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
278-
{i === 0 ? fmtTime(elapsedMs) : ""}
279-
</span>
280-
<span
281-
title="识别中"
282-
style={{
283-
width: 8,
284-
height: 8,
285-
borderRadius: 4,
286-
flexShrink: 0,
287-
alignSelf: "center",
288-
background: "#d1d5db",
289-
}}
290-
/>
291-
<span>{seg}{i === segs.length - 1 ? "…" : ""}</span>
292-
</Text>
293-
));
294-
})}
170+
<PendingRows partials={partials} fmtTime={fmtTime} elapsedMs={elapsedMs} />
295171
</div>
296172
);
297173
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* TranscriptRow — 采集期实时转写流的**行渲染**(批 7 T20 自 `LiveTranscriptStream.tsx` 纯搬迁而出)。
3+
*
4+
* @ai-context 为什么拆(§C9.19「先拆后改」):主件 `LiveTranscriptStream.tsx` 在拆前是 **297/300**
5+
* (余 3),而 T20 的 B 块要给它挂「逐段显影」(§C9.16 的 7b 半,约 +15~30 行)⇒ 余量装不下。
6+
* 本件承载**行**这一层,主件只留状态与四个事件订阅 ⇒ 两个文件各自留出余量。
7+
* 拆这件**只搬不改**:DOM 结构、类名、行内样式、文案一个字未动(判据 = 结构快照逐字节对拍)。
8+
* @ai-context 边界:本件**零状态、零订阅、零 IPC** —— 采集事件的累积(`partialsRef` 镜像、
9+
* `settleCommitted` 沉淀、`MAX_PENDING_LINES` 防御)全在 `LiveTranscriptStream.tsx`;本件拿到的是
10+
* 「已经算好的行数组 + 两个时间口(`fmtTime` / `elapsedMs`)」。`nextPendingId` 同样留在主件
11+
* (**句子序是跨行状态**,搬进来就会造出第二份计数器)。
12+
* @ai-context 搬迁守恒:`TranscriptLine` / `PendingLine` 两个行类型与 `splitBySentence` / `hasText`
13+
* 两个纯函数随代码搬入本件(主件 `import type` 取类型)—— 同一份实现只有一处,不各持一份。
14+
* 副作用:无(纯函数式渲染)。边界:`partials` 里 `committed === false` 的行按句读**一对多**展开
15+
* (一行文本出多行),故本件返回的是**数组**而非单元素(`<>…</>` 不产生 DOM 节点)。
16+
*/
17+
import type { ReactElement } from "react";
18+
import { Text } from "../ui/primitives";
19+
20+
/** 定稿转写行(字幕或语音) */
21+
export interface TranscriptLine {
22+
id: number;
23+
time: number; // 会话相对毫秒(前端按事件到达估算,展示 mm:ss)
24+
source: "subtitle" | "asr";
25+
text: string;
26+
}
27+
28+
/**
29+
* 识别中行(M3/REQ-038 流式先行 + 静默修正;2026-08 扩展为多行挂起):
30+
* committed=false = partial 上屏(灰色斜体"识别中",同句流式更新原位替换);
31+
* committed=true = SenseVoice 重打分定稿(原位转黑,待新句开始统一沉淀入列表)。
32+
* id=句子序:同句 partial→final 复用同一 id(保证 React key 稳定不跳动)。
33+
*/
34+
export interface PendingLine {
35+
id: number;
36+
/** 定稿时刻(会话纪元 ms;未定稿阶段 0——展示用实时时钟) */
37+
time: number;
38+
text: string;
39+
committed: boolean;
40+
}
41+
42+
/**
43+
* 中文句读切分(识别中行展示用,2026-08 用户需求:识别中的灰斜体内容全部显示)。
44+
*
45+
* @ai-context: 流式 partial 是整句候选且可能含多个句子(zipformer 中文模型输出
46+
* 带句读;asr_clean 的跨标点重复/纯标点幻觉处理即依赖此事实)——
47+
* 现状整句挤在一行灰斜里越滚越长;按句读切分后每句一行,全部可见。
48+
* 句读保留在句尾;无句读尾段为"残余"(调用方加 … 表示仍在识别)。
49+
* 不切英文句点(Mr./U.S. 缩写防误切)与逗号(句内成分不拆行)。
50+
* 连续句读("结束。。")切出的纯标点段过滤(防垃圾行)。
51+
*/
52+
function splitBySentence(text: string): string[] {
53+
const parts: string[] = [];
54+
let buf = "";
55+
for (const ch of text) {
56+
buf += ch;
57+
if ("。!?!?…".includes(ch)) {
58+
if (hasText(buf)) parts.push(buf);
59+
buf = "";
60+
}
61+
}
62+
if (hasText(buf)) parts.push(buf);
63+
return parts;
64+
}
65+
66+
/** 段是否含实质内容(过滤纯标点/空白段——连续句读切出的垃圾段不展示) */
67+
function hasText(seg: string): boolean {
68+
return seg.trim().length > 0 && !/^[。!?!?…\s]+$/.test(seg);
69+
}
70+
71+
/** 一条**定稿**转写行(时间码 · 来源色点 · 正文)—— 形态与拆前逐字相同 */
72+
export function TranscriptRow({ line, fmtTime }: { line: TranscriptLine; fmtTime: (ms: number) => string }): ReactElement {
73+
return (
74+
<div style={{ display: "flex", gap: 8, alignItems: "baseline", fontSize: 13, lineHeight: 1.6 }}>
75+
<Text tone="ink-3" style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
76+
{fmtTime(line.time)}
77+
</Text>
78+
<span
79+
title={line.source === "subtitle" ? "字幕" : "语音"}
80+
style={{
81+
width: 8,
82+
height: 8,
83+
borderRadius: 4,
84+
flexShrink: 0,
85+
alignSelf: "center",
86+
background: line.source === "subtitle" ? "#0d9488" : "#9ca3af",
87+
}}
88+
/>
89+
<span style={{ color: line.source === "subtitle" ? "#0f766e" : "#374151" }}>{line.text}</span>
90+
</div>
91+
);
92+
}
93+
94+
/**
95+
* **未沉淀行**区(识别中 + 已定稿待沉淀)。2026-08 用户需求:ASR 未沉淀行全部展示——
96+
* 识别中(灰斜)按句读拆多行全部显示;已定稿待沉淀(黑)一行;连续定稿各行并存;
97+
* 新句首个 partial 到达时统一沉淀入上方列表(沉淀逻辑在主件,本件只渲染)。
98+
*/
99+
export function PendingRows({ partials, fmtTime, elapsedMs }: {
100+
partials: readonly PendingLine[];
101+
fmtTime: (ms: number) => string;
102+
elapsedMs: number;
103+
}): ReactElement {
104+
return (
105+
<>
106+
{partials.map((p) => {
107+
if (p.committed) {
108+
return (
109+
<div
110+
key={p.id}
111+
style={{
112+
display: "flex",
113+
gap: 8,
114+
alignItems: "baseline",
115+
fontSize: 13,
116+
color: "#374151",
117+
}}
118+
>
119+
<span style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
120+
{fmtTime(p.time)}
121+
</span>
122+
<span
123+
title="已定稿待沉淀"
124+
style={{
125+
width: 8,
126+
height: 8,
127+
borderRadius: 4,
128+
flexShrink: 0,
129+
alignSelf: "center",
130+
background: "#9ca3af",
131+
}}
132+
/>
133+
<span>{p.text}</span>
134+
</div>
135+
);
136+
}
137+
// 识别中:整句候选按句读切分多行灰斜体——"识别中的内容全部显示";
138+
// 首行带时间,后续行对齐留空;残余段(无句读尾段)加 …
139+
const segs = splitBySentence(p.text);
140+
return segs.map((seg, i) => (
141+
<Text as="div" size={4} tone="ink-3" key={`${p.id}-${i}`} style={{
142+
display: "flex",
143+
gap: 8,
144+
alignItems: "baseline",
145+
fontStyle: "italic",
146+
}}>
147+
<span style={{ fontSize: 11, width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
148+
{i === 0 ? fmtTime(elapsedMs) : ""}
149+
</span>
150+
<span
151+
title="识别中"
152+
style={{
153+
width: 8,
154+
height: 8,
155+
borderRadius: 4,
156+
flexShrink: 0,
157+
alignSelf: "center",
158+
background: "#d1d5db",
159+
}}
160+
/>
161+
<span>{seg}{i === segs.length - 1 ? "…" : ""}</span>
162+
</Text>
163+
));
164+
})}
165+
</>
166+
);
167+
}

‎app/src/ui/primitives/textBaseline.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ export const FROZEN_MUTED_GRAY_FILES = 43;
3838
export const FROZEN_FONT_OOB_TOTAL = 551;
3939

4040
/** 字号越界冻结**文件数**(条目数;只许降) */
41-
export const FROZEN_FONT_OOB_FILES = 122; // 120 → 122:批 7 T6 拆件,键随代码走(Σ 551 不变,一处文件变三处)
41+
export const FROZEN_FONT_OOB_FILES = 123; // 120 → 122:批 7 T6 拆件;122 → 123:批 7 T20 拆件(键随代码走,Σ 551 不变)
4242

4343
/**
4444
* 锚(anti-table-swap):条目数 + 一个具名文件的冻结值;换一张同样「自洽」的表 ⇒ 锚必红。
4545
* ⚠️ **T16-B 换锚**:T16-A 的锚(`LiveActivityPanel` 9)正是本单元的迁移目标(9 → 2)⇒ 随迁移失效。
4646
* 新锚 = `NoteLinkToSystem.tsx`:5 处**全部**是登记例外(B1 硬守卫 + 品牌青三元)⇒ 无裁决不可能下降。
4747
*/
4848
export const ANCHOR_MUTED_GRAY = { file: "components/NoteLinkToSystem.tsx", count: 5, files: 43 } as const;
49-
export const ANCHOR_FONT_OOB = { file: "components/action-center/ActionCenterPanel.tsx", count: 19, files: 122 } as const;
49+
export const ANCHOR_FONT_OOB = { file: "components/action-center/ActionCenterPanel.tsx", count: 19, files: 123 } as const;
5050

5151
/**
5252
* 相对 `app/src` 的正斜杠路径 → 该文件**允许残留的弱化灰行数上限**(只许降,键不许新增)。
@@ -72,7 +72,6 @@ export const FROZEN_MUTED_GRAY_BY_FILE: Readonly<Record<string, number>> = {
7272
"components/KnowledgeSystemWizard.tsx": 2,
7373
"components/KnowledgeTreeView.tsx": 1,
7474
"components/LearningLibraryEngineSection.tsx": 2,
75-
"components/LiveTranscriptStream.tsx": 2,
7675
"components/MaterialInputPanel.tsx": 1,
7776
"components/NoteColorPicker.tsx": 2,
7877
"components/NoteEditView.tsx": 1,
@@ -92,6 +91,7 @@ export const FROZEN_MUTED_GRAY_BY_FILE: Readonly<Record<string, number>> = {
9291
"components/SecondPassPanel.tsx": 1,
9392
"components/SessionListPanel.tsx": 1,
9493
"components/TaskConversationView.tsx": 1,
94+
"components/TranscriptRow.tsx": 2,
9595
"components/VideoImportPanel.tsx": 1,
9696
"components/session-detail/SessionScreenCards.tsx": 1,
9797
"pages/KnowledgePage.tsx": 3,
@@ -140,7 +140,7 @@ export const RESIDUAL: readonly { file: string; kind: TextResidualKind; reason:
140140
{ file: "components/KnowledgeSystemWizard.tsx", kind: "ternary-no-equivalent", reason: "`:187` 步骤徽标两支非灰(active #0f766e 品牌青 / done #047857)+ 背景 / 描边条件;`:244` 原生 `<button disabled>` ⇒ 无等价档 / `Button` 域" },
141141
{ file: "components/KnowledgeTreeView.tsx", kind: "interactive", reason: "`:162` `onClick` + `data-testid`(展开 / 折叠节点)⇒ 可交互文本属 `Button` 域(批 5/7)" },
142142
{ file: "components/LearningLibraryEngineSection.tsx", kind: "interactive-no-equivalent", reason: "2 处原生 `<button disabled>`(下载模型 / 加载引擎)+ 非灰支 #374151 / #0f766e + `background` 三元 ⇒ 交互 + 无等价档" },
143-
{ file: "components/LiveTranscriptStream.tsx", kind: "nontext", reason: "2 处 `background`(转写来源色点 / 识别中色点底色)不是墨度 ⇒ B19 第 5 条:不迁不动;批 7 T6 拆件时自 `components/LiveActivityPanel.tsx` 整段搬来(键随代码走:条目数与总数 43/63 均不变)" },
143+
{ file: "components/TranscriptRow.tsx", kind: "nontext", reason: "2 处 `background`(转写来源色点 / 识别中色点底色)不是墨度 ⇒ B19 第 5 条:不迁不动;批 7 T6 拆件时自 `components/LiveActivityPanel.tsx` 整段搬入 `LiveTranscriptStream.tsx`,批 7 T20 再按 §C9.19 随代码搬入本件(键随代码走:两次拆件的条目数与总数 43/63 均不变)" },
144144
{ file: "components/MaterialInputPanel.tsx", kind: "interactive-no-equivalent", reason: "原生 `<button disabled>` + 非灰支 #fff(品牌青实底白字)⇒ 交互 + 无等价档" },
145145
{ file: "components/NoteColorPicker.tsx", kind: "nontext", reason: "2 处 `border`(悬停描边 / 清除色虚线框)不是墨度 ⇒ B19 第 5 条" },
146146
{ file: "components/NoteEditView.tsx", kind: "b1-non-migrated", reason: "B1/B2 硬守卫:本文件在 `NON_MIGRATED_14` 内(`dialogMigration.e.test.ts` ② + `buttonMigration.test.ts` ④ 断言「不迁的 14 个不许被顺手迁掉」)——加原语 import 会绕过守卫 ⇒ 本处(快捷键提示)不迁" },
@@ -230,7 +230,7 @@ export const FROZEN_FONT_OOB_BY_FILE: Readonly<Record<string, number>> = {
230230
"components/LiveImageStrip.tsx": 6,
231231
"components/LiveOcrPreview.tsx": 3,
232232
"components/LiveProfileStrip.tsx": 8,
233-
"components/LiveTranscriptStream.tsx": 4,
233+
"components/LiveTranscriptStream.tsx": 1,
234234
"components/MaterialInputPanel.tsx": 3,
235235
"components/ModelCardCreateDialog.tsx": 1,
236236
"components/ModelCardFromNoteDialog.tsx": 1,
@@ -279,7 +279,7 @@ export const FROZEN_FONT_OOB_BY_FILE: Readonly<Record<string, number>> = {
279279
"components/SystemStatusBadge.tsx": 2,
280280
"components/TaskConversationView.tsx": 3,
281281
"components/TaskLaunchDialog.tsx": 1,
282-
"components/TaskThreadCard.tsx": 7,
282+
"components/TaskThreadCard.tsx": 7, "components/TranscriptRow.tsx": 3,
283283
"components/VersionPanel.tsx": 10,
284284
"components/VideoImportPanel.tsx": 4,
285285
"components/VocabManager.tsx": 6,

0 commit comments

Comments
 (0)