Skip to content

Commit 231ab19

Browse files
committed
feat: v0.17.1 — CRDT 协同引擎、FSRS 调度器、AI 流式传输与全模块增强
CRDT 协同引擎: - 新增 crdtEngine.ts 冲突无关协同编辑引擎 - 新增 sync-service/handlers/crdt.go 后端 CRDT 处理 - 引入 @automerge/automerge 依赖 - SyncEngine / syncHandler 同步层优化 FSRS 调度器: - 新增 fsrs.ts 遗忘曲线调度算法 - 新增 scheduler.ts / schedulingFactory.ts 统一调度工厂 AI 流式传输: - 新增 streamHandler.ts Electron 端流式处理 - 新增 aiStreamConsumer.ts 前端流式消费 - 新增 streaming.py 后端流式路由 - ElectronAIPlugin / RemoteAIPlugin / AIPluginLoader 流式支持 - AI types / AIPluginLoader 接口扩展 3D 与导航: - 新增 MobileNavGrid.tsx 移动端 3D 导航网格 - BottomNav / AppLayout / FunctionalOverlay 布局优化 笔记与搜索: - 新增 NoteSearchBar 搜索组件 - 新增 dexieSearchIndexer / search types 搜索索引 - useSearchIndexSync / useNoteStore / database 优化 费曼与闪卡: - 新增 FlashcardSettings 闪卡设置页 - useFeynmanSession / FeynmanSessionPage / useFeynmanStore 优化 - useFlashcardStore / useStudySessionStore / inspirationStore 状态增强 环境与存储: - 新增 lib/env/ 环境配置模块 - classroomNoteStore / writeWithLog / database 存储层优化 - vite.config 构建配置更新 后端全链路更新: - config.py / main.py 网关配置优化 - 全部 Provider 增强(base/deepseek/fallback/gemini/glm/qwen) - routers/__init__.py 路由注册更新 - nginx.conf 反向代理配置调整 - sync-service Go 模型扩展 PWA 与设置: - 新增 offline.html 离线页面 - PWAInstallPrompt / SettingsPage 优化 全量版本号同步至 v0.17.1
1 parent c10b194 commit 231ab19

64 files changed

Lines changed: 4881 additions & 229 deletions

Some content is hidden

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

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<p align="center"><strong>你的 AI 智能学习伙伴 — 让每一分钟学习都高效有价值</strong></p>
44
<p align="center">
55
<img src="https://img.shields.io/badge/status-beta-green.svg" alt="Status" />
6-
<img src="https://img.shields.io/badge/version-v0.17.0-blue.svg" alt="Version" />
6+
<img src="https://img.shields.io/badge/version-v0.17.1-blue.svg" alt="Version" />
77
<img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
88
</p>
99
</p>

‎client/electron/ai/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { logger } from '../logger.js';
99
import type { AIFeatureDef } from './utils.js';
1010
import { registerOllamaHandlers, initOllama } from './ollama/index.js';
11+
import { registerStreamHandler } from './streamHandler.js';
1112

