Skip to content

Commit d01c6de

Browse files
committed
Merge branch 'dev'
2 parents 5aee98c + 2df5044 commit d01c6de

47 files changed

Lines changed: 4363 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* AI 学伴对话 Handler
3+
*
4+
* 处理 ai:chat:* IPC 请求:发送消息(流式)、历史加载、会话管理。
5+
* 流式输出复用 streamHandler 的 ai:stream:* 事件体系。
6+
*
7+
* @ai-context: 学伴对话 IPC handler——非 AIFeatureDef 模式(多通道),
8+
* 直接 safeHandle 注册;流式走 postJsonStream + sender.send 回推;
9+
* 用户消息与助手回复均持久化到 SQLite(chatRepository)。
10+
*/
11+
import { safeHandle } from '../../ipcUtils.js';
12+
import { logger } from '../../logger.js';
13+
import { postJsonStream } from '../gatewayStream.js';
14+
import * as chatRepo from '../../db/chatRepository.js';
15+
16+
/** IPC chunk 推送节流间隔(ms),防止高频 chunk 风暴 */
17+
const CHUNK_THROTTLE_MS = 50;
18+
19+
export function registerChatHandlers(): void {
20+
// ── ai:chat:send — 流式对话 ──────────────────────────────────
21+
safeHandle(
22+
'ai:chat:send',
23+
async (
24+
event,
25+
args: {
26+
requestId: string;
27+
sessionId: string;
28+
message: string;
29+
history: Array<{ role: string; content: string }>;
30+
scene: string;
31+
authToken?: string;
32+
userApiKey?: string;
33+
},
34+
) => {
35+
const { requestId, sessionId, message, history, scene, authToken, userApiKey } = args;
36+
const sender = event.sender;
37+
const startMs = Date.now();
38+
39+
logger.info(`[AI] [chat] Send: reqId=${requestId}, session=${sessionId}, msg_len=${message.length}`);
40+
41+
// 持久化用户消息
42+
chatRepo.insertMessage({
43+
session_id: sessionId,
44+
role: 'user',
45+
content: message,
46+
content_type: 'text',
47+
trigger_type: null,
48+
tokens_used: null,
49+
model: null,
50+
latency_ms: null,
51+
});
52+
chatRepo.touchSession(sessionId);
53+
54+
const payload = {
55+
message,
56+
history: history.slice(-40),
57+
system_context: { personality: 'dynamic_adaptive', scene },
58+
};
59+
60+
let fullResponse = '';
61+
let chunkBuffer = '';
62+
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
63+
64+
/** 将缓冲区内的 chunk 一次性推送到渲染进程 */
65+
function flush(): void {
66+
if (!chunkBuffer || sender.isDestroyed()) return;
67+
sender.send('ai:stream:chunk', { requestId, chunk: chunkBuffer });
68+
chunkBuffer = '';
69+
throttleTimer = null;
70+
}
71+
72+
try {
73+
const stream = postJsonStream('/api/v1/ai/chat/stream', payload, authToken, userApiKey);
74+
75+
for await (const chunk of stream) {
76+
fullResponse += chunk;
77+
chunkBuffer += chunk;
78+
if (!throttleTimer) {
79+
throttleTimer = setTimeout(flush, CHUNK_THROTTLE_MS);
80+
}
81+
}
82+
83+
// 流结束:刷出剩余缓冲
84+
if (throttleTimer) {
85+
clearTimeout(throttleTimer);
86+
throttleTimer = null;
87+
}
88+
flush();
89+
90+
const latencyMs = Date.now() - startMs;
91+
92+
// 持久化助手回复
93+
chatRepo.insertMessage({
94+
session_id: sessionId,
95+
role: 'assistant',
96+
content: fullResponse,
97+
content_type: 'text',
98+
trigger_type: null,
99+
tokens_used: null,
100+
model: null,
101+
latency_ms: latencyMs,
102+
});
103+
chatRepo.touchSession(sessionId);
104+
105+
if (!sender.isDestroyed()) {
106+
sender.send('ai:stream:end', { requestId });
107+
}
108+
109+
logger.info(`[AI] [chat] Complete: ${latencyMs}ms, resp_len=${fullResponse.length}`);
110+
return { ok: true, requestId };
111+
} catch (err) {
112+
if (throttleTimer) {
113+
clearTimeout(throttleTimer);
114+
throttleTimer = null;
115+
}
116+
flush();
117+
118+
const errorMsg = err instanceof Error ? err.message : String(err);
119+
logger.error(`[AI] [chat] Error: reqId=${requestId}, ${errorMsg}`);
120+
121+
if (!sender.isDestroyed()) {
122+
sender.send('ai:stream:error', { requestId, error: errorMsg });
123+
}
124+
125+
return { ok: false, requestId, error: errorMsg };
126+
}
127+
},
128+
);
129+
130+
// ── ai:chat:history — 加载历史消息 ───────────────────────────
131+
safeHandle(
132+
'ai:chat:history',
133+
async (_event, args: { sessionId: string; limit?: number; before?: number }) => {
134+
const rows = chatRepo.getMessages(args.sessionId, args.limit ?? 50, args.before);
135+
return rows.reverse();
136+
},
137+
);
138+
139+
// ── ai:chat:sessions — 获取最新会话 ──────────────────────────
140+
safeHandle('ai:chat:sessions', async () => {
141+
const session = chatRepo.getLatestSession();
142+
return session ? [session] : [];
143+
});
144+
145+
// ── ai:chat:new-session — 创建新会话 ─────────────────────────
146+
safeHandle(
147+
'ai:chat:new-session',
148+
async (_event, args?: { title?: string }) => {
149+
return chatRepo.createSession(args?.title);
150+
},
151+
);
152+
}

