Skip to content

Commit d84ad14

Browse files
committed
feat(ocr): 前端屏卡流+笔记屏段落配图+实时面板按屏摘要
1 parent 8e9e889 commit d84ad14

4 files changed

Lines changed: 164 additions & 24 deletions

File tree

‎app/src/components/LiveActivityPanel.tsx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ interface PendingLine {
4040
committed: boolean;
4141
}
4242

43-
/** 画面要点行 */
43+
/** 画面要点行(v0.7.3 REQ-161:一行=一屏摘要——同屏块合并显示) */
4444
interface OcrLine {
4545
id: number;
4646
time: number;
47+
/** 屏号(同屏事件合并为一行) */
48+
screenId: number;
4749
text: string;
4850
}
4951

@@ -235,8 +237,20 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
235237
}),
236238
listen<OcrEvent>("live:ocr", (e) => {
237239
setOcrLines((prev) => {
238-
// TD-043:时间戳取后端会话纪元
239-
const next = [...prev, { id: nextId(), time: e.payload.timestampMs, text: e.payload.text }];
240+
// TD-043:时间戳取后端会话纪元;v0.7.3(REQ-161):同屏块合并为
241+
// 一行屏摘要(首块 + 后续小字块追加,防 175 行碎片刷屏)
242+
const last = prev[prev.length - 1];
243+
if (last && last.screenId === e.payload.screenId) {
244+
const next = [...prev];
245+
const text =
246+
last.text.length < 80 ? `${last.text} ${e.payload.text}` : last.text;
247+
next[next.length - 1] = { ...last, text };
248+
return next;
249+
}
250+
const next = [
251+
...prev,
252+
{ id: nextId(), time: e.payload.timestampMs, screenId: e.payload.screenId, text: e.payload.text },
253+
];
240254
return next.length > MAX_KEPT ? next.slice(next.length - MAX_KEPT) : next;
241255
});
242256
countsRef.current.ocr += 1;
@@ -459,13 +473,15 @@ export default function LiveActivityPanel({ sessionId }: { sessionId?: number |
459473
<span style={{ fontSize: 11, color: "#9ca3af", width: 44, flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
460474
{fmtTime(o.time)}
461475
</span>
462-
<span style={{ width: 8, height: 8, borderRadius: 4, flexShrink: 0, alignSelf: "center", background: "#2563eb" }} />
476+
<span style={{ fontSize: 10, color: "#2563eb", flexShrink: 0, fontWeight: 600 }}>
477+
屏{o.screenId}
478+
</span>
463479
<span style={{ color: "#1e40af" }}>{o.text}</span>
464480
</div>
465481
))}
466482
{counts.ocr > SHOW_OCR_LINES && (
467483
<p style={{ fontSize: 11, color: "#9ca3af", margin: "4px 0 0", paddingLeft: 52 }}>
468-
⋯ 共 {counts.ocr} 条,仅显示最近 {SHOW_OCR_LINES} 条(会话页可看全部)
484+
⋯ 共 {counts.ocr} 块 / {ocrLines.length} 屏,仅显示最近 {SHOW_OCR_LINES} 屏(会话页可看全部)
469485
</p>
470486
)}
471487
</div>

‎app/src/components/NotePreviewView.tsx‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* 生效,落库时经 ai_decisions 回传保持输出一致。
1212
*/
1313
import { useCallback, useEffect, useState } from "react";
14-
import { invoke } from "@tauri-apps/api/core";
14+
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
1515
import type { NoteFilterResult, TextFilterDecision, TextFilterReview, TextFilterStatus } from "../types";
1616

1717
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12 };
@@ -36,12 +36,19 @@ function escapeHtml(s: string): string {
3636
.replace(/'/g, "&#39;");
3737
}
3838

39-
/** 轻量 Markdown 渲染(标题/段落/列表——笔记正文结构有限,避免引渲染库;
40-
* 所有文本经 escapeHtml 转义——本地内容仍按不可信输入处理) */
41-
function renderMarkdown(md: string): string {
39+
/** 轻量 Markdown 渲染(标题/段落/列表/屏配图——笔记正文结构有限,避免引渲染库;
40+
* 所有文本经 escapeHtml 转义——本地内容仍按不可信输入处理;
41+
* v0.7.3:`![alt](session-images/...)` 配图行 → 本地图(baseUrl + convertFileSrc)) */
42+
function renderMarkdown(md: string, imageBaseUrl: string): string {
4243
return md
4344
.split("\n")
4445
.map((line) => {
46+
// 屏配图行(v0.7.3 REQ-160:笔记画面要点配图,本地路径不出本机)
47+
const img = line.match(/^\s*-\s*!\[([^\]]*)\]\(([^)]*)\)$/);
48+
if (img) {
49+
const src = imageBaseUrl ? convertFileSrc(`${imageBaseUrl}/${img[2]}`) : "";
50+
return `<img src="${src}" alt="${escapeHtml(img[1])}" loading="lazy" style="max-width:260px;border-radius:6px;border:1px solid #e5e7eb;margin:4px 0" />`;
51+
}
4552
if (line.startsWith("# ")) return `<h2 style="font-size:15px;margin:10px 0 4px">${escapeHtml(line.slice(2))}</h2>`;
4653
if (line.startsWith("## ")) return `<h3 style="font-size:13px;margin:8px 0 4px;color:#0f766e">${escapeHtml(line.slice(3))}</h3>`;
4754
if (line.startsWith("- ")) return `<div style="font-size:12px;color:#4b5563">• ${escapeHtml(line.slice(2))}</div>`;
@@ -60,6 +67,8 @@ export default function NotePreviewView({ sessionId }: { sessionId: number }) {
6067
const [aiStatus, setAiStatus] = useState<TextFilterStatus | null>(null);
6168
// 审查修复(2026-08-19):AI 判定列表——落库回传保证预览/落库一致
6269
const [aiDecisions, setAiDecisions] = useState<TextFilterDecision[]>([]);
70+
// v0.7.3(REQ-160):屏配图 baseUrl(图集同款)
71+
const [baseUrl, setBaseUrl] = useState("");
6372

6473
const load = useCallback(async () => {
6574
try {
@@ -73,7 +82,10 @@ export default function NotePreviewView({ sessionId }: { sessionId: number }) {
7382
useEffect(() => {
7483
void load();
7584
void invoke<TextFilterStatus>("text_filter_status").then(setAiStatus).catch(() => undefined);
76-
}, [load]);
85+
void invoke<string>("session_images_base_url", { sessionId })
86+
.then(setBaseUrl)
87+
.catch(() => setBaseUrl(""));
88+
}, [load, sessionId]);
7789

7890
/** 一键落库(复用 session_to_note;AI 判定结果回传保持预览一致——REQ-081) */
7991
const saveToNote = async () => {
@@ -147,7 +159,7 @@ export default function NotePreviewView({ sessionId }: { sessionId: number }) {
147159
</span>
148160
))}
149161
<span style={{ fontSize: 11, color: "#6b7280", alignSelf: "center" }}>
150-
保留 {preview.kept.length} 段 · 画面要点 {preview.ocr_points.length} 条
162+
保留 {preview.kept.length} 段 · 画面要点 {preview.ocr_screens.length} 屏
151163
</span>
152164
</div>
153165

@@ -184,7 +196,7 @@ export default function NotePreviewView({ sessionId }: { sessionId: number }) {
184196
{/* 过滤后笔记正文 */}
185197
<div
186198
style={{ fontSize: 13, lineHeight: 1.6, background: "#fff", border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 }}
187-
dangerouslySetInnerHTML={{ __html: renderMarkdown(preview.markdown) }}
199+
dangerouslySetInnerHTML={{ __html: renderMarkdown(preview.markdown, baseUrl) }}
188200
/>
189201

190202
{/* 被过滤内容对照(可复查误杀——来源定位原料) */}

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111
import { useEffect, useState } from "react";
1212
import { invoke } from "@tauri-apps/api/core";
13+
import { convertFileSrc } from "@tauri-apps/api/core";
1314
import ArtifactView from "../components/ArtifactView";
1415
import ImageGallery from "../components/ImageGallery";
1516
import NotePreviewView from "../components/NotePreviewView";
@@ -49,6 +50,8 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
4950
const [quality, setQuality] = useState<QualityReport | null>(null);
5051
// M6(REQ-077):大纲(产物视图侧边导航)
5152
const [outline, setOutline] = useState<OutlineEntry[]>([]);
53+
// v0.7.3(REQ-160):屏卡配图 baseUrl(图集同款:convertFileSrc 拼本地路径)
54+
const [baseUrl, setBaseUrl] = useState("");
5255
const sessionId = detail.session.id;
5356

5457
// 质量报告 + 大纲随详情加载(失败不阻断详情展示)
@@ -62,6 +65,9 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
6265
void invoke<OutlineEntry[]>("session_outline", { id: sessionId })
6366
.then(setOutline)
6467
.catch(() => undefined);
68+
void invoke<string>("session_images_base_url", { sessionId })
69+
.then(setBaseUrl)
70+
.catch(() => setBaseUrl(""));
6571
}, [sessionId]);
6672

6773
return (
@@ -230,18 +236,91 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
230236
))}
231237
</div>
232238

233-
{/* 画面要点 */}
234-
<h3 style={{ fontSize: 13, margin: "16px 0 6px" }}>画面要点(OCR)</h3>
235-
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
236-
{detail.ocr_blocks.length === 0 && <p style={{ fontSize: 12, color: "#9ca3af" }}>本会话无画面识别内容</p>}
237-
{detail.ocr_blocks.map((b) => (
238-
<div key={b.id} id={`ocr-${sessionId}-${b.timestamp_ms}`} style={{ fontSize: 12, color: "#4b5563" }}>
239-
<span style={{ color: "#9ca3af", fontVariantNumeric: "tabular-nums" }}>[{fmtMs(b.timestamp_ms)}]</span>{" "}
240-
{b.text}
241-
{b.region === "subtitle" && <span style={{ color: "#0d9488", marginLeft: 4 }}>字幕</span>}
239+
{/* 画面要点(v0.7.3 屏卡流:区间+标题+正文+标签+配图+结构徽标;可展开块级明细复查) */}
240+
<h3 style={{ fontSize: 13, margin: "16px 0 6px" }}>
241+
画面要点(OCR)· {detail.screens.length} 屏
242+
<span style={{ color: "#9ca3af", fontWeight: 400 }}>
243+
{detail.screens.length === 0 ? "" : `(原始 ${detail.ocr_blocks.length} 块)`}
244+
</span>
245+
</h3>
246+
{detail.screens.length === 0 && (
247+
<p style={{ fontSize: 12, color: "#9ca3af" }}>本会话无画面识别内容</p>
248+
)}
249+
{detail.screens.map((s, i) => {
250+
// 块级明细(原料复查):屏时间区间内的原始块
251+
const raw = detail.ocr_blocks.filter(
252+
(b) => b.timestamp_ms >= s.first_seen_ms && b.timestamp_ms <= s.last_seen_ms,
253+
);
254+
return (
255+
<div
256+
key={i}
257+
id={`ocr-${sessionId}-${s.first_seen_ms}`}
258+
style={{
259+
border: "1px solid #e5e7eb",
260+
borderRadius: 8,
261+
padding: "8px 10px",
262+
marginBottom: 8,
263+
background: "#fafafa",
264+
}}
265+
>
266+
<div style={{ fontSize: 11, color: "#0f766e", fontWeight: 600, marginBottom: 4 }}>
267+
📄 屏 {s.screen_id ?? i + 1} · {fmtMs(s.first_seen_ms)} – {fmtMs(s.last_seen_ms)}
268+
{s.structure.length > 0 &&
269+
s.structure.map((st, j) => (
270+
<span key={j} style={{ marginLeft: 8, color: "#7c3aed" }}>
271+
{st.kind === "table" ? "📊" : st.kind === "formula" ? "∑" : "⟨code⟩"} {st.kind}
272+
</span>
273+
))}
274+
</div>
275+
{s.title && (
276+
<div style={{ fontSize: 14, fontWeight: 600, color: "#111827", marginBottom: 2 }}>
277+
{s.title}
278+
</div>
279+
)}
280+
{s.body.map((b, j) => (
281+
<div key={j} style={{ fontSize: 12.5, color: "#374151", lineHeight: 1.6 }}>
282+
{b}
283+
</div>
284+
))}
285+
{s.labels.length > 0 && (
286+
<div style={{ fontSize: 11.5, color: "#6b7280", marginTop: 3 }}>
287+
标签:{s.labels.join(" · ")}
288+
</div>
289+
)}
290+
{s.structure.length > 0 && (
291+
<div style={{ fontSize: 11.5, color: "#7c3aed", marginTop: 3 }}>
292+
{s.structure.map((st, j) => (
293+
<div key={j}>[{st.kind}] {st.rendered ?? st.text.slice(0, 60)}</div>
294+
))}
295+
</div>
296+
)}
297+
{s.image_ref && baseUrl && (
298+
<img
299+
src={convertFileSrc(`${baseUrl}/${s.image_ref}`)}
300+
alt={`屏 ${i + 1}`}
301+
loading="lazy"
302+
style={{
303+
maxWidth: 260,
304+
marginTop: 6,
305+
borderRadius: 6,
306+
border: "1px solid #e5e7eb",
307+
display: "block",
308+
}}
309+
/>
310+
)}
311+
{raw.length > 0 && (
312+
<details style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
313+
<summary style={{ cursor: "pointer" }}>块级明细({raw.length} 块,可复查误合并)</summary>
314+
{raw.map((b) => (
315+
<div key={b.id}>
316+
[{fmtMs(b.timestamp_ms)}] {b.text}
317+
</div>
318+
))}
319+
</details>
320+
)}
242321
</div>
243-
))}
244-
</div>
322+
);
323+
})}
245324

246325
{/* 参考图集(v0.5.0 M6:REQ-051 三层图结构) */}
247326
<h3 style={{ fontSize: 13, margin: "16px 0 6px" }}>参考图集</h3>

‎app/src/types.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,39 @@ export interface SessionOcrBlock {
9696
region: string;
9797
}
9898

99-
/** 会话详情(会话 + 转写段 + OCR 块) */
99+
/** 屏内结构块(Rust ScreenStructure;v0.7.3 REQ-159) */
100+
export interface ScreenStructure {
101+
kind: string; // table | formula | code
102+
text: string;
103+
/** 精修渲染产物(None=未精修/失败) */
104+
rendered: string | null;
105+
}
106+
107+
/** 画面要点屏(Rust SessionScreen;v0.7.3 REQ-155/158/160,ADR-015) */
108+
export interface SessionScreen {
109+
session_id: number;
110+
/** 屏号(None=旧数据聚类派生) */
111+
screen_id: number | null;
112+
first_seen_ms: number;
113+
last_seen_ms: number;
114+
/** 标题角色行(None=无 bbox 降级/无标题) */
115+
title: string | null;
116+
/** 正文行(行合并后) */
117+
body: string[];
118+
/** 图注/标签 */
119+
labels: string[];
120+
/** 归档 full 图相对路径(None=无匹配图) */
121+
image_ref: string | null;
122+
structure: ScreenStructure[];
123+
}
124+
125+
/** 会话详情(会话 + 转写段 + OCR 块 + 画面要点屏) */
100126
export interface SessionDetail {
101127
session: Session;
102128
segments: SessionSegment[];
103129
ocr_blocks: SessionOcrBlock[];
130+
/** v0.7.3:画面要点屏卡(旧数据聚类兜底) */
131+
screens: SessionScreen[];
104132
}
105133

106134
/** 流式 ASR 模型就绪状态 */
@@ -146,6 +174,8 @@ export interface ImportProgress {
146174
export interface OcrEvent {
147175
timestampMs: number;
148176
text: string;
177+
/** v0.7.3:块所属屏号(前端按屏摘要显示) */
178+
screenId: number;
149179
}
150180

151181
/** 会话信息(Rust SessionInfo,camelCase 契约;live:session-info 事件,v0.7.2 REQ-151) */
@@ -416,7 +446,10 @@ export interface NoteFilterResult {
416446
title: string;
417447
markdown: string;
418448
kept: SessionSegment[];
449+
/** 画面要点(屏段落行) */
419450
ocr_points: string[];
451+
/** v0.7.3:画面要点屏(结构化渲染用) */
452+
ocr_screens: SessionScreen[];
420453
stats: FilterStats;
421454
filtered: FilteredItem[];
422455
merged: MergedItem[];

0 commit comments

Comments
 (0)