Skip to content

Commit 19ca5cb

Browse files
committed
feat(asr): REQ-270 文本校对开关与建议裁决面板
1 parent 1904c2c commit 19ca5cb

4 files changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* ProofreadPanel — 可选 LLM 文本校对面板(v0.20.2 / REQ-270)。
3+
*
4+
* @ai-context: 建议制流程——预估成本 → 勾选「仅文本上云」授权 → 运行(后端
5+
* proofread_run,逐句建议落 origin=proofread 草稿)→ 逐条采纳/回退
6+
* (复用 second_pass_decide 裁决通道,原料 session_segments 永不变);
7+
* 双闸门(proofread_enabled + content_gate)未开时给出明确引导文案。
8+
*/
9+
import { useCallback, useEffect, useState } from "react";
10+
import { invoke } from "@tauri-apps/api/core";
11+
import type { RefineDraftView, SecondPassView } from "./SecondPassPanel";
12+
13+
interface EstimateView {
14+
sentences: number;
15+
chars: number;
16+
cost_yuan: number;
17+
model: string;
18+
capped: boolean;
19+
}
20+
21+
interface RunView {
22+
draft_count: number;
23+
suggestions_received: number;
24+
chars: number;
25+
cost_yuan: number;
26+
model: string;
27+
capped: boolean;
28+
}
29+
30+
interface Props {
31+
sessionId: number;
32+
onClose: () => void;
33+
}
34+
35+
const overlayStyle: React.CSSProperties = {
36+
position: "fixed",
37+
inset: 0,
38+
background: "rgba(0,0,0,.45)",
39+
zIndex: 1000,
40+
display: "flex",
41+
alignItems: "center",
42+
justifyContent: "center",
43+
};
44+
const cardStyle: React.CSSProperties = {
45+
background: "#fff",
46+
borderRadius: 12,
47+
width: 680,
48+
maxWidth: "92vw",
49+
maxHeight: "82vh",
50+
overflow: "auto",
51+
padding: 16,
52+
fontSize: 13,
53+
};
54+
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
55+
const okBtn: React.CSSProperties = { ...btn, background: "#0d9488", color: "#fff", border: "none" };
56+
const ghostBtn: React.CSSProperties = { ...btn, background: "#fff", border: "1px solid #e5e7eb", color: "#374151" };
57+
58+
/** 相对会话起点 mm:ss(纯本地格式化) */
59+
function fmtClock(ms: number): string {
60+
const totalSec = Math.floor(ms / 1000);
61+
return `${String(Math.floor(totalSec / 60) % 60).padStart(2, "0")}:${String(totalSec % 60).padStart(2, "0")}`;
62+
}
63+
64+
export default function ProofreadPanel({ sessionId, onClose }: Props) {
65+
const [est, setEst] = useState<EstimateView | null>(null);
66+
const [list, setList] = useState<SecondPassView | null>(null);
67+
const [consent, setConsent] = useState(false);
68+
const [busy, setBusy] = useState(false);
69+
const [err, setErr] = useState("");
70+
const [msg, setMsg] = useState("");
71+
72+
const reload = useCallback(async () => {
73+
try {
74+
setErr("");
75+
const [e, l] = await Promise.all([
76+
invoke<EstimateView>("proofread_estimate", { sessionId }),
77+
invoke<SecondPassView>("proofread_list", { sessionId }),
78+
]);
79+
setEst(e);
80+
setList(l);
81+
} catch (e) {
82+
setErr(`加载失败: ${e}`);
83+
}
84+
}, [sessionId]);
85+
86+
useEffect(() => {
87+
void reload();
88+
}, [reload]);
89+
90+
const run = async () => {
91+
if (!consent) {
92+
setErr("请先勾选「我已阅读并同意:本次仅上传转写文本用于校对」");
93+
return;
94+
}
95+
setBusy(true);
96+
setErr("");
97+
setMsg("校对进行中(仅文本上云;长会话分块多次请求)…");
98+
try {
99+
const v = await invoke<RunView>("proofread_run", { sessionId, authorized: true });
100+
setMsg(
101+
`校对完成:${v.suggestions_received} 条建议 → ${v.draft_count} 条待裁决草稿(预估 ¥${v.cost_yuan.toFixed(4)})${v.capped ? "(超出 240 句部分未校对)" : ""}`,
102+
);
103+
await reload();
104+
} catch (e) {
105+
setErr(String(e));
106+
setMsg("");
107+
} finally {
108+
setBusy(false);
109+
}
110+
};
111+
112+
const decide = async (draftId: number, adopt: boolean) => {
113+
try {
114+
await invoke("second_pass_decide", { sessionId, draftId, adopt });
115+
await reload();
116+
} catch (e) {
117+
setErr(`裁决失败: ${e}`);
118+
}
119+
};
120+
121+
const pending = list?.pending ?? 0;
122+
123+
return (
124+
<div style={overlayStyle} onClick={onClose}>
125+
<div style={cardStyle} onClick={(e) => e.stopPropagation()}>
126+
<div style={{ display: "flex", alignItems: "center", marginBottom: 10 }}>
127+
<h3 style={{ margin: 0, fontSize: 15 }}>🔤 文本校对(LLM)</h3>
128+
<span style={{ fontSize: 11, color: "#6b7280", marginLeft: 8 }}>
129+
逐句建议制 · 仅文本上云 · 人类裁决(原料永不变)
130+
</span>
131+
<button style={{ ...ghostBtn, marginLeft: "auto" }} onClick={onClose}>
132+
关闭
133+
</button>
134+
</div>
135+
136+
{msg && (
137+
<div style={{ fontSize: 12, color: "#047857", background: "#ecfdf5", border: "1px solid #6ee7b7", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
138+
{msg}
139+
</div>
140+
)}
141+
{err && (
142+
<div style={{ fontSize: 12, color: "#dc2626", background: "#fef2f2", border: "1px solid #fca5a5", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
143+
{err}
144+
</div>
145+
)}
146+
147+
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 10 }}>
148+
<div style={{ fontSize: 12, color: "#374151" }}>
149+
候选句:<b>{est?.sentences ?? "…"}</b> 句(约 {est?.chars ?? "…"} 字符)
150+
{est?.capped ? <span style={{ color: "#b45309" }}>(已超 240 句护栏,超出部分本次不校对)</span> : ""}
151+
,预估 <b>¥{(est?.cost_yuan ?? 0).toFixed(4)}</b>(模型 {est?.model ?? "…"})
152+
</div>
153+
<label style={{ fontSize: 12, display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
154+
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
155+
我已阅读并同意:本次仅上传<b>转写文本</b>用于校对(语音/画面永不出本机)
156+
</label>
157+
<div style={{ display: "flex", gap: 6 }}>
158+
<button style={okBtn} onClick={() => void run()} disabled={busy || pending > 0}>
159+
{busy ? "校对中…" : "▶ 运行校对(建议落草稿,不直改)"}
160+
</button>
161+
{pending > 0 && (
162+
<span style={{ fontSize: 11, color: "#b45309" }}>有 {pending} 条待裁决草稿——请先裁决(采纳/回退)再重跑,防重复建议</span>
163+
)}
164+
</div>
165+
</div>
166+
167+
{list !== null && list.total === 0 && !busy && (
168+
<p style={{ color: "#9ca3af", fontSize: 12 }}>
169+
{est && est.sentences === 0
170+
? "本会话无可校对句子(无转写内容)。"
171+
: "暂无校对草稿——运行后建议在此逐条裁决。"}
172+
</p>
173+
)}
174+
175+
{(list?.items.length ?? 0) > 0 && (
176+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
177+
{list!.items.map((d: RefineDraftView) => (
178+
<div
179+
key={d.id}
180+
style={{
181+
border: "1px solid #e5e7eb",
182+
borderRadius: 8,
183+
padding: "8px 10px",
184+
background: d.status === "adopted" ? "#ecfdf5" : d.status === "rejected" ? "#f9fafb" : "#fff",
185+
}}
186+
>
187+
<div style={{ fontSize: 11, color: "#9ca3af", marginBottom: 3 }}>
188+
{fmtClock(d.start_ms)} – {fmtClock(d.end_ms)} · 草稿 #{d.id} · 状态:
189+
{d.status === "pending" ? "待裁决" : d.status === "adopted" ? "已采纳 ✓" : "已回退"}
190+
{d.similarity != null ? ` · 相似 ${(d.similarity * 100).toFixed(0)}%` : ""}
191+
</div>
192+
<div style={{ fontSize: 12.5, color: "#6b7280", marginBottom: 3, textDecoration: "line-through", opacity: 0.75 }}>
193+
{d.base_text}
194+
</div>
195+
<div style={{ fontSize: 12.5, color: "#111827" }}>{d.refined_text}</div>
196+
{d.status === "pending" && (
197+
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
198+
<button style={{ ...okBtn, fontSize: 11 }} onClick={() => void decide(d.id, true)}>✓ 采纳</button>
199+
<button style={{ ...ghostBtn, fontSize: 11 }} onClick={() => void decide(d.id, false)}>回退</button>
200+
</div>
201+
)}
202+
{d.status === "adopted" && (
203+
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
204+
<button style={{ ...ghostBtn, fontSize: 11 }} onClick={() => void decide(d.id, false)}>↩ 撤销采纳</button>
205+
</div>
206+
)}
207+
</div>
208+
))}
209+
</div>
210+
)}
211+
<p style={{ fontSize: 11, color: "#9ca3af", marginTop: 10 }}>
212+
采纳后于「笔记预览/转为笔记」生效(与离线精修共用覆盖合成);原料视图恒显示原始转写。双闸门默认关——如需使用请先在设置→AI 服务开启。
213+
</p>
214+
</div>
215+
</div>
216+
);
217+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* ProofreadToggle — 可选 LLM 文本校对开关(v0.20.2 / REQ-270)。
3+
*
4+
* @ai-context: 双闸门之二(content_gate 之外独立开关,默认关);开启提示
5+
* 「仅文本上云、语音不出本机」;关闭=转写链路零影响。
6+
*/
7+
import { useEffect, useState } from "react";
8+
import { invoke } from "@tauri-apps/api/core";
9+
10+
interface AiSettingsView {
11+
enabled: boolean;
12+
authorized: boolean;
13+
proofread_enabled: boolean;
14+
}
15+
16+
const btn: React.CSSProperties = { padding: "5px 10px", cursor: "pointer", fontSize: 12, borderRadius: 6 };
17+
18+
export function ProofreadToggle() {
19+
const [on, setOn] = useState(false);
20+
const [globalEnabled, setGlobalEnabled] = useState(false);
21+
const [authorized, setAuthorized] = useState(false);
22+
const [msg, setMsg] = useState("");
23+
const [err, setErr] = useState("");
24+
25+
useEffect(() => {
26+
void invoke<AiSettingsView>("ai_get_settings")
27+
.then((v) => {
28+
setOn(v.proofread_enabled);
29+
setGlobalEnabled(v.enabled);
30+
setAuthorized(v.authorized);
31+
})
32+
.catch((e) => setErr(`读取设置失败: ${e}`));
33+
}, []);
34+
35+
const toggle = async (next: boolean) => {
36+
setErr("");
37+
setMsg("");
38+
if (next && !globalEnabled) {
39+
setMsg("提示:还需开启上方「AI 功能」全局开关(默认关——授权红线)");
40+
}
41+
try {
42+
await invoke("ai_set_proofread", { enabled: next });
43+
setOn(next);
44+
setMsg(next ? "已开启文本校对(建议制:机器只给建议,由你裁决采纳)" : "已关闭文本校对(转写链路零影响)");
45+
} catch (e) {
46+
setErr(String(e));
47+
}
48+
};
49+
50+
return (
51+
<div style={{ fontSize: 12, color: "#374151", marginBottom: 6 }}>
52+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
53+
<b>🔤 文本校对(LLM)</b>
54+
<input
55+
type="checkbox"
56+
checked={on}
57+
onChange={(e) => void toggle(e.target.checked)}
58+
data-testid="proofread-toggle"
59+
style={{ cursor: "pointer" }}
60+
/>
61+
<span style={{ fontSize: 11, color: "#9ca3af" }}>{on ? "已开启(默认关双闸门之二)" : "默认关"}</span>
62+
{on && (
63+
<button
64+
style={{ ...btn, border: "1px solid #e5e7eb", background: "#fff", color: "#374151" }}
65+
onClick={() => void toggle(false)}
66+
>
67+
关闭
68+
</button>
69+
)}
70+
</div>
71+
<div style={{ fontSize: 11, color: "#6b7280", marginTop: 2 }}>
72+
逐句建议制:仅文本上云(语音/画面永不出本机);开启仍需全局开关+授权;建议在会话详情「🔤 校对」入口使用。
73+
{authorized ? "" : "(尚未同意 AI 授权——请在 AI 服务授权卡完成)"}
74+
</div>
75+
{msg && <div style={{ fontSize: 11, color: "#047857", marginTop: 2 }}>{msg}</div>}
76+
{err && <div style={{ fontSize: 11, color: "#dc2626", marginTop: 2 }}>{err}</div>}
77+
</div>
78+
);
79+
}
80+
81+
export default ProofreadToggle;

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { listen } from "@tauri-apps/api/event";
1919
import BoxSelectOverlay from "../components/BoxSelectOverlay";
2020
import ImageGallery from "../components/ImageGallery";
2121
import NotePreviewView from "../components/NotePreviewView";
22+
import ProofreadPanel from "../components/ProofreadPanel";
2223
import SecondPassPanel from "../components/SecondPassPanel";
2324
import SpeakerSwitchCard from "../components/SpeakerSwitchCard";
2425
import type { GlossaryTerm, QualityReport, SessionDetail, SessionOcrBlock } from "../types";
@@ -96,6 +97,9 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
9697
// v0.20.2(REQ-268):离线精修(第二遍)裁决面板显隐(会话切换即关)
9798
const [showPass2, setShowPass2] = useState(false);
9899
useEffect(() => setShowPass2(false), [sessionId]);
100+
// v0.20.2(REQ-270):LLM 文本校对面板显隐(会话切换即关)
101+
const [showProofread, setShowProofread] = useState(false);
102+
useEffect(() => setShowProofread(false), [sessionId]);
99103

100104
// toast 定时器卸载清理(防卸载后 setState)
101105
useEffect(
@@ -411,6 +415,16 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
411415
⚡ 离线精修
412416
</button>
413417
)}
418+
{/* v0.20.2(REQ-270):可选 LLM 文本校对(建议制·默认关双闸门——
419+
未开启时面板给引导文案;仅文本上云) */}
420+
{detail.session.status === "finished" && detail.session.kind !== "photo" && (
421+
<button
422+
style={{ ...btn, borderRadius: 6, border: "1px solid #2563eb", background: "#eff6ff", color: "#1d4ed8" }}
423+
onClick={() => setShowProofread(true)}
424+
>
425+
🔤 文本校对
426+
</button>
427+
)}
414428
</div>
415429
{refineMsg && (
416430
<div style={{ fontSize: 11, color: refining ? "#b45309" : "#0d9488", marginBottom: 6 }}>
@@ -616,6 +630,10 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
616630

617631
{/* v0.20.2(REQ-268):离线精修(第二遍)裁决面板 */}
618632
{showPass2 && <SecondPassPanel sessionId={sessionId} onClose={() => setShowPass2(false)} />}
633+
{/* v0.20.2(REQ-270):LLM 文本校对面板 */}
634+
{showProofread && (
635+
<ProofreadPanel sessionId={sessionId} onClose={() => setShowProofread(false)} />
636+
)}
619637
</>
620638
);
621639
}

‎app/src/pages/SettingsPage.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import AiTaskPanel from "../components/AiTaskPanel";
2727
import FeatureFlagSetting from "../components/FeatureFlagSetting";
2828
// v0.20.2(REQ-269):ASR 混淆画像闭环面板(候选确认制/规则管理)
2929
import AsrConfusionPanel from "../components/AsrConfusionPanel";
30+
// v0.20.2(REQ-270):可选 LLM 文本校对开关(默认关双闸门之二)
31+
import ProofreadToggle from "../components/ProofreadToggle";
3032

3133
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
3234
const groupTitle: React.CSSProperties = {
@@ -87,6 +89,10 @@ export default function SettingsPage({ active = true }: Props) {
8789
<div style={{ marginTop: 8 }}>
8890
<GoalAiSection />
8991
</div>
92+
{/* v0.20.2(REQ-270):可选 LLM 文本校对独立开关(默认关) */}
93+
<div style={{ marginTop: 8 }}>
94+
<ProofreadToggle />
95+
</div>
9096
<div style={{ marginTop: 8 }}>
9197
<AiTaskPanel />
9298
</div>

0 commit comments

Comments
 (0)