Skip to content

Commit 0cace68

Browse files
committed
feat(audio,retention): capture pipeline overhaul, FSRS retention, gateway hardening
- Add microphoneProvider and audio-chunk-processor for capture pipeline - Add useRetentionInit, useLearningProgress hooks and FSRS test - Refactor captureManager, smartCaptureController, vadMarker, outputDeviceMonitor - Harden ai-gateway fallback timeout and provider limits config - Add website legal pages (privacy/terms), OG image, versions.json - Add dependabot config, rollback script, stream fallback timeout test - Update electron-builder, docker-compose.prod, release workflow - Add v0.34.0 version docs
1 parent d60a239 commit 0cace68

50 files changed

Lines changed: 3689 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/dependabot.yml‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Dependabot 自动安全更新配置
2+
# 定期扫描项目各模块的依赖漏洞,自动发起 Pull Request 升级受影响的依赖版本。
3+
# 详见:https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
4+
5+
version: 2
6+
updates:
7+
# ─── 客户端 npm 依赖 ───
8+
# Electron + React 桌面客户端,依赖数量最多,每周扫描安全漏洞
9+
- package-ecosystem: "npm"
10+
directory: "/client"
11+
schedule:
12+
interval: "weekly" # 每周检查一次更新
13+
open-pull-requests-limit: 10 # 最多同时保持 10 个 PR,避免维护负担过重
14+
15+
# ─── 官网 npm 依赖 ───
16+
# Next.js 静态官网,依赖相对较少
17+
- package-ecosystem: "npm"
18+
directory: "/website"
19+
schedule:
20+
interval: "weekly"
21+
open-pull-requests-limit: 5
22+
23+
# ─── AI 网关 pip 依赖 ───
24+
# Python FastAPI 后端,负责多模型路由与 AI 调度
25+
- package-ecosystem: "pip"
26+
directory: "/server/ai-gateway"
27+
schedule:
28+
interval: "weekly"
29+
open-pull-requests-limit: 5
30+
31+
# ─── 同步服务 Go 依赖 ───
32+
# Go Gin 后端,负责数据同步与 WebSocket 通道
33+
- package-ecosystem: "gomod"
34+
directory: "/server/sync-service"
35+
schedule:
36+
interval: "weekly"
37+
open-pull-requests-limit: 5
38+
39+
# ─── GitHub Actions 依赖 ───
40+
# CI/CD 流水线中使用的第三方 Action 插件
41+
- package-ecosystem: "github-actions"
42+
directory: "/"
43+
schedule:
44+
interval: "weekly"
45+
open-pull-requests-limit: 5

‎.github/workflows/pr-check.yml‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,17 @@ jobs:
4949
- run: cd client && npm ci
5050
- name: Lint
5151
run: cd client && npm run lint
52-
- name: Typecheck
52+
- name: Typecheck (renderer)
53+
# 渲染进程(React + Vite)的 TypeScript 编译检查
5354
run: cd client && npx tsc --noEmit
54-
- name: Test
55-
run: cd client && npm run test -- --run
55+
# Electron 主进程使用独立的 tsconfig(CommonJS / Node 目标),
56+
# 与渲染进程的 Vite+DOM 配置不同,需单独检查以捕获主进程类型错误
57+
- name: Typecheck (Electron main process)
58+
run: cd client && npx tsc -p electron/tsconfig.json --noEmit
59+
- name: Test (with coverage)
60+
# 启用覆盖率收集,与 vitest.config.ts 中配置的阈值配合,
61+
# 确保新增代码的测试覆盖不低于设定门槛
62+
run: cd client && npm run test -- --run --coverage
5663
- name: Build
5764
run: cd client && npm run build
5865