1213
// 导入所有 AI 功能模块
1314
import { feature as summarizeFeature } from './handlers/summarizeHandler.js';
@@ -70,6 +71,9 @@ export function registerAIHandlers(): void {
7071
// 注册 Ollama 本地推理 IPC handler
7172
registerOllamaHandlers();
7273

74+
// 注册流式输出 IPC handler
75+
registerStreamHandler();
76+
7377
logger.info('[AI] All AI handlers registered successfully');
7478
}
7579

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* AI 流式输出 IPC Handler
3+
*
4+
* 处理 ai:stream:start IPC 请求,通过 postJsonStream() 获取 SSE 流,
5+
* 将 chunk 以 50ms 节流推送到渲染进程。
6+
*
7+
* 通信协议:
8+
* - 渲染进程 → 主进程:ipcRenderer.invoke('ai:stream:start', { requestId, method, payload })
9+
* - 主进程 → 渲染进程:
10+
* - 'ai:stream:chunk' { requestId, chunk }
11+
* - 'ai:stream:end' { requestId }
12+
* - 'ai:stream:error' { requestId, error }
13+
*/
14+
15+
import { safeHandle } from '../ipcUtils.js';
16+
import { logger } from '../logger.js';
17+
import { postJsonStream } from './utils.js';
18+
19+
// ================================================================
20+
// 节流配置
21+
// ================================================================
22+
23+
/** IPC chunk 推送节流间隔(ms),防止高频 chunk 风暴 */
24+
const CHUNK_THROTTLE_MS = 50;
25+
26+
/** 活跃的流式请求 AbortController 映射(requestId → controller) */
27+
const activeStreams = new Map<string, AbortController>();
28+
29+
// ================================================================
30+
// IPC Handler 注册
31+
// ================================================================
32+
33+
/**
34+
* 注册 ai:stream:start handler
35+
*
36+
* 渲染进程通过 invoke 发起流式请求,主进程逐 chunk 推送到渲染进程。
37+
* 使用 50ms 节流合并高频 chunk,防止 IPC 风暴。
38+
*/
39+
export function registerStreamHandler(): void {
40+
safeHandle(
41+
'ai:stream:start',
42+
async (
43+
event,
44+
args: {
45+
requestId: string;
46+
/** API 路径,如 /api/v1/ai/summarize/stream */
47+
method: string;
48+
/** 请求体 */
49+
payload: Record<string, unknown>;
50+
/** 认证 token */
51+
authToken?: string;
52+
/** 用户 API Key */
53+
userApiKey?: string;
54+
},
55+
) => {
56+
const { requestId, method, payload, authToken, userApiKey } = args;
57+
const sender = event.sender;
58+
59+
logger.info(`[AI] [stream] Start: requestId=${requestId}, method=${method}`);
60+
61+
// 为该流式请求创建 AbortController
62+
const abortController = new AbortController();
63+
activeStreams.set(requestId, abortController);
64+
65+
// 50ms 节流缓冲
66+
let chunkBuffer = '';
67+
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
68+
let cancelled = false;
69+
70+
/** 将缓冲区内的 chunk 一次性推送到渲染进程 */
71+
function flushChunks(): void {
72+
if (cancelled || !chunkBuffer) return;
73+
if (sender.isDestroyed()) return;
74+
sender.send('ai:stream:chunk', { requestId, chunk: chunkBuffer });
75+
chunkBuffer = '';
76+
throttleTimer = null;
77+
}
78+
79+
try {
80+
const stream = postJsonStream(
81+
method,
82+
payload,
83+
authToken,
84+
userApiKey,
85+
);
86+
87+
for await (const chunk of stream) {
88+
if (cancelled || abortController.signal.aborted) {
89+
cancelled = true;
90+
break;
91+
}
92+
93+
chunkBuffer += chunk;
94+
95+
// 启动节流定时器(仅在无定时器运行时)
96+
if (!throttleTimer) {
97+
throttleTimer = setTimeout(flushChunks, CHUNK_THROTTLE_MS);
98+
}
99+
}
100+
101+
// 流结束:刷出剩余缓冲
102+
if (throttleTimer) {
103+
clearTimeout(throttleTimer);
104+
throttleTimer = null;
105+
}
106+
flushChunks();
107+
108+
// 推送结束信号
109+
if (!cancelled && !sender.isDestroyed()) {
110+
sender.send('ai:stream:end', { requestId });
111+
}
112+
113+
logger.info(`[AI] [stream] Complete: requestId=${requestId}`);
114+
return { ok: true, requestId };
115+
} catch (err) {
116+
// 流异常:刷出剩余缓冲,推送错误信号
117+
if (throttleTimer) {
118+
clearTimeout(throttleTimer);
119+
throttleTimer = null;
120+
}
121+
flushChunks();
122+
123+
const errorMessage = err instanceof Error ? err.message : String(err);
124+
logger.error(`[AI] [stream] Error: requestId=${requestId}, error=${errorMessage}`);
125+
126+
if (!cancelled && !sender.isDestroyed()) {
127+
sender.send('ai:stream:error', { requestId, error: errorMessage });
128+
}
129+
130+
return { ok: false, requestId, error: errorMessage };
131+
} finally {
132+
activeStreams.delete(requestId);
133+
}
134+
},
135+
);
136+
137+
// 注册取消流式请求的 handler
138+
safeHandle(
139+
'ai:stream:cancel',
140+
async (_event, args: { requestId: string }) => {
141+
const { requestId } = args;
142+
logger.info(`[AI] [stream] Cancel requested: requestId=${requestId}`);
143+
const controller = activeStreams.get(requestId);
144+
if (controller) {
145+
controller.abort();
146+
activeStreams.delete(requestId);
147+
logger.info(`[AI] [stream] Cancelled: requestId=${requestId}`);
148+
}
149+
return { ok: true };
150+
},
151+
);
152+
}

