Skip to content

Commit 1d9c491

Browse files
committed
feat(classroom): 热词/替换词表与本地持久化
- 新增 hotwordStore(Dexie v24 hotwords 表):boost 热词增强 / replace 替换纠错,courseId 绑定课程、空为全局词条 - 新增 hotwordApply 后处理替换管线,ASR 转写后按词表纠错(替换旧后处理) - 新增 hotwordRuntime 会话级词表运行时与 HotwordDialog 管理 UI - database.ts 注册 version(24),并增加 versionchange 兜底(多开/双版本共存时主动 close 让位,评审 F 项修复) - local-asr 补充 TODO 结论注释:SenseVoice/Paraformer 不支持 hotwords,本期不透传,未来 transducer 模型接入路径已留档
1 parent 852ad2a commit 1d9c491

9 files changed

Lines changed: 715 additions & 0 deletions

File tree

‎client/electron/ai/local-asr/SherpaAsrService.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,17 @@ export function getOnlineRecognizer(): OnlineRecognizer | null {
160160
// 公共 API
161161
// ================================================================
162162

163+
/**
164+
* TODO(P1-3 热词增强): 经查 sherpa-onnx-node 类型声明,hotwords 仅 transducer
165+
* 系模型支持(OfflineStream createStream(hotwords),见 non-streaming-asr.js 注释
166+
* "Hotwords are supported only by transducer models");本项目使用的
167+
* SenseVoice(offline)与 Paraformer(streaming)均不支持。故本期不透传热词,
168+
* 渲染进程词表 boost 词条已由 hotwordRuntime.getSessionBoostWords() 预留,
169+
* 未来若换用 zipformer-transducer 模型,可给 local_asr_transcribe /
170+
* local_asr_stream_start 的 IPC payload 增加可选 hotwords 字段(旧载荷须兼容)
171+
* 并在 createStream 时传入。云端网关热词透传同为遗留项(不改 transcribe.py)。
172+
*/
173+
163174
/**
164175
* 检测本地 ASR 是否可用(sherpa-onnx 已加载 + 模型已下载)
165176
*/

‎client/electron/ai/local-asr/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939

4040
export function registerLocalAsrHandlers(): void {
4141
// ── 转写(核心接口,渲染进程 asrTranscriber.ts 调用) ──
42+
// TODO(P1-3): 本 IPC payload 暂未含可选 hotwords 字段——SenseVoice/Paraformer
43+
// 不支持热词(详见 SherpaAsrService.ts 的 TODO 结论),届时新增字段须保持旧载荷兼容
4244
ipcMain.handle(
4345
'local_asr_transcribe',
4446
async (_event, args: {
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* HotwordDialog — 课堂助手热词/替换词表管理对话框(P1-3)
3+
* Vocabulary management dialog: CRUD + enable toggle, grouped by course.
4+
*
5+
* @ai-context: 组装共享 Modal(Radix Dialog,参照批次二 ConfirmDialog 的组装
6+
* 方式)。词条存储走 hotwordStore(Dexie 本地优先);按课程分组展示
7+
* (当前课程 / 全局 / 其他课程)。kind='replace' 词条在转写后处理生效
8+
* (hotwordApply),kind='boost' 为 ASR 热词增强预留(当前本地模型不支持,
9+
* 见 local-asr TODO)。courseId 即课程名,与 CourseMeta.courseName 对齐。
10+
* @ai-context: EN: local-first vocab CRUD; empty courseId = global terms.
11+
*/
12+
import { useCallback, useEffect, useMemo, useState } from 'react';
13+
import { Plus, Pencil, Trash2 } from 'lucide-react';
14+
import { Modal } from '@/components/ui/Modal';
15+
import { hotwordStore, type HotwordEntry, type HotwordKind } from '@/lib/storage/hotwordStore';
16+
17+
export interface HotwordDialogProps {
18+
open: boolean;
19+
/** 当前配置的课程名(作为"当前课程"分组的 courseId),可为空 */
20+
courseId?: string;
21+
onClose: () => void;
22+
}
23+
24+
/** 表单草稿状态 */
25+
interface DraftState {
26+
term: string;
27+
target: string;
28+
kind: HotwordKind;
29+
scope: 'global' | 'course';
30+
/** 编辑"其他课程"词条时保持其原课程绑定,避免被改挂到当前课程 */
31+
keepCourseId?: string;
32+
}
33+
34+
const EMPTY_DRAFT: DraftState = { term: '', target: '', kind: 'replace', scope: 'course' };
35+
36+
const inputCls = 'w-full px-3 py-2 rounded-kb-lg text-b3 bg-bg-secondary border border-border/30 '
37+
+ 'text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-brand-500/60 transition-colors';
38+
const ghostBtnCls = 'p-1.5 rounded-kb-sm text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary '
39+
+ 'active:scale-95 transition-all';
40+
41+
export function HotwordDialog({ open, courseId, onClose }: HotwordDialogProps) {
42+
const [entries, setEntries] = useState<HotwordEntry[]>([]);
43+
const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);
44+
const [editingId, setEditingId] = useState<string | null>(null);
45+
46+
const reload = useCallback(async () => {
47+
try {
48+
setEntries(await hotwordStore.listAll());
49+
} catch (err) {
50+
console.warn('[HotwordDialog] 词表加载失败:', err);
51+
}
52+
}, []);
53+
54+
useEffect(() => {
55+
if (open) {
56+
void reload();
57+
// 打开时重置表单:有当前课程则默认课程专属作用域,否则全局
58+
setDraft({ ...EMPTY_DRAFT, scope: courseId ? 'course' : 'global' });
59+
setEditingId(null);
60+
}
61+
}, [open, courseId, reload]);
62+
63+
/** 按课程分组:当前课程 / 全局 / 其他课程(空组不渲染) */
64+
const groups = useMemo(() => {
65+
const current = courseId ? entries.filter((e) => e.courseId === courseId) : [];
66+
const global = entries.filter((e) => !e.courseId);
67+
const other = entries.filter((e) => e.courseId && e.courseId !== courseId);
68+
return [
69+
{ label: courseId ? `当前课程 · ${courseId}` : '', items: current },
70+
{ label: '全局词条(所有课堂生效)', items: global },
71+
{ label: '其他课程', items: other },
72+
].filter((g) => g.items.length > 0);
73+
}, [entries, courseId]);
74+
75+
const resetDraft = () => {
76+
setDraft({ ...EMPTY_DRAFT, scope: courseId ? 'course' : 'global' });
77+
setEditingId(null);
78+
};
79+
80+
/** 保存(新增或更新):term 为空忽略 */
81+
const handleSave = async () => {
82+
const term = draft.term.trim();
83+
if (!term) return;
84+
// 课程归属:编辑其他课程词条时保持原绑定;否则按作用域选择
85+
const entryCourseId = draft.keepCourseId
86+
?? (draft.scope === 'course' && courseId ? courseId : undefined);
87+
const target = draft.kind === 'replace' ? draft.target.trim() : undefined;
88+
try {
89+
if (editingId) {
90+
await hotwordStore.update(editingId, { term, target, kind: draft.kind, courseId: entryCourseId });
91+
} else {
92+
await hotwordStore.add({ term, target, kind: draft.kind, courseId: entryCourseId, enabled: true });
93+
}
94+
resetDraft();
95+
await reload();
96+
} catch (err) {
97+
console.warn('[HotwordDialog] 保存词条失败:', err);
98+
}
99+
};
100+
101+
const handleEdit = (entry: HotwordEntry) => {
102+
setEditingId(entry.id);
103+
const isForeignCourse = !!entry.courseId && entry.courseId !== courseId;
104+
setDraft({
105+
term: entry.term,
106+
target: entry.target ?? '',
107+
kind: entry.kind,
108+
scope: entry.courseId ? 'course' : 'global',
109+
keepCourseId: isForeignCourse ? entry.courseId : undefined,
110+
});
111+
};
112+
113+
const handleDelete = async (id: string) => {
114+
try {
115+
await hotwordStore.remove(id);
116+
if (editingId === id) resetDraft();
117+
await reload();
118+
} catch (err) {
119+
console.warn('[HotwordDialog] 删除词条失败:', err);
120+
}
121+
};
122+
123+
const handleToggle = async (entry: HotwordEntry) => {
124+
try {
125+
await hotwordStore.update(entry.id, { enabled: !entry.enabled });
126+
await reload();
127+
} catch (err) {
128+
console.warn('[HotwordDialog] 切换启用状态失败:', err);
129+
}
130+
};
131+
132+
return (
133+
<Modal
134+
open={open}
135+
onClose={onClose}
136+
title="热词 / 替换词表"
137+
description="替换词条自动纠正课堂转写错误;热词为语音增强预留。原始转写始终保留可回溯。"
138+
size="lg"
139+
footer={
140+
<button onClick={onClose}
141+
className="px-4 py-2 rounded-kb-lg text-b3 font-semibold text-white bg-brand-600 hover:bg-brand-700 active:scale-[0.98] transition-all">
142+
完成
143+
</button>
144+
}
145+
>
146+
<div className="space-y-4">
147+
{/* ── 新增 / 编辑表单 ── */}
148+
<div className="p-3 rounded-kb-lg bg-bg-secondary/60 border border-border/20 space-y-2.5">
149+
<div className="flex items-center gap-2">
150+
<select value={draft.kind}
151+
onChange={(e) => setDraft((d) => ({ ...d, kind: e.target.value as HotwordKind }))}
152+
className={`${inputCls} w-28 flex-shrink-0`}>
153+
<option value="replace">替换纠错</option>
154+
<option value="boost">热词增强</option>
155+
</select>
156+
<input value={draft.term} placeholder={draft.kind === 'replace' ? '错误转写形态,如:机气' : '需增强的术语,如:反向传播'}
157+
onChange={(e) => setDraft((d) => ({ ...d, term: e.target.value }))} className={inputCls} />
158+
</div>
159+
{/* 大小写录入提示:替换匹配刻意不做大小写宽容(保真优先),
160+
词条必须与转写实际形态一致才能命中 */}
161+
<p className="text-[11px] text-text-tertiary leading-relaxed">
162+
英文词条请按转写实际大小写录入(匹配区分大小写,如 GPT 与 gpt 不互通)。
163+
</p>
164+
{draft.kind === 'replace' && (
165+
<input value={draft.target} placeholder="替换为(留空 = 删除误词),如:机器"
166+
onChange={(e) => setDraft((d) => ({ ...d, target: e.target.value }))} className={inputCls} />
167+
)}
168+
<div className="flex items-center gap-2">
169+
<select value={draft.scope} disabled={!courseId || !!draft.keepCourseId}
170+
onChange={(e) => setDraft((d) => ({ ...d, scope: e.target.value as 'global' | 'course' }))}
171+
className={`${inputCls} w-44 flex-shrink-0`}>
172+
{courseId && !draft.keepCourseId && <option value="course">仅当前课程</option>}
173+
{draft.keepCourseId && <option value="course">仅 {draft.keepCourseId}</option>}
174+
<option value="global">全局(所有课堂)</option>
175+
</select>
176+
<div className="flex-1" />
177+
{editingId && (
178+
<button onClick={resetDraft}
179+
className="px-3 py-1.5 rounded-kb-lg text-b3 text-text-secondary hover:text-text-primary transition-colors">
180+
取消编辑
181+
</button>
182+
)}
183+
<button onClick={() => void handleSave()} disabled={!draft.term.trim()}
184+
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-kb-lg text-b3 font-semibold transition-all active:scale-[0.98] ${
185+
draft.term.trim()
186+
? 'bg-brand-600 text-white hover:bg-brand-700'
187+
: 'bg-bg-secondary text-text-tertiary cursor-not-allowed'
188+
}`}>
189+
{editingId ? <Pencil className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
190+
{editingId ? '保存修改' : '添加词条'}
191+
</button>
192+
</div>
193+
</div>
194+
195+
{/* ── 词条列表(按课程分组) ── */}
196+
<div className="max-h-72 overflow-y-auto space-y-3 pr-1">
197+
{groups.length === 0 && (
198+
<p className="py-6 text-center text-b3 text-text-tertiary">
199+
暂无词条。添加替换词条可纠正常见转写错误(如学科名词、教师口头禅)。
200+
</p>
201+
)}
202+
{groups.map((group) => (
203+
<div key={group.label}>
204+
<p className="mb-1.5 text-[11px] font-medium text-text-tertiary tracking-wide">{group.label}</p>
205+
<ul className="space-y-1">
206+
{group.items.map((entry) => (
207+
<li key={entry.id}
208+
className="flex items-center gap-2 px-2.5 py-1.5 rounded-kb-lg bg-bg-secondary/40 border border-border/15">
209+
{/* 启用开关 */}
210+
<button onClick={() => void handleToggle(entry)} role="switch" aria-checked={entry.enabled}
211+
className={`relative w-8 h-[18px] flex-shrink-0 rounded-full transition-colors ${
212+
entry.enabled ? 'bg-brand-600' : 'bg-bg-tertiary'
213+
}`}>
214+
<span className={`absolute top-0.5 w-3.5 h-3.5 rounded-full bg-white transition-all ${
215+
entry.enabled ? 'left-[16px]' : 'left-0.5'
216+
}`} />
217+
</button>
218+
{/* 类型徽标 */}
219+
<span className={`flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${
220+
entry.kind === 'replace'
221+
? 'bg-brand-50 text-brand-600'
222+
: 'bg-bg-tertiary text-text-secondary'
223+
}`}>
224+
{entry.kind === 'replace' ? '替换' : '热词'}
225+
</span>
226+
{/* 词条内容 */}
227+
<span className={`flex-1 min-w-0 truncate text-b3 ${entry.enabled ? 'text-text-primary' : 'text-text-tertiary line-through'}`}>
228+
{entry.term}
229+
{entry.kind === 'replace' && <span className="text-text-tertiary"> → {entry.target || '(删除)'}</span>}
230+
{entry.courseId && group.label === '其他课程' && (
231+
<span className="ml-1.5 text-[11px] text-text-tertiary">[{entry.courseId}]</span>
232+
)}
233+
</span>
234+
<button onClick={() => handleEdit(entry)} title="编辑" className={ghostBtnCls}>
235+
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
236+
</button>
237+
<button onClick={() => void handleDelete(entry.id)} title="删除"
238+
className="p-1.5 rounded-kb-sm text-text-tertiary hover:text-semantic-error hover:bg-semantic-error/10 active:scale-95 transition-all">
239+
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
240+
</button>
241+
</li>
242+
))}
243+
</ul>
244+
</div>
245+
))}
246+
</div>
247+
</div>
248+
</Modal>
249+
);
250+
}
251+
252+
export default HotwordDialog;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* 课堂会话级热词运行时(P1-3 接线层,外置以控制 useClassroomEvents 行数)
3+
* Session-scoped hotword runtime: loads the course vocabulary at session
4+
* start and exposes a sync apply-function for transcript display points.
5+
*
6+
* @ai-context: 会话启动时按 courseMeta.courseName 一次性加载"课程专属 +
7+
* 全局"词条(hotwordStore),替换词条转为 ReplaceRule 供展示层单点调用;
8+
* boost 词条暂存备用(本地 ASR 暂不支持 hotwords,见 local-asr TODO)。
9+
* 模块级单例状态:同一时刻仅一个采集会话,stop 时 clear。
10+
* @ai-context: EN: replacing acts on display text only — storage keeps the
11+
* original transcript (audioSegments.audioText) for traceability. Load
12+
* failures degrade silently to "no vocabulary" (local-first, never blocking).
13+
*/
14+
import { hotwordStore } from '@/lib/storage/hotwordStore';
15+
import { applyReplaceTerms, type ReplaceRule } from '@/lib/capture/hotwordApply';
16+
17+
/** 当前会话生效的替换规则(会话启动时加载,停止时清空) */
18+
let activeReplaces: ReplaceRule[] = [];
19+
/** 当前会话生效的热词增强词(预留送 ASR 引擎) */
20+
let activeBoosts: string[] = [];
21+
/**
22+
* 会话代际标记:load 为异步(Dexie 查询),若其 resolve 前会话已 stop
23+
* (clear 使 seq 前进),resolve 时丢弃陈旧结果,防止旧词表被写回。
24+
*/
25+
let sessionSeq = 0;
26+
27+
/**
28+
* 会话启动时加载词表:课程专属词条(courseId === courseName)+ 全局词条。
29+
* 失败静默降级为空词表,不阻塞采集启动时序。
30+
*/
31+
export async function loadSessionHotwords(courseName?: string): Promise<void> {
32+
const seq = ++sessionSeq;
33+
try {
34+
const entries = await hotwordStore.listForCourse(courseName);
35+
// 代际校验:await 期间已 stop / 被新会话覆盖 → 丢弃陈旧结果
36+
if (seq !== sessionSeq) return;
37+
activeReplaces = entries
38+
.filter((e) => e.enabled && e.kind === 'replace' && e.term.length > 0)
39+
.map((e) => ({ term: e.term, target: e.target ?? '' }));
40+
activeBoosts = entries
41+
.filter((e) => e.enabled && e.kind === 'boost' && e.term.length > 0)
42+
.map((e) => e.term);
43+
} catch (err) {
44+
if (seq !== sessionSeq) return;
45+
console.warn('[hotwordRuntime] 词表加载失败,本次会话不应用热词:', err);
46+
activeReplaces = [];
47+
activeBoosts = [];
48+
}
49+
}
50+
51+
/** 会话停止时清空(防止陈旧词表作用于残余转写回调) */
52+
export function clearSessionHotwords(): void {
53+
// 先推进代际,使在途 load 的 await 结果作废,再清空
54+
sessionSeq++;
55+
activeReplaces = [];
56+
activeBoosts = [];
57+
}
58+
59+
/** 展示层单点入口:对 final 转写文本应用当前会话替换词条(无词条时原样返回) */
60+
export function applySessionReplaces(text: string): string {
61+
return applyReplaceTerms(text, activeReplaces);
62+
}
63+
64+
/** 当前会话热词增强词列表(5.3 预留:ASR 引擎支持后经可选 IPC 字段透传) */
65+
export function getSessionBoostWords(): string[] {
66+
return activeBoosts;
67+
}

0 commit comments

Comments
 (0)