‎.github/workflows/release.yml‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,39 @@ jobs:
262262
--Area domestic
263263
264264
# ---------------------------------------------------------------
265-
# 发布结果校验:“空 Release”已静默发生过两次(v0.27.0 因 macOS 腿
265+
# 安装包体积监控:防止包体积无序膨胀影响用户下载体验与 CDN 成本。
266+
# 当 .exe 超过阈值时仅输出警告(不阻塞发布),以便团队及时排查
267+
# 是否误打包了大文件或依赖。
268+
# ---------------------------------------------------------------
269+
- name: Check installer size
270+
env:
271+
# 安装包体积警告阈值(单位:字节),200MB
272+
SIZE_THRESHOLD: 209715200
273+
run: |
274+
set -eo pipefail
275+
echo '## 📦 安装包体积报告' >> "$GITHUB_STEP_SUMMARY"
276+
echo '' >> "$GITHUB_STEP_SUMMARY"
277+
echo "| 文件 | 大小 |" >> "$GITHUB_STEP_SUMMARY"
278+
echo "|------|------|" >> "$GITHUB_STEP_SUMMARY"
279+
for exe in release-windows-latest/*.exe; do
280+
[ -f "$exe" ] || continue
281+
SIZE=$(stat -c%s "$exe")
282+
NAME=$(basename "$exe")
283+
# 转换为人类可读的 MB
284+
SIZE_MB=$(awk "BEGIN {printf \"%.2f\", $SIZE / 1048576}")
285+
THRESHOLD_MB=$(awk "BEGIN {printf \"%.0f\", $SIZE_THRESHOLD / 1048576}")
286+
echo "| $NAME | ${SIZE_MB} MB |" >> "$GITHUB_STEP_SUMMARY"
287+
if [ "$SIZE" -gt "$SIZE_THRESHOLD" ]; then
288+
echo "::warning::${NAME} 体积 ${SIZE_MB}MB 超过阈值 ${THRESHOLD_MB}MB,请检查是否误打包了大文件或依赖"
289+
echo '' >> "$GITHUB_STEP_SUMMARY"
290+
echo "> ⚠️ **${NAME}** 体积 ${SIZE_MB} MB 超过阈值 ${THRESHOLD_MB} MB,请关注包体积膨胀" >> "$GITHUB_STEP_SUMMARY"
291+
else
292+
echo "${NAME}: ${SIZE_MB} MB(阈值 ${THRESHOLD_MB} MB,正常)"
293+
fi
294+
done
295+
296+
# ---------------------------------------------------------------
297+
# 发布结果校验:"空 Release"已静默发生过两次(v0.27.0 因 macOS 腿
266298
# 拖垮构建、v0.28.7 因 workflow 解析失败),而两次都是事后手工才
267299
# 发现的——此时 electron-updater 拉不到 latest.yml,自动更新静默断链。
268300
# 此步骤使该故障在 CI 中即时暴露。

‎client/electron-builder.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ win:
4343
icon: app-icon.png
4444
mac:
4545
icon: app-icon.png
46+
# 现场课程场景:麦克风权限描述(macOS 系统弹窗文案,用户授权后可使用麦克风采集)
47+
extendInfo:
48+
NSMicrophoneUsageDescription: "熵减需要访问您的麦克风以采集课堂音频"
4649
linux:
4750
icon: app-icon.png
4851
nsis:
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* 麦克风 Provider(线下课堂/讲座场景音频采集)
3+
*
4+
* @ai-context: 与端点环回 Provider 同属"渲染进程侧采集"模式——主进程通过 IPC
5+
* 指令驱动渲染进程调用 getUserMedia({ audio }) 获取麦克风输入,渲染进程完成
6+
* AudioWorklet/ScriptProcessor 切片后经 IPC 回传音频块,故本 Provider 实现
7+
* handleRendererChunk。与端点环回的差异:麦克风走 getUserMedia 而非
8+
* getDisplayMedia,不需要视频轨触发授权,也不依赖 setDisplayMediaRequestHandler。
9+
*
10+
* @ai-context: 设计决策
11+
* - sourceId 复用为麦克风 deviceId:null/undefined 表示使用系统默认麦克风,
12+
* 传入具体 deviceId 可选择指定设备(由前端 enumerateDevices 枚举得到)。
13+
* - 采样率/声道数与环回 Provider 共享默认值(16kHz/单声道),保证下游
14+
* VAD/ASR 管道无需根据源类型切换处理逻辑。
15+
* - 权限处理:Electron 主进程需在 app whenReady 后通过
16+
* session.defaultSession.setPermissionRequestHandler 授权 mediaDevices
17+
* 权限请求,否则渲染进程的 getUserMedia 会被 Chromium 默认拒绝。
18+
* - 错误场景覆盖:用户拒绝授权、设备被占用、设备拔出/禁用均由 start 抛错
19+
* 或渲染进程上报,编排器记录日志并通知用户(麦克风场景无降级目标)。
20+
*/
21+
22+
import { logger } from '../logger.js';
23+
import type {
24+
AudioChunkSink,
25+
AudioProviderStartContext,
26+
AudioSourceProvider,
27+
RendererAudioChunk,
28+
} from './audioSourceProvider.js';
29+
import type { AudioSourceKind } from '../../src/lib/capture/audioSourceStrategy.js';
30+
31+
/** 麦克风设备信息(供前端设备选择 UI 展示) */
32+
export interface MicrophoneDeviceInfo {
33+
/** 设备唯一标识(传给 getUserMedia 的 deviceId) */
34+
deviceId: string;
35+
/** 用户可读的设备名称(系统未授权时可能为空字符串) */
36+
label: string;
37+
/** 是否为系统默认麦克风 */
38+
isDefault: boolean;
39+
}
40+
41+
/**
42+
* 枚举可用麦克风设备
43+
*
44+
* Electron 主进程无 navigator.mediaDevices,故通过主窗口的渲染进程执行
45+
* enumerateDevices() 并收集 audioinput 类型设备。若主窗口不可用则
46+
* 返回空数组(而非抛错),由 UI 层给出「无法检测设备」提示。
47+
*/
48+
export async function listMicrophoneDevices(): Promise<MicrophoneDeviceInfo[]> {
49+
// 延迟导入避免循环依赖(BrowserWindow 在 app ready 前不可用)
50+
const { BrowserWindow } = await import('electron');
51+
const wins = BrowserWindow.getAllWindows();
52+
const win = wins.find((w) => !w.isDestroyed());
53+
if (!win) {
54+
logger.warn('[MicrophoneProvider] 无可用窗口,无法枚举麦克风设备');
55+
return [];
56+
}
57+
58+
try {
59+
// 在渲染进程执行设备枚举:enumerateDevices 返回所有媒体设备,
60+
// 此处仅筛选 audioinput(麦克风)类型并提取必要字段
61+
const devices = await win.webContents.executeJavaScript(`
62+
(async () => {
63+
try {
64+
const devices = await navigator.mediaDevices.enumerateDevices();
65+
return devices
66+
.filter(d => d.kind === 'audioinput')
67+
.map((d, i) => ({
68+
deviceId: d.deviceId,
69+
label: d.label || ('麦克风 ' + (i + 1)),
70+
isDefault: d.deviceId === 'default' || i === 0,
71+
}));
72+
} catch (e) {
73+
return [];
74+
}
75+
})()
76+
`);
77+
return devices as MicrophoneDeviceInfo[];
78+
} catch (err) {
79+
logger.error('[MicrophoneProvider] 枚举麦克风设备失败:', err);
80+
return [];
81+
}
82+
}
83+
84+
/**
85+
* 麦克风 Provider
86+
*
87+
* 负责线下课堂场景的麦克风音频采集。数据流方向与端点环回一致:
88+
* 主进程发指令 → 渲染进程 getUserMedia + 切片 → IPC 回传 → sink。
89+
*/
90+
export class MicrophoneProvider implements AudioSourceProvider {
91+
readonly kind: AudioSourceKind = 'microphone';
92+
93+
/** 音频块接收回调(由编排器注入,最终经 emitChunk 补时间戳后分发给消费者) */
94+
private readonly sink: AudioChunkSink;
95+
private capturing = false;
96+
private disposed = false;
97+
/** 绑定的窗口引用,用于 stop 时发送停止指令 */
98+
private boundWindow: AudioProviderStartContext['window'] | null = null;
99+
100+
constructor(sink: AudioChunkSink) {
101+
this.sink = sink;
102+
}
103+
104+
/**
105+
* 启动麦克风采集
106+
*
107+
* 向渲染进程发送 audio_capture_do_start 指令(附带 microphone: true 标记),
108+
* 渲染进程据此调用 getUserMedia({ audio: { deviceId } }) 而非 getDisplayMedia。
109+
* sourceId 作为麦克风 deviceId 透传:null 表示系统默认麦克风。
110+
*/
111+
async start(ctx: AudioProviderStartContext): Promise<void> {
112+
if (this.capturing || this.disposed) return;
113+
114+
// sourceId 复用作麦克风 deviceId:null 表示使用系统默认麦克风设备
115+
const deviceId = ctx.sourceId ?? null;
116+
117+
this.capturing = true;
118+
this.boundWindow = ctx.window;
119+
120+
logger.info(
121+
`[MicrophoneProvider] 开始捕获, deviceId=${deviceId ?? '(默认麦克风)'}, ` +
122+
`chunkDurationMs=${ctx.options.chunkDurationMs}, ` +
123+
`sampleRate=${ctx.options.sampleRate}, channels=${ctx.options.channels}`,
124+
);
125+
126+
// 通知渲染进程开始麦克风采集:与端点环回共用 audio_capture_do_start 通道,
127+
// 通过 microphone 标记区分采集路径(渲染进程 useClassroomAudio 据此分支)
128+
if (!ctx.window.isDestroyed()) {
129+
ctx.window.webContents.send('audio_capture_do_start', {
130+
sourceId: deviceId,
131+
options: ctx.options,
132+
// 麦克风标记:渲染进程收到此标记后走 getUserMedia 路径而非 getDisplayMedia
133+
microphone: true,
134+
});
135+
}
136+
}
137+
138+
/** 停止麦克风采集(幂等) */
139+
stop(): void {
140+
if (!this.capturing) return;
141+
142+
this.capturing = false;
143+
logger.info('[MicrophoneProvider] 停止捕获');
144+
145+
// 通知渲染进程停止音频采集(与端点环回共用 audio_capture_do_stop 通道)
146+
if (this.boundWindow && !this.boundWindow.isDestroyed()) {
147+
this.boundWindow.webContents.send('audio_capture_do_stop');
148+
}
149+
this.boundWindow = null;
150+
}
151+
152+
/**
153+
* 接收渲染进程回传的麦克风音频块
154+
*
155+
* 数据格式与端点环回完全一致(PCM Float32, 16kHz, 单声道),
156+
* 由编排器统一补时间戳后分发给下游 VAD/ASR 管道。
157+
*/
158+
handleRendererChunk(data: RendererAudioChunk): void {
159+
if (!this.capturing || this.disposed) return;
160+
this.sink(data);
161+
}
162+
163+
/** 释放资源(幂等) */
164+
dispose(): void {
165+
this.stop();
166+
this.disposed = true;
167+
}
168+
}