‎client/electron/ai/utils.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,124 @@ export async function callWithLocalFallback<TReq, TRes>(
382382
);
383383
return { data, source: 'remote', requestId };
384384
}
385+
386+
// ================================================================
387+
// 流式 POST 请求辅助函数(SSE 解析)
388+
// ================================================================
389+
390+
/**
391+
* 流式 POST 请求:解析 SSE data: 行,逐 chunk yield 文本
392+
*
393+
* @param apiPath API 路径(如 /api/v1/ai/summarize/stream)
394+
* @param body 请求体
395+
* @param authToken 认证 token
396+
* @param userApiKey 用户 API Key
397+
* @param timeoutMs 超时时间
398+
*/
399+
export async function* postJsonStream<TReq>(
400+
apiPath: string,
401+
body: TReq,
402+
authToken?: string,
403+
userApiKey?: string,
404+
timeoutMs: number = 300000,
405+
): AsyncGenerator<string, void, unknown> {
406+
const base = gatewayUrl();
407+
if (!base) {
408+
throw new Error('[AI] Gateway URL not configured');
409+
}
410+
const url = `${base}${apiPath}`;
411+
const clientRequestId = randomUUID();
412+
413+
logger.info(`[AI] → POST (stream) ${url} [req-id: ${clientRequestId}]`);
414+
415+
const headers: Record<string, string> = {
416+
'Content-Type': 'application/json',
417+
'X-Request-ID': clientRequestId,
418+
};
419+
if (authToken) {
420+
headers['Authorization'] = `Bearer ${authToken}`;
421+
}
422+
if (userApiKey) {
423+
headers['X-User-API-Key'] = userApiKey;
424+
}
425+
426+
const controller = new AbortController();
427+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
428+
429+
let resp: Response;
430+
try {
431+
resp = await fetch(url, {
432+
method: 'POST',
433+
headers,
434+
body: JSON.stringify(body),
435+
signal: controller.signal,
436+
});
437+
} catch (networkError: unknown) {
438+
const err = networkError as { name?: string; message?: string };
439+
if (err.name === 'AbortError') {
440+
throw new Error(`Stream request timeout after ${timeoutMs}ms`);
441+
}
442+
throw new Error(`Stream network error: ${err.message || String(networkError)}`);
443+
} finally {
444+
clearTimeout(timeoutId);
445+
}
446+
447+
const requestId = resp.headers.get('ai-gateway-request-id') ?? undefined;
448+
449+
if (!resp.ok) {
450+
const detail = await resp.text().catch(() => 'unknown error');
451+
logger.error(`[AI] ✖ Stream HTTP ${resp.status} ${url} [req-id: ${requestId ?? clientRequestId}]: ${detail.slice(0, 200)}`);
452+
throw new Error(`Stream HTTP ${resp.status}: ${detail}`);
453+
}
454+
455+
logger.info(`[AI] ← Stream started ${url}${requestId ? ` [req-id: ${requestId}]` : ''}`);
456+
457+
if (!resp.body) {
458+
throw new Error('Stream response body is null');
459+
}
460+
461+
const reader = resp.body.getReader();
462+
const decoder = new TextDecoder();
463+
let buffer = '';
464+
465+
try {
466+
while (true) {
467+
const { done, value } = await reader.read();
468+
if (done) break;
469+
470+
buffer += decoder.decode(value, { stream: true });
471+
472+
// 按 \n\n 分割 SSE 事件
473+
const events = buffer.split('\n\n');
474+
buffer = events.pop() || '';
475+
476+
for (const event of events) {
477+
const lines = event.split('\n');
478+
for (const line of lines) {
479+
if (line.startsWith('data: ')) {
480+
const data = line.slice(6).trim();
481+
if (data === '[DONE]') {
482+
return;
483+
}
484+
try {
485+
const parsed = JSON.parse(data);
486+
if (parsed.error) {
487+
throw new Error(`Stream error: ${parsed.error}`);
488+
}
489+
if (parsed.chunk) {
490+
yield parsed.chunk;
491+
}
492+
} catch (e) {
493+
// JSON 解析失败,尝试作为纯文本
494+
if (data && data !== '[DONE]') {
495+
yield data;
496+
}
497+
}
498+
}
499+
}
500+
}
501+
}
502+
} finally {
503+
reader.releaseLock();
504+
}
505+
}

‎client/electron/db/schema.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import type Database from 'better-sqlite3';
66

