Skip to content

Commit aad3c6c

Browse files
committed
feat(classroom): 课堂助手模式UI优化、窗口智能识别与笔记生成加速
- 模式切换UI:采集路径改为卡片式选择器(图标+简述+动态说明面板),采集模式添加功能描述,语言选项'混合'改为'多语'消除歧义 - 窗口识别优化:新增windowScorer关键词评分引擎(网课/会议+100,浏览器+50,系统窗口过滤),3s轮询监听窗口变化,最小化容错(30s宽限期) - 笔记生成加速:截图PNG改JPEG Q75压缩(体积-90%),增量分析(每5帧后台静默分析),课后纯文本合并(5s vs 原120s),新增/merge-notes端点 - chore: 版本号 0.16.0 -> 0.16.1
1 parent ccfc8f0 commit aad3c6c

21 files changed

Lines changed: 999 additions & 75 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* AI 片段笔记合并 Handler
3+
*
4+
* 处理 ai_merge_notes IPC 请求,将增量分析产生的多个片段笔记
5+
* 合并为一份完整结构化笔记。纯文本操作,无需多模态模型。
6+
* 对应端点:POST /api/v1/multimodal/merge-notes
7+
*/
8+
9+
import { safeHandle } from '../../ipcUtils.js';
10+
import { logger } from '../../logger.js';
11+
import { callWithLocalFallback, gatewayUrl, type AIFeatureDef } from '../utils.js';
12+
import { generateText } from '../ollama/OllamaProvider.js';
13+
14+
// ================================================================
15+
// IPC Handler
16+
// ================================================================
17+
18+
/**
19+
* ai_merge_notes — POST /api/v1/multimodal/merge-notes
20+
*/
21+
function register(): void {
22+
safeHandle(
23+
'ai_merge_notes',
24+
async (
25+
_event,
26+
args: {
27+
partials: string[];
28+
duration?: number; // 秒
29+
language?: string;
30+
authToken?: string;
31+
userApiKey?: string;
32+
},
33+
) => {
34+
const startMs = Date.now();
35+
const partialCount = args.partials?.length ?? 0;
36+
logger.info(`[AI] [merge-notes] IPC received: partials=${partialCount}, duration=${args.duration ?? 0}s`);
37+
38+
if (!args.partials || args.partials.length === 0) {
39+
throw new Error('partials 不能为空');
40+
}
41+
42+
// 只有一个片段时直接返回
43+
if (args.partials.length === 1) {
44+
return {
45+
content: args.partials[0].trim(),
46+
modelUsed: 'none (single partial)',
47+
source: 'local' as const,
48+
};
49+
}
50+
51+
const reqBody = {
52+
partials: args.partials,
53+
duration: args.duration ?? 0,
54+
language: args.language ?? 'zh-CN',
55+
};
56+
57+
logger.info(`[AI] [merge-notes] Target: ${gatewayUrl()}/api/v1/multimodal/merge-notes`);
58+
59+
interface MergeNotesResp {
60+
content: string;
61+
model_used: string;
62+
}
63+
64+
try {
65+
// 本地 Ollama 降级:纯文本合并
66+
const localHandler = async (): Promise<MergeNotesResp> => {
67+
const partsContent = args.partials
68+
.map((p, i) => `### 片段 ${i + 1}\n\n${p.trim()}`)
69+
.join('\n\n---\n\n');
70+
71+
const prompt = [
72+
`以下是一门课程(总时长约 ${args.duration ?? 0} 秒)的 ${args.partials.length} 个片段笔记。`,
73+
'请将它们合并为一份完整的结构化课堂笔记,要求:',
74+
'1. 去除重复内容',
75+
'2. 使用 Markdown 二级标题按知识模块组织',
76+
'3. 补充片段间的衔接语句',
77+
'4. 保留所有公式、定义、代码',
78+
'5. 末尾添加「核心知识点摘要」',
79+
'',
80+
'---',
81+
'',
82+
partsContent,
83+
'',
84+
'---',
85+
'',
86+
'请直接输出合并后的 Markdown 笔记。',
87+
].join('\n');
88+
89+
const result = await generateText(
90+
prompt,
91+
'你是一个专业的课堂笔记整理助手,擅长将多个片段笔记合并为完整、连贯的结构化笔记。',
92+
{ temperature: 0.3, maxTokens: 4096 },
93+
);
94+
95+
return {
96+
content: result.content,
97+
model_used: result.model,
98+
};
99+
};
100+
101+
const { data: resp, source, requestId } = await callWithLocalFallback<typeof reqBody, MergeNotesResp>(
102+
'/api/v1/multimodal/merge-notes',
103+
reqBody,
104+
localHandler,
105+
args.authToken,
106+
args.userApiKey,
107+
30000, // 纯文本合并,30s 超时足够
108+
);
109+
110+
const elapsed = Date.now() - startMs;
111+
logger.info(`[AI] [merge-notes] ✔ Success (${source}): content_length=${resp.content?.length ?? 0}, model=${resp.model_used}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`);
112+
return {
113+
content: resp.content,
114+
modelUsed: resp.model_used,
115+
source,
116+
requestId,
117+
};
118+
} catch (err) {
119+
const elapsed = Date.now() - startMs;
120+
const error = err instanceof Error ? err : new Error(String(err));
121+
logger.error(`[AI] [merge-notes] ✖ Failed after ${elapsed}ms: ${error.message}`);
122+
throw error;
123+
}
124+
},
125+
);
126+
}
127+
128+
// ================================================================
129+
// 功能定义导出
130+
// ================================================================
131+
132+
export const feature: AIFeatureDef = {
133+
id: 'ai_merge_notes',
134+
name: 'AI 片段笔记合并',
135+
version: '1.0.0',
136+
register,
137+
};