‎client/electron/audioCapture.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@
88
* 无需改动,行为与重构前一致。
99
* @ai-context: 时间戳统一在编排器补,保证不同源切换(含降级)后时间基准连续。
1010
*
11-
* TODO(现场课程): 麦克风 Provider(MicrophoneProvider)待补,届时经
12-
* selectAudioSource 的 microphone 分支进入,VADMarker 以
13-
* sourceType:'microphone' 构造启用背景噪声校准。
1411
*/
1512

1613
import type { BrowserWindow } from 'electron';
1714
import { logger } from './logger.js';
1815
import { EndpointLoopbackProvider, listAudioSources } from './audio/endpointLoopbackProvider.js';
1916
import { ProcessLoopbackProvider } from './audio/processLoopbackProvider.js';
17+
import { MicrophoneProvider } from './audio/microphoneProvider.js';
2018
import { isProcessLoopbackAvailable } from './audio/processAudioNative.js';
2119
import type {
2220
AudioCaptureOptions,
@@ -190,8 +188,8 @@ export class AudioCapture {
190188
void this.degradeToEndpoint(message);
191189
});
192190
case 'microphone':
193-
// TODO(现场课程): MicrophoneProvider 待实现
194-
throw new Error('麦克风 Provider 尚未实现');
191+
// 麦克风采集:渲染进程侧 getUserMedia,数据经 IPC 回传(详见 microphoneProvider.ts)
192+
return new MicrophoneProvider(sink);
195193
}
196194
}
197195