7-
export const SCHEMA_VERSION = 1;
7+
export const SCHEMA_VERSION = 4;
88

99
export const SCHEMA_DDL = /* sql */ `
1010
CREATE TABLE IF NOT EXISTS pomodoro_sessions (
@@ -146,6 +146,7 @@ CREATE TABLE IF NOT EXISTS search_index (
146146
id INTEGER PRIMARY KEY AUTOINCREMENT, note_id TEXT NOT NULL,
147147
tokens TEXT NOT NULL DEFAULT '[]', title TEXT NOT NULL DEFAULT '',
148148
content TEXT NOT NULL DEFAULT '', updated_at REAL NOT NULL DEFAULT 0,
149+
entity_id TEXT, entity_type TEXT,
149150
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
150151
);
151152
-- 高频查询列索引
@@ -158,8 +159,54 @@ CREATE INDEX IF NOT EXISTS idx_predictions_note_id ON predictions(note_id);
158159
CREATE INDEX IF NOT EXISTS idx_predictions_created_at ON predictions(created_at);
159160
`;
160161

162+
/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
163+
export const SCHEMA_V3_DDL = /* sql */ `
164+
CREATE TABLE IF NOT EXISTS crdt_docs (
165+
table_name TEXT PRIMARY KEY,
166+
snapshot TEXT NOT NULL DEFAULT '',
167+
last_heads TEXT NOT NULL DEFAULT '',
168+
updated_at TEXT NOT NULL
169+
);
170+
CREATE TABLE IF NOT EXISTS crdt_changes (
171+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
172+
table_name TEXT NOT NULL,
173+
entity_id TEXT NOT NULL,
174+
changeset TEXT NOT NULL,
175+
operation TEXT NOT NULL CHECK (operation IN ('create','update','delete')),
176+
created_at TEXT NOT NULL
177+
);
178+
CREATE INDEX IF NOT EXISTS idx_crdt_changes_table_name ON crdt_changes(table_name);
179+
`;
180+
161181
/** 执行 DDL 并设置 PRAGMA user_version。幂等调用(CREATE IF NOT EXISTS)。 */
162182
export function initializeSchema(db: Database.Database): void {
163183
db.exec(SCHEMA_DDL);
184+
185+
// v2 迁移:FSRS-5 扩展字段(条件 ALTER TABLE,幂等)
186+
const currentVersion = db.pragma('user_version', { simple: true }) as number;
187+
if (currentVersion < 2) {
188+
try {
189+
db.exec(`ALTER TABLE flashcards ADD COLUMN stability REAL DEFAULT NULL`);
190+
} catch { /* 列已存在 */ }
191+
try {
192+
db.exec(`ALTER TABLE flashcards ADD COLUMN difficulty REAL DEFAULT NULL`);
193+
} catch { /* 列已存在 */ }
194+
}
195+
196+
// v3 迁移:CRDT 同步引擎元数据表
197+
if (currentVersion < 3) {
198+
db.exec(SCHEMA_V3_DDL);
199+
}
200+
201+
// v4 迁移:search_index 表增加 entity_id 和 entity_type 列
202+
if (currentVersion < 4) {
203+
try {
204+
db.exec(`ALTER TABLE search_index ADD COLUMN entity_id TEXT`);
205+
} catch { /* 列已存在 */ }
206+
try {
207+
db.exec(`ALTER TABLE search_index ADD COLUMN entity_type TEXT`);
208+
} catch { /* 列已存在 */ }
209+
}
210+
164211
db.pragma(`user_version = ${SCHEMA_VERSION}`);
165212
}

‎client/electron/preload.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ const ALLOWED_CHANNELS = [
8181
'ollama:set-config',
8282
'ollama:pull-model',
8383
'ollama:delete-model',
84+
// AI 流式输出 IPC channel
85+
'ai:stream:start',
86+
'ai:stream:cancel',
8487
] as const;
8588

8689
/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
@@ -101,6 +104,10 @@ const ALLOWED_EVENT_CHANNELS = [
101104
'video_record_do_stop',
102105
// Ollama 模型拉取进度推送
103106
'ollama:pull-progress',
107+
// AI 流式输出推送
108+
'ai:stream:chunk',
109+
'ai:stream:end',
110+
'ai:stream:error',
104111
] as const;
105112

106113
/** 允许渲染进程单向发送的 channel 白名单(渲染进程 → 主进程,fire-and-forget) */

0 commit comments

Comments
 (0)