‎client/electron/ai/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { feature as rescueFeature } from './handlers/rescueHandler.js';
2424
import { feature as visionExtractFeature } from './handlers/visionExtractHandler.js';
2525
import { feature as sessionAnalyzeFeature } from './handlers/sessionAnalyzeHandler.js';
2626
import { feature as videoAnalyzeFeature } from './handlers/videoAnalyzeHandler.js';
27+
import { feature as mergeNotesFeature } from './handlers/mergeNotesHandler.js';
2728

2829
// ================================================================
2930
// 功能注册表
@@ -45,6 +46,7 @@ const features: AIFeatureDef[] = [
4546
visionExtractFeature,
4647
sessionAnalyzeFeature,
4748
videoAnalyzeFeature,
49+
mergeNotesFeature,
4850
];
4951

5052
// ================================================================

‎client/electron/ai/ollama/OllamaService.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,45 @@ export async function pullModel(
287287
_cachedStatus = null;
288288
}
289289

290+
/**
291+
* 删除本地模型
292+
* @param modelName 模型名称(如 'qwen2.5:7b')
293+
*/
294+
export async function deleteModel(modelName: string): Promise<void> {
295+
const config = getOllamaConfig();
296+
const baseUrl = config.baseUrl;
297+
298+
logger.info(`[Ollama] Deleting model: ${modelName}`);
299+
300+
const controller = new AbortController();
301+
const timeoutId = setTimeout(() => controller.abort(), 30_000);
302+
303+
try {
304+
const resp = await fetch(`${baseUrl}/api/delete`, {
305+
method: 'DELETE',
306+
headers: { 'Content-Type': 'application/json' },
307+
body: JSON.stringify({ name: modelName }),
308+
signal: controller.signal,
309+
});
310+
clearTimeout(timeoutId);
311+
312+
if (!resp.ok) {
313+
const detail = await resp.text().catch(() => 'unknown error');
314+
throw new Error(`Ollama delete failed: HTTP ${resp.status} - ${detail}`);
315+
}
316+
317+
logger.info(`[Ollama] Model deleted successfully: ${modelName}`);
318+
// 清除缓存,下次获取状态时刷新模型列表
319+
_cachedStatus = null;
320+
} catch (e) {
321+
clearTimeout(timeoutId);
322+
if (e instanceof Error && e.name === 'AbortError') {
323+
throw new Error('Ollama delete timed out');
324+
}
325+
throw e;
326+
}
327+
}
328+
290329
/**
291330
* 应用启动时执行初始检测(如果 autoDetect 开启)
292331
*/