‎client/electron/ai/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { logger } from '../logger.js';
1111
import type { AIFeatureDef } from './utils.js';
1212
import { registerOllamaHandlers, initOllama } from './ollama/index.js';
1313
import { registerStreamHandler } from './streamHandler.js';
14+
import { registerChatHandlers } from './handlers/chatHandler.js';
1415
import { registerLocalAsrHandlers } from './local-asr/index.js';
1516
import { loadLocalAsrConfig } from './local-asr/config.js';
1617

@@ -81,6 +82,9 @@ export function registerAIHandlers(): void {
8182
// 注册流式输出 IPC handler
8283
registerStreamHandler();
8384

85+
// 注册学伴对话 IPC handler
86+
registerChatHandlers();
87+
8488
logger.info('[AI] All AI handlers registered successfully');
8589
}
8690

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* AI 助手对话持久化 — SQLite CRUD
3+
*
4+
* @ai-context: 助手会话/消息/触发记录的数据库访问层;
5+
* 依赖 sqliteService.getConnection() 获取连接,表 DDL 在 schema.ts 中定义。
6+
* 纯数据访问,无业务逻辑——业务编排在渲染进程 hooks 中完成。
7+
*/
8+
import { randomUUID } from 'crypto';
9+
import { getConnection } from './sqliteService.js';
10+
import { logger } from '../logger.js';
11+
12+
// ── 类型(主进程侧,与渲染进程 types.ts 结构对齐) ──────────
13+
14+
export interface SessionRow {
15+
id: string;
16+
title: string;
17+
created_at: number;
18+
updated_at: number;
19+
is_archived: number;
20+
metadata: string | null;
21+
}
22+
23+
export interface MessageRow {
24+
id: string;
25+
session_id: string;
26+
role: 'user' | 'assistant' | 'system';
27+
content: string;
28+
content_type: string;
29+
trigger_type: string | null;
30+
tokens_used: number | null;
31+
model: string | null;
32+
latency_ms: number | null;
33+
created_at: number;
34+
}
35+
36+
// ── 会话 CRUD ─────────────────────────────────────────────────
37+
38+
export function createSession(title = '新对话'): SessionRow {
39+
const db = getConnection();
40+
const now = Date.now();
41+
const row: SessionRow = { id: randomUUID(), title, created_at: now, updated_at: now, is_archived: 0, metadata: null };
42+
db.prepare('INSERT INTO assistant_sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)')
43+
.run(row.id, row.title, row.created_at, row.updated_at);
44+
logger.info(`[ChatRepo] Session created: ${row.id}`);
45+
return row;
46+
}
47+
48+
export function getLatestSession(): SessionRow | null {
49+
const db = getConnection();
50+
return db.prepare('SELECT * FROM assistant_sessions WHERE is_archived = 0 ORDER BY updated_at DESC LIMIT 1').get() as SessionRow | undefined ?? null;
51+
}
52+
53+
export function touchSession(id: string): void {
54+
const db = getConnection();
55+
db.prepare('UPDATE assistant_sessions SET updated_at = ? WHERE id = ?').run(Date.now(), id);
56+
}
57+
58+
export function updateSessionTitle(id: string, title: string): void {
59+
const db = getConnection();
60+
db.prepare('UPDATE assistant_sessions SET title = ? WHERE id = ?').run(title, id);
61+
}
62+
63+
// ── 消息 CRUD ─────────────────────────────────────────────────
64+
65+
export function insertMessage(msg: Omit<MessageRow, 'id' | 'created_at'> & { id?: string; created_at?: number }): MessageRow {
66+
const db = getConnection();
67+
const row: MessageRow = {
68+
id: msg.id ?? randomUUID(),
69+
created_at: msg.created_at ?? Date.now(),
70+
session_id: msg.session_id,
71+
role: msg.role,
72+
content: msg.content,
73+
content_type: msg.content_type,
74+
trigger_type: msg.trigger_type,
75+
tokens_used: msg.tokens_used,
76+
model: msg.model,
77+
latency_ms: msg.latency_ms,
78+
};
79+
db.prepare(`INSERT INTO assistant_messages (id, session_id, role, content, content_type, trigger_type, tokens_used, model, latency_ms, created_at)
80+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
81+
.run(row.id, row.session_id, row.role, row.content, row.content_type, row.trigger_type, row.tokens_used, row.model, row.latency_ms, row.created_at);
82+
return row;
83+
}
84+
85+
export function getMessages(sessionId: string, limit = 50, before?: number): MessageRow[] {
86+
const db = getConnection();
87+
if (before) {
88+
return db.prepare('SELECT * FROM assistant_messages WHERE session_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?')
89+
.all(sessionId, before, limit) as MessageRow[];
90+
}
91+
return db.prepare('SELECT * FROM assistant_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?')
92+
.all(sessionId, limit) as MessageRow[];
93+
}
94+
95+
// ── 触发记录 ──────────────────────────────────────────────────
96+
97+
export function insertTrigger(ruleId: string): string {
98+
const db = getConnection();
99+
const id = randomUUID();
100+
db.prepare('INSERT INTO assistant_triggers (id, rule_id, triggered_at) VALUES (?, ?, ?)').run(id, ruleId, Date.now());
101+
return id;
102+
}
103+
104+
export function getLastTriggerTime(ruleId: string): number | null {
105+
const db = getConnection();
106+
const row = db.prepare('SELECT triggered_at FROM assistant_triggers WHERE rule_id = ? ORDER BY triggered_at DESC LIMIT 1').get(ruleId) as { triggered_at: number } | undefined;
107+
return row?.triggered_at ?? null;
108+
}
109+
110+
export function getRecentTriggerCount(sinceMs: number): number {
111+
const db = getConnection();
112+
const row = db.prepare('SELECT COUNT(*) as cnt FROM assistant_triggers WHERE triggered_at > ?').get(Date.now() - sinceMs) as { cnt: number };
113+
return row.cnt;
114+
}
115+
116+
export function getConsecutiveIgnores(): number {
117+
const db = getConnection();
118+
const rows = db.prepare('SELECT dismissed, responded FROM assistant_triggers ORDER BY triggered_at DESC LIMIT ?').all(10) as Array<{ dismissed: number; responded: number }>;
119+
let count = 0;
120+
for (const r of rows) {
121+
if (r.dismissed && !r.responded) count++;
122+
else break;
123+
}
124+
return count;
125+
}
126+
127+
export function markTriggerResponded(id: string): void {
128+
const db = getConnection();
129+
db.prepare('UPDATE assistant_triggers SET responded = 1 WHERE id = ?').run(id);
130+
}
131+
132+
export function markTriggerDismissed(id: string): void {
133+
const db = getConnection();
134+
db.prepare('UPDATE assistant_triggers SET dismissed = 1 WHERE id = ?').run(id);
135+
}

‎client/electron/db/schema.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import type Database from 'better-sqlite3';
88

9-
export const SCHEMA_VERSION = 4;
9+
export const SCHEMA_VERSION = 5;
1010

1111
export const SCHEMA_DDL = /* sql */ `
1212
CREATE TABLE IF NOT EXISTS pomodoro_sessions (
@@ -159,6 +159,27 @@ CREATE INDEX IF NOT EXISTS idx_flashcard_reviews_reviewed_at ON flashcard_review
159159
CREATE INDEX IF NOT EXISTS idx_feynman_notes_created_at ON feynman_notes(created_at);
160160
CREATE INDEX IF NOT EXISTS idx_predictions_note_id ON predictions(note_id);
161161
CREATE INDEX IF NOT EXISTS idx_predictions_created_at ON predictions(created_at);
162+
CREATE TABLE IF NOT EXISTS assistant_sessions (
163+
id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '新对话',
164+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
165+
is_archived INTEGER NOT NULL DEFAULT 0, metadata TEXT
166+
);
167+
CREATE TABLE IF NOT EXISTS assistant_messages (
168+
id TEXT PRIMARY KEY,
169+
session_id TEXT NOT NULL REFERENCES assistant_sessions(id) ON DELETE CASCADE,
170+
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
171+
content TEXT NOT NULL, content_type TEXT NOT NULL DEFAULT 'text',
172+
trigger_type TEXT, tokens_used INTEGER, model TEXT, latency_ms INTEGER,
173+
created_at INTEGER NOT NULL
174+
);
175+
CREATE TABLE IF NOT EXISTS assistant_triggers (
176+
id TEXT PRIMARY KEY, rule_id TEXT NOT NULL,
177+
triggered_at INTEGER NOT NULL, dismissed INTEGER NOT NULL DEFAULT 0,
178+
responded INTEGER NOT NULL DEFAULT 0
179+
);
180+
CREATE INDEX IF NOT EXISTS idx_asst_msg_session ON assistant_messages(session_id, created_at);
181+
CREATE INDEX IF NOT EXISTS idx_asst_sess_active ON assistant_sessions(is_archived, updated_at DESC);
182+
CREATE INDEX IF NOT EXISTS idx_asst_trig_rule ON assistant_triggers(rule_id, triggered_at);
162183
`;
163184

164185
/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
@@ -210,5 +231,8 @@ export function initializeSchema(db: Database.Database): void {
210231
} catch { /* 列已存在 */ }
211232
}
212233

234+
// v5 迁移:AI 助手会话/消息/触发表(CREATE IF NOT EXISTS 幂等)
235+
// 表 DDL 已包含在 SCHEMA_DDL 中,此处无需额外操作
236+
213237
db.pragma(`user_version = ${SCHEMA_VERSION}`);
214238
}

‎client/electron/ipc/channels.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export const IPC_CHANNELS = {
2424
AI_PREDICT: 'ai_predict',
2525
AI_RESCUE: 'ai_rescue',
2626
AI_SET_GATEWAY_URL: 'ai:set-gateway-url',
27+
AI_CHAT_SEND: 'ai:chat:send',
28+
AI_CHAT_HISTORY: 'ai:chat:history',
29+
AI_CHAT_SESSIONS: 'ai:chat:sessions',
30+
AI_CHAT_NEW_SESSION: 'ai:chat:new-session',
2731

2832
// 采集相关
2933
SCREEN_LIST_WINDOWS: 'screen_list_windows',

‎client/electron/preload.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ const ALLOWED_CHANNELS = [
3131
'ai_video_analyze',
3232
'ai_merge_notes',
3333
'ai:set-gateway-url',
34+
// AI 学伴对话 IPC channel
35+
'ai:chat:send',
36+
'ai:chat:history',
37+
'ai:chat:sessions',
38+
'ai:chat:new-session',
3439
'screen_list_windows',
3540
'screen_watch_windows_start',
3641
'screen_watch_windows_stop',
@@ -93,6 +98,14 @@ const ALLOWED_CHANNELS = [
9398
'performance:set-mode',
9499
// P3-18 性能诊断 IPC channel
95100
'perf:get-metrics',
101+
// 本地 ASR(sherpa-onnx)IPC channel
102+
'local_asr_transcribe',
103+
'local_asr_get_config',
104+
'local_asr_update_config',
105+
'local_asr_check_available',
106+
'local_asr_get_models',
107+
'local_asr_download_model',
108+
'local_asr_delete_model',
96109
] as const;
97110

98111
/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
@@ -117,6 +130,8 @@ const ALLOWED_EVENT_CHANNELS = [
117130
'ai:stream:chunk',
118131
'ai:stream:end',
119132
'ai:stream:error',
133+
// 本地 ASR 模型下载进度推送
134+
'local_asr_download_progress',
120135
] as const;
121136

122137
/** 允许渲染进程单向发送的 channel 白名单(渲染进程 → 主进程,fire-and-forget) */
32 Bytes
Binary file not shown.
32 Bytes
Binary file not shown.
32 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)