Skip to content

Commit e54257d

Browse files
committed
fix(client): 内测反馈五连修——导航竞态/剪贴板IPC/音频资源路径/番茄钟预设/ASR兼容
- 3D导航:OrbitalStore.syncWithRoute 飞行中不再丢弃路由同步,SceneProvider 增加 2.5s 强制停靠兜底,修复快速切换页面不渲染 - 剪贴板:新增 clipboard:write-text IPC(sender 校验 + 白名单)与 lib/utils/clipboard.ts 三级降级链,修复 Electron 失焦复制失败 - 音频资源:新增 publicAssetUrl(BASE_URL 拼接),修复 file:// 生产构建下音效/白噪音/BGM 绝对路径 404 - 番茄钟预设:标题去重清洗、删除与排序能力补全,设置页一屏适配 - 本地ASR:sherpa-onnx 新旧版 acceptWaveform/free 签名适配层,设置页新增流式识别启用开关 - 同步:SyncEngine/NetworkManager 幂等写入与退避加固(含测试) - 沉淀 3 张知识卡片并更新知识索引
1 parent 7b0ed0a commit e54257d

38 files changed

Lines changed: 867 additions & 141 deletions

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

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
* 符合项目"可选增强"设计原则。
1111
* @ai-context: 音频输入要求:16kHz 单声道 Float32 PCM。
1212
* 渲染进程发来的音频块已满足此格式(process-audio native addon 输出)。
13+
* @ai-context: sherpa-onnx-node API 兼容:旧版(<=1.12)导出工厂函数
14+
* createOfflineRecognizer/createOnlineRecognizer;新版(1.13+,如 1.13.4)
15+
* 改为导出类 OfflineRecognizer/OnlineRecognizer(new 构造,config 形状不变)。
16+
* 下方工厂包装双路径兼容,否则升级依赖后 TypeError: ... is not a function
17+
* (2026-08 真流式启动失败根因,离线路径同样受影响)。
1318
*/
1419