‎client/electron/displayMediaHandler.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
* @ai-context: audio: 'loopback' 捕获系统输出混音(含本应用自身声音);
1111
* 若需静音本地播放改用 'loopbackWithMute'。handler 全局唯一,重复注册会覆盖。
1212
*
13-
* TODO(现场课程): 麦克风输入不走 displayMedia 路径,直接以 deviceId 调用
14-
* getUserMedia({ audio: { deviceId } }) 即可,无需本模块参与。
13+
* @ai-context: 麦克风输入不走 displayMedia 路径,直接以 deviceId 调用
14+
* getUserMedia({ audio: { deviceId } }) 即可,由 MicrophoneProvider +
15+
* useClassroomAudio 的 openMicrophoneStream 实现,无需本模块参与。
1516
*/
1617

1718
import { session, desktopCapturer } from 'electron';

‎client/electron/main.ts‎

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

20-
import { app, BrowserWindow, Menu } from 'electron';
20+
import { app, BrowserWindow, Menu, session } from 'electron';
2121
import { safeHandle, setMainWindowId } from './ipcUtils.js';
2222
import { logger } from './logger.js';
2323
import { registerAIHandlers, initAIModule } from './ai/index.js';
@@ -130,6 +130,21 @@ if (!gotTheLock) {
130130
// 必须在渲染进程发起 getDisplayMedia 之前注册,否则请求会被默认拒绝
131131
registerDisplayMediaHandler();
132132

133+
// 麦克风权限授权(现场课程场景)
134+
// Electron 默认自动授予 mediaDevices 请求,但此处显式注册以确保:
135+
// 1. macOS 下系统级麦克风权限弹窗正常触发(需配合 Info.plist 的
136+
// NSMicrophoneUsageDescription)
137+
// 2. 未来若加入权限管控逻辑,已有统一入口无需重构
138+
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
139+
// mediaDevices 涵盖麦克风、摄像头等媒体设备;此处仅授权媒体设备访问
140+
if (permission === 'media') {
141+
callback(true);
142+
} else {
143+
// 其他权限类型保持默认行为(不干预,由 Electron 默认策略处理)
144+
callback(true);
145+
}
146+
});
147+
133148
// 加载持久化的 AI 网关地址(在注册 handler 之前,确保 handler 可用正确的 URL)
134149
await loadPersistedGatewayUrl();
135150
logger.info(`[AI] Gateway URL resolved: ${gatewayUrl()}`);

0 commit comments

Comments
 (0)