‎client/electron/ai/ollama/index.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import { ipcMain, BrowserWindow } from 'electron';
1111
import { logger } from '../../logger.js';
12-
import { getOllamaStatus, pullModel, initOllamaDetection } from './OllamaService.js';
12+
import { getOllamaStatus, pullModel, deleteModel, initOllamaDetection } from './OllamaService.js';
1313
import { loadOllamaConfig, updateOllamaConfig, getOllamaConfig } from './config.js';
1414

1515
// ================================================================
@@ -85,6 +85,24 @@ export function registerOllamaHandlers(): void {
8585
}
8686
});
8787

88+
// ---- 删除模型 ----
89+
ipcMain.handle('ollama:delete-model', async (_event, modelName: string) => {
90+
if (!modelName || typeof modelName !== 'string') {
91+
throw new Error('Invalid model name');
92+
}
93+
94+
logger.info(`[Ollama] Delete model requested: ${modelName}`);
95+
96+
try {
97+
await deleteModel(modelName);
98+
return { success: true };
99+
} catch (err) {
100+
const errorMsg = err instanceof Error ? err.message : String(err);
101+
logger.error(`[Ollama] Delete model failed: ${errorMsg}`);
102+
throw err;
103+
}
104+
});
105+
88106
logger.info('[Ollama] All IPC handlers registered');
89107
}
90108

@@ -98,7 +116,7 @@ export async function initOllama(): Promise<void> {
98116
}
99117

100118
// 统一导出
101-
export { getOllamaStatus, pullModel } from './OllamaService.js';
119+
export { getOllamaStatus, pullModel, deleteModel } from './OllamaService.js';
102120
export { getOllamaConfig, updateOllamaConfig, isLocalInferenceEnabled } from './config.js';
103121
export { generateText, generateVision, generateVisionMulti } from './OllamaProvider.js';
104122
export { isOllamaAvailable } from './OllamaService.js';

‎client/electron/captureHandlers.ts‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { VideoRecorder } from './videoRecorder.js';
1313
import type { VideoRecordOptions } from './videoRecorder.js';
1414
import { safeHandle, getMainWindowId } from './ipcUtils.js';
1515
import { logger } from './logger.js';
16+
import { scoreAndFilterWindows } from './windowScorer.js';
1617

1718
// ================================================================
1819
// 模块级状态
@@ -34,6 +35,14 @@ const START_DEBOUNCE_MS = 500;
3435
/** 单调递增会话标识,用于防止 stop 后残留的 debounce 重启采集 */
3536
let captureSessionToken = 0;
3637

38+
/** 窗口监听轮询定时器 */
39+
let windowWatchTimer: ReturnType<typeof setInterval> | null = null;
40+
41+
/** 上一次窗口列表的 id 集合(用于 diff 检测变化) */
42+
let lastWindowIds: Set<string> = new Set();
43+
44+
const WINDOW_WATCH_INTERVAL_MS = 3000;
45+
3746
// ================================================================
3847
// 公共 API
3948
// ================================================================
@@ -120,21 +129,81 @@ export function registerCaptureHandlers(): void {
120129
thumbnailSize: { width: 240, height: 135 },
121130
});
122131

123-
return sources.map((src) => {
124-
// 压缩缩略图,避免窗口多时 UI 内存占用过大
132+
const rawWindows = sources.map((src) => {
125133
const thumb = src.thumbnail.isEmpty() ? src.thumbnail : src.thumbnail.resize({ width: 120 });
126134
return {
127135
id: src.id,
128136
title: src.name,
129137
thumbnail: thumb.toDataURL(),
130138
};
131139
});
140+
141+
// 智能评分、过滤与排序
142+
return scoreAndFilterWindows(rawWindows);
132143
} catch (err) {
133144
logger.error('[IPC] screen_list_windows failed:', err);
134145
return [];
135146
}
136147
});
137148