1520
import * as path from 'path';
@@ -26,9 +31,16 @@ import {
2631
// sherpa-onnx 动态加载(可选依赖,加载失败不崩溃)
2732
// ================================================================
2833

34+
/** 识别器构造器签名(新版 API:类导出) */
35+
type RecognizerCtor<T> = new (config: Record<string, unknown>) => T;
36+
2937
interface SherpaOnnx {
30-
createOfflineRecognizer(config: Record<string, unknown>): OfflineRecognizer;
31-
createOnlineRecognizer(config: Record<string, unknown>): OnlineRecognizer;
38+
/** 旧版 API(<=1.12):工厂函数 */
39+
createOfflineRecognizer?: (config: Record<string, unknown>) => OfflineRecognizer;
40+
createOnlineRecognizer?: (config: Record<string, unknown>) => OnlineRecognizer;
41+
/** 新版 API(1.13+):类导出 */
42+
OfflineRecognizer?: RecognizerCtor<OfflineRecognizer>;
43+
OnlineRecognizer?: RecognizerCtor<OnlineRecognizer>;
3244
}
3345

3446
interface OfflineRecognizer {
@@ -38,9 +50,12 @@ interface OfflineRecognizer {
3850
}
3951

4052
interface OfflineStream {
53+
/** 旧版:(sampleRate, samples);新版 1.13+:({ samples, sampleRate }),统一走 feedWaveform */
4154
acceptWaveform(sampleRate: number, samples: Float32Array): void;
55+
acceptWaveform(waveform: { samples: Float32Array; sampleRate: number }): void;
4256
inputFinished(): void;
43-
free(): void;
57+
/** 新版 OnlineStream/OfflineStream 未提供 free(句柄由 GC 回收),故为可选 */
58+
free?(): void;
4459
}
4560

4661
export interface OnlineRecognizer {
@@ -53,14 +68,56 @@ export interface OnlineRecognizer {
5368
}
5469

5570
export interface OnlineStream {
71+
/** 旧版:(sampleRate, samples);新版 1.13+:({ samples, sampleRate }),统一走 feedWaveform */
5672
acceptWaveform(sampleRate: number, samples: Float32Array): void;
73+
acceptWaveform(waveform: { samples: Float32Array; sampleRate: number }): void;
5774
inputFinished(): void;
58-
free(): void;
75+
free?(): void;
76+
}
77+
78+
/**
79+
* 向流喂入音频:自动适配新旧 API 签名差异。
80+
* 旧版(<=1.12)acceptWaveform(sampleRate, samples) 双参;
81+
* 新版(1.13+)acceptWaveform({ samples, sampleRate }) 单对象。
82+
* 按函数形参数(length)判别,调用方无需感知版本。
83+
*/
84+
export function feedWaveform(
85+
stream: OnlineStream | OfflineStream,
86+
sampleRate: number,
87+
samples: Float32Array,
88+
): void {
89+
if (stream.acceptWaveform.length <= 1) {
90+
stream.acceptWaveform({ samples, sampleRate });
91+
} else {
92+
stream.acceptWaveform(sampleRate, samples);
93+
}
5994
}
6095

6196
let _sherpa: SherpaOnnx | null = null;
6297
let _loadAttempted = false;
6398

99+
/** 创建离线识别器:优先旧版工厂函数,缺失时用新版类构造 */
100+
function instantiateOffline(sherpa: SherpaOnnx, config: Record<string, unknown>): OfflineRecognizer {
101+
if (typeof sherpa.createOfflineRecognizer === 'function') {
102+
return sherpa.createOfflineRecognizer(config);
103+
}
104+
if (typeof sherpa.OfflineRecognizer === 'function') {
105+
return new sherpa.OfflineRecognizer(config);
106+
}
107+
throw new Error('sherpa-onnx-node 既无 createOfflineRecognizer 工厂也无 OfflineRecognizer 类,请核对依赖版本');
108+
}
109+
110+
/** 创建在线识别器:优先旧版工厂函数,缺失时用新版类构造 */
111+
function instantiateOnline(sherpa: SherpaOnnx, config: Record<string, unknown>): OnlineRecognizer {
112+
if (typeof sherpa.createOnlineRecognizer === 'function') {
113+
return sherpa.createOnlineRecognizer(config);
114+
}
115+
if (typeof sherpa.OnlineRecognizer === 'function') {
116+
return new sherpa.OnlineRecognizer(config);
117+
}
118+
throw new Error('sherpa-onnx-node 既无 createOnlineRecognizer 工厂也无 OnlineRecognizer 类,请核对依赖版本');
119+
}
120+
64121
/** 尝试加载 sherpa-onnx-node(仅尝试一次) */
65122
function loadSherpa(): SherpaOnnx | null {
66123
if (_loadAttempted) return _sherpa;
@@ -97,7 +154,7 @@ function getOfflineRecognizer(): OfflineRecognizer | null {
97154
const threads = config.threads > 0 ? config.threads : Math.max(1, os.cpus().length - 1);
98155

99156
try {
100-
_offlineRecognizer = sherpa.createOfflineRecognizer({
157+
_offlineRecognizer = instantiateOffline(sherpa, {
101158
featConfig: { sampleRate: 16000, featureDim: 80 },
102159
modelConfig: {
103160
senseVoice: {
@@ -131,7 +188,7 @@ export function getOnlineRecognizer(): OnlineRecognizer | null {
131188
const threads = config.threads > 0 ? config.threads : Math.max(1, os.cpus().length - 1);
132189

133190
try {
134-
_onlineRecognizer = sherpa.createOnlineRecognizer({
191+
_onlineRecognizer = instantiateOnline(sherpa, {
135192
featConfig: { sampleRate: 16000, featureDim: 80 },
136193
modelConfig: {
137194
paraformer: {
@@ -219,7 +276,7 @@ export async function transcribeOffline(
219276

220277
const stream = recognizer.createStream();
221278
try {
222-
stream.acceptWaveform(16000, pcmData);
279+
feedWaveform(stream, 16000, pcmData);
223280
stream.inputFinished();
224281
recognizer.decode(stream);
225282
const result = recognizer.getResult(stream);
@@ -229,7 +286,7 @@ export async function transcribeOffline(
229286
logger.debug(`[LocalASR] Offline transcribe: ${text.length} chars, ${durationMs}ms`);
230287
return { text, engine: 'offline', durationMs };
231288
} finally {
232-
stream.free();
289+
stream.free?.();
233290
}
234291
}
235292

@@ -258,7 +315,7 @@ export async function transcribeStreaming(
258315
const chunkSize = 1600;
259316
for (let offset = 0; offset < pcmData.length; offset += chunkSize) {
260317
const chunk = pcmData.subarray(offset, Math.min(offset + chunkSize, pcmData.length));
261-
stream.acceptWaveform(16000, chunk);
318+
feedWaveform(stream, 16000, chunk);
262319
while (recognizer.isReady(stream)) {
263320
recognizer.decode(stream);
264321
}
@@ -277,7 +334,7 @@ export async function transcribeStreaming(
277334
logger.debug(`[LocalASR] Streaming transcribe: ${text.length} chars, ${durationMs}ms`);
278335
return { text, engine: 'streaming', durationMs };
279336
} finally {
280-
stream.free();
337+
stream.free?.();
281338
}
282339
}
283340

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import type { BrowserWindow } from 'electron';
1414
import { logger } from '../../logger.js';
15-
import { getOnlineRecognizer, type OnlineStream } from './SherpaAsrService.js';
15+
import { getOnlineRecognizer, feedWaveform, type OnlineStream } from './SherpaAsrService.js';
1616

1717
/** partial 推送节流:两次 partial 推送的最小间隔(ms) */
1818
const PARTIAL_EMIT_INTERVAL_MS = 150;
@@ -91,7 +91,8 @@ export function feedStreamingAsr(audioBuffer: ArrayBuffer, sampleRate?: number):
9191
const rate = sampleRate ?? _sampleRate;
9292

9393
try {
94-
_stream.acceptWaveform(rate, samples);
94+
// 统一适配层:新旧版 sherpa-onnx-node 的 acceptWaveform 签名不同
95+
feedWaveform(_stream, rate, samples);
9596
while (recognizer.isReady(_stream)) {
9697
recognizer.decode(_stream);
9798
}
@@ -129,7 +130,8 @@ export function feedStreamingAsr(audioBuffer: ArrayBuffer, sampleRate?: number):
129130
export function stopStreamingAsr(): void {
130131
if (_stream) {
131132
try {
132-
_stream.free();
133+
// 新版(1.13+)流对象无 free 方法(句柄由 GC 回收),可选调用
134+
_stream.free?.();
133135
} catch (err) {
134136
logger.warn(`[StreamingASR] 释放流失败: ${err}`);
135137
}

‎client/electron/ipc/channels.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export const IPC_CHANNELS = {
108108

109109
// 系统
110110
APP_GET_VERSION: 'get-app-version',
111+
CLIPBOARD_WRITE_TEXT: 'clipboard:write-text',
111112
GET_DEFAULT_STORAGE_PATH: 'get-default-storage-path',
112113
DIALOG_SELECT_DIRECTORY: 'dialog:selectDirectory',
113114

‎client/electron/main.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
* 应用无法启动,任何修改需完整回归启动/退出/托盘/更新流程。
1818
*/
1919

20-
import { app, BrowserWindow, Menu, session } from 'electron';
21-
import { safeHandle, setMainWindowId } from './ipcUtils.js';
20+
import { app, BrowserWindow, Menu, session, clipboard } from 'electron';
21+
import { safeHandle, setMainWindowId, requireText } from './ipcUtils.js';
2222
import { logger } from './logger.js';
2323
import { registerAIHandlers, initAIModule } from './ai/index.js';
2424
import { loadPersistedGatewayUrl, setRuntimeGatewayUrl, gatewayUrl, isDevMode } from './ai/utils.js';
@@ -231,6 +231,14 @@ if (!gotTheLock) {
231231
return app.getVersion();
232232
});
233233

234+
// 剪贴板写入:渲染进程 navigator.clipboard 在 Electron 非聚焦/受限上下文下
235+
// 会抛 "Document is not focused" 而失败,改由主进程 clipboard 模块写入更可靠。
236+
safeHandle('clipboard:write-text', async (_event, text: unknown) => {
237+
const value = requireText(text, 'text');
238+
clipboard.writeText(value);
239+
return { success: true };
240+
});
241+
234242
// 创建主窗口(内部会创建托盘)
235243
mainWindow = createMainWindow(isQuittingRef, performQuit);
236244

‎client/electron/mcpMemoryServer.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,32 @@ import * as fs from 'fs';
2626
// 路径解析(无 electron 依赖:独立进程内手动推导 userData)
2727
// ================================================================
2828

29-
const APP_DIR_NAME = 'entropy-decrease';
29+
/**
30+
* 应用实际 userData 目录名取自 Electron app.name:默认 productName
31+
* ("Entropy decrease"),无 productName 时才回退 name("entropy-decrease")。
32+
* 独立进程无法调用 app.getPath('userData'),只能按此顺序推导候选目录,
33+
* 取第一个真实存在的目录——否则授权标记、keban.db、访问日志会全部错位。
34+
*/
35+
const APP_DIR_NAMES = ['Entropy decrease', 'entropy-decrease'];
3036
const CONSENT_MARKER = 'memory-server-consent';
3137
const ACCESS_LOG = 'memory-server-access.log';
3238

3339
function resolveUserDataDir(): string {
40+
let base: string;
3441
switch (process.platform) {
3542
case 'win32':
36-
return path.join(process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), APP_DIR_NAME);
43+
base = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
44+
break;
3745
case 'darwin':
38-
return path.join(os.homedir(), 'Library', 'Application Support', APP_DIR_NAME);
46+
base = path.join(os.homedir(), 'Library', 'Application Support');
47+
break;
3948
default:
40-
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), APP_DIR_NAME);
49+
base = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
4150
}
51+
return (
52+
APP_DIR_NAMES.map((name) => path.join(base, name)).find((dir) => fs.existsSync(dir)) ??
53+
path.join(base, APP_DIR_NAMES[0])
54+
);
4255
}
4356

4457
/** 与 storageConfig.resolveDbPath 同口径:优先自定义路径,回退 userData/keban.db */

‎client/electron/preload.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ const ALLOWED_CHANNELS = [
5757
// A2 语音对话:启动前查询是否已有活跃采集(互斥保护,避免误伤课堂采集)
5858
'audio_capture_status',
5959
'get-app-version',
60+
// 剪贴板写入(渲染进程 navigator.clipboard 在 Electron 受限上下文下不可靠)
61+
'clipboard:write-text',
6062
'dialog:selectDirectory',
6163
'get-default-storage-path',
6264
'update:check',

‎client/src/components/layout/AppLayout.tsx‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,19 +154,26 @@ export default function AppLayout() {
154154
: undefined}
155155
maskClassName={currentModule === 'inspiration' ? '!bg-[var(--kb-bg-primary)] !backdrop-blur-none' : undefined}
156156
>
157-
{/* 路由级 AnimatePresence:mode="wait" 确保旧页面完成退出动画后再挂载新页面,避免残帧 */}
158-
<AnimatePresence mode="wait">
159-
<motion.div
160-
key={pathname}
161-
/* 简单的 opacity 淡入淡出,不影响 3D 场景的相机飞行和停靠时序 */
162-
initial={{ opacity: 0 }}
163-
animate={{ opacity: 1 }}
164-
exit={{ opacity: 0 }}
165-
transition={{ duration: 0.2 }}
166-
>
167-
<Outlet />
168-
</motion.div>
169-
</AnimatePresence>
157+
{/* 路由级过渡:grid 叠放 + 同期交叉淡入淡出。
158+
历史方案 mode="wait" 要求旧页退出回调交接后才挂载新页;在
159+
reduced-motion / StrictMode(dev) / HMR 等条件下交接会偶发丢失,
160+
新页永不挂载——即内测反馈"回到主页时主页不显示"的根因。
161+
交叉淡入淡出下新页立即挂载、旧页淡出后卸载,退出即使延迟也不阻塞内容展示 */}
162+
<div className="grid">
163+
<AnimatePresence>
164+
<motion.div
165+
key={pathname}
166+
style={{ gridArea: '1 / 1' }}
167+
/* 简单的 opacity 淡入淡出,不影响 3D 场景的相机飞行和停靠时序 */
168+
initial={{ opacity: 0 }}
169+
animate={{ opacity: 1 }}
170+
exit={{ opacity: 0 }}
171+
transition={{ duration: 0.2 }}
172+
>
173+
<Outlet />
174+
</motion.div>
175+
</AnimatePresence>
176+
</div>
170177
</FunctionalOverlay>
171178
)}
172179

‎client/src/features/assistant/hooks/useAssistantAudio.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
import { useEffect, useCallback } from 'react';
99
import { useAssistantStore } from '../store/useAssistantStore';
1010
import { ttsController } from '../lib/ttsController';
11+
import { publicAssetUrl } from '@/lib/assets/publicAssetUrl';
1112

13+
// publicAssetUrl 适配 Electron file:// 相对解析(绝对路径会 404)
1214
const SOUND_PATHS = {
13-
bubble: '/sounds/assistant-bubble.mp3',
14-
speakStart: '/sounds/assistant-speak-start.mp3',
15-
ack: '/sounds/assistant-ack.mp3',
15+
bubble: publicAssetUrl('/sounds/assistant-bubble.mp3'),
16+
speakStart: publicAssetUrl('/sounds/assistant-speak-start.mp3'),
17+
ack: publicAssetUrl('/sounds/assistant-ack.mp3'),
1618
} as const;
1719

1820
export type SoundName = keyof typeof SOUND_PATHS;

‎client/src/features/classroom/hooks/useClassroomAudio.ts‎

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,7 @@
2424
*/
2525
import { useState, useEffect, useRef } from 'react';
2626
import type { AudioChunkData, CaptureMode, SessionStatus, CaptureManager } from '@/lib/capture';
27-
import {
28-
isAudioWorkletSupported,
29-
startAudioWorkletPipeline,
30-
startScriptProcessorPipeline,
31-
} from '@/lib/audioPipeline';
27+
import { startAudioPipeline } from '@/lib/audioPipeline';
3228

3329
/** 开始采集后多久仍无音频块则判定管道未启动 */
3430
const NEVER_RECEIVED_TIMEOUT_MS = 15000;
@@ -245,18 +241,11 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us
245241
});
246242
};
247243

248-
// 优先使用 AudioWorklet,不可用时降级到 ScriptProcessor
249-
if (isAudioWorkletSupported()) {
250-
console.info('[useClassroomCapture] 使用 AudioWorklet 音频管道(推荐)');
251-
audioCleanupRef.current = await startAudioWorkletPipeline(
252-
audioCtx, stream, payload.options, onChunk,
253-
);
254-
} else {
255-
console.warn('[useClassroomCapture] AudioWorklet 不可用,降级到 ScriptProcessor');
256-
audioCleanupRef.current = startScriptProcessorPipeline(
257-
audioCtx, stream, payload.options, onChunk,
258-
);
259-
}
244+
// 统一入口:优先 AudioWorklet,模块加载失败自动降级 ScriptProcessor
245+
// (生产 file:// 下 addModule 会因 opaque origin 失败,降级保底)
246+
audioCleanupRef.current = await startAudioPipeline(
247+
audioCtx, stream, payload.options, onChunk,
248+
);
260249
} catch (err) {
261250
console.error('[useClassroomCapture] Audio pipeline start failed:', err);
262251
// 根据采集源类型给出不同的诊断提示

‎client/src/features/notes/components/ContentTierModal.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useToast } from '@/components/ui';
1111
import { AIThinkingIndicator } from '@/components/ui/AIThinkingIndicator';
1212
import { Layers, ChevronDown, ChevronRight, Copy } from 'lucide-react';
1313
import { cn } from '@/lib/utils';
14+
import { copyText } from '@/lib/utils/clipboard';
1415
import { useAIContentTier } from '@/lib/ai/hooks/useAIContentTier';
1516
import type { ContentTierItem } from '@/lib/ai/types';
1617

@@ -47,10 +48,11 @@ export function ContentTierModal({ open, onClose, noteText }: ContentTierModalPr
4748
const copyCore = async () => {
4849
if (!tier) return;
4950
const text = tier.core.map((item, i) => `${i + 1}. ${item.text}`).join('\n');
50-
try {
51-
await navigator.clipboard.writeText(text);
51+
// copyText 三级降级(Electron 下 navigator.clipboard 非聚焦态会失败)
52+
const ok = await copyText(text);
53+
if (ok) {
5254
toast({ type: 'success', message: '核心概念已复制到剪贴板' });
53-
} catch {
55+
} else {
5456
toast({ type: 'error', message: '复制失败,请手动选择复制' });
5557
}
5658
};

0 commit comments

Comments
 (0)