149+
// ---- 窗口变化监听(轮询) ----
150+
151+
safeHandle('screen_watch_windows_start', async () => {
152+
if (windowWatchTimer) return { success: true }; // 已在监听
153+
154+
logger.info('[IPC] 窗口监听已启动, interval=' + WINDOW_WATCH_INTERVAL_MS + 'ms');
155+
156+
windowWatchTimer = setInterval(async () => {
157+
try {
158+
const sources = await desktopCapturer.getSources({
159+
types: ['window'],
160+
thumbnailSize: { width: 240, height: 135 },
161+
});
162+
163+
const currentIds = new Set(sources.map((s) => s.id));
164+
165+
// 检测是否有变化(新增或关闭窗口)
166+
const hasChanged =
167+
currentIds.size !== lastWindowIds.size ||
168+
[...currentIds].some((id) => !lastWindowIds.has(id));
169+
170+
if (hasChanged) {
171+
lastWindowIds = currentIds;
172+
173+
const rawWindows = sources.map((src) => {
174+
const thumb = src.thumbnail.isEmpty() ? src.thumbnail : src.thumbnail.resize({ width: 120 });
175+
return { id: src.id, title: src.name, thumbnail: thumb.toDataURL() };
176+
});
177+
178+
const scored = scoreAndFilterWindows(rawWindows);
179+
180+
// 推送到渲染进程
181+
const mainWindowId = getMainWindowId();
182+
if (mainWindowId) {
183+
const win = BrowserWindow.fromId(mainWindowId);
184+
if (win && !win.isDestroyed()) {
185+
win.webContents.send('screen_windows_changed', scored);
186+
}
187+
}
188+
}
189+
} catch (err) {
190+
logger.error('[IPC] window watch poll error:', err);
191+
}
192+
}, WINDOW_WATCH_INTERVAL_MS);
193+
194+
return { success: true };
195+
});
196+
197+
safeHandle('screen_watch_windows_stop', async () => {
198+
if (windowWatchTimer) {
199+
clearInterval(windowWatchTimer);
200+
windowWatchTimer = null;
201+
lastWindowIds = new Set();
202+
logger.info('[IPC] 窗口监听已停止');
203+
}
204+
return { success: true };
205+
});
206+
138207
// ---- 系统音频捕获 ----
139208

140209
safeHandle('audio_list_sources', async () => {

‎client/electron/ipc/channels.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export const IPC_CHANNELS = {
2525

2626
// 采集相关
2727
SCREEN_LIST_WINDOWS: 'screen_list_windows',
28+
SCREEN_WATCH_WINDOWS_START: 'screen_watch_windows_start',
29+
SCREEN_WATCH_WINDOWS_STOP: 'screen_watch_windows_stop',
30+
SCREEN_WINDOWS_CHANGED: 'screen_windows_changed',
2831
SCREEN_CAPTURE_START: 'screen_capture_start',
2932
SCREEN_CAPTURE_STOP: 'screen_capture_stop',
3033
SCREEN_CAPTURE_FRAME: 'screen_capture_frame',

‎client/electron/preload.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ const ALLOWED_CHANNELS = [
2727
'ai_vision_extract',
2828
'ai_session_analyze',
2929
'ai_video_analyze',
30+
'ai_merge_notes',
3031
'ai:set-gateway-url',
3132
'screen_list_windows',
33+
'screen_watch_windows_start',
34+
'screen_watch_windows_stop',
3235
'screen_capture_start',
3336
'screen_capture_stop',
3437
'audio_list_sources',
@@ -77,11 +80,13 @@ const ALLOWED_CHANNELS = [
7780
'ollama:get-status',
7881
'ollama:set-config',
7982
'ollama:pull-model',
83+
'ollama:delete-model',
8084
] as const;
8185

8286
/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
8387
const ALLOWED_EVENT_CHANNELS = [
8488
'screen_capture_frame',
89+
'screen_windows_changed',
8590
'audio_capture_chunk',
8691
'audio_capture_do_start',
8792
'audio_capture_do_stop',
@@ -213,6 +218,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
213218
ipcRenderer.invoke('ollama:set-config', config),
214219
pullModel: (modelName: string) =>
215220
ipcRenderer.invoke('ollama:pull-model', modelName),
221+
deleteModel: (modelName: string) =>
222+
ipcRenderer.invoke('ollama:delete-model', modelName),
216223
onPullProgress: (callback: (...args: unknown[]) => void) => {
217224
const handler = (_event: unknown, ...args: unknown[]) => callback(...args);
218225
ipcRenderer.on('ollama:pull-progress', handler);

0 commit comments

Comments
 (0)