diff --git a/package.json b/package.json index 647f17b1..7fc24980 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never", "build:linux": "pnpm run build && electron-builder --linux --config electron-builder.yml -p never", "dev:app": "vite --config vite.web.config.mts", - "dev:serve": "tsx packages/server/src/cli.ts serve", + "dev:serve": "tsx watch packages/server/src/cli.ts serve", "dev:web": "CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts", "build:web": "vite build --config vite.web.config.mts", "type-check:web": "vue-tsc --noEmit -p tsconfig.web.json", diff --git a/packages/server/src/http/routes/web.ts b/packages/server/src/http/routes/web.ts index 418ee3bf..48584bd5 100644 --- a/packages/server/src/http/routes/web.ts +++ b/packages/server/src/http/routes/web.ts @@ -41,6 +41,15 @@ function ensureDb(dbManager: DatabaseManager, sessionId: string) { return db } +function ensureWritableDb(dbManager: DatabaseManager, sessionId: string) { + dbManager.close(sessionId) + const db = dbManager.open(sessionId, { readonly: false }) + if (!db) { + throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 }) + } + return db +} + function parseTimeFilter(query: Record): TimeFilter | undefined { const { startTs, endTs, memberId } = query if (!startTs && !endTs && !memberId) return undefined @@ -406,6 +415,61 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa return stmt.all(params) }) + // ==================== 会话索引 ==================== + + server.post<{ + Params: { id: string } + Body: { gapThreshold?: number } + }>('/_web/sessions/:id/generate-index', async (request) => { + const db = ensureWritableDb(dbManager, request.params.id) + const gapThreshold = (request.body as any)?.gapThreshold ?? 1800 + + const messages = db.prepare('SELECT id, ts FROM message ORDER BY ts ASC').all() as Array<{ id: number; ts: number }> + + if (messages.length === 0) return { sessionCount: 0 } + + type SessionBound = { startTs: number; endTs: number; msgIds: number[] } + const sessions: SessionBound[] = [] + let current: SessionBound = { startTs: messages[0].ts, endTs: messages[0].ts, msgIds: [messages[0].id] } + + for (let i = 1; i < messages.length; i++) { + const gap = messages[i].ts - messages[i - 1].ts + if (gap > gapThreshold) { + sessions.push(current) + current = { startTs: messages[i].ts, endTs: messages[i].ts, msgIds: [messages[i].id] } + } else { + current.endTs = messages[i].ts + current.msgIds.push(messages[i].id) + } + } + sessions.push(current) + + const insertSession = db.prepare('INSERT INTO chat_session (start_ts, end_ts, message_count) VALUES (?, ?, ?)') + const insertCtx = db.prepare('INSERT OR REPLACE INTO message_context (message_id, session_id) VALUES (?, ?)') + + db.transaction(() => { + db.prepare('DELETE FROM chat_session').run() + db.prepare('DELETE FROM message_context').run() + + for (const s of sessions) { + const info = insertSession.run(s.startTs, s.endTs, s.msgIds.length) + const rowId = info.lastInsertRowid + for (const msgId of s.msgIds) { + insertCtx.run(msgId, rowId) + } + } + }) + + return { sessionCount: sessions.length } + }) + + server.post<{ Params: { id: string } }>('/_web/sessions/:id/clear-index', async (request) => { + const db = ensureWritableDb(dbManager, request.params.id) + db.prepare('DELETE FROM chat_session').run() + db.prepare('DELETE FROM message_context').run() + return { success: true } + }) + // ==================== 导入管线 ==================== server.get('/_web/supported-formats', async () => { diff --git a/src/adapters/web-api-shim.ts b/src/adapters/web-api-shim.ts index 483e2e01..6398c5be 100644 --- a/src/adapters/web-api-shim.ts +++ b/src/adapters/web-api-shim.ts @@ -175,8 +175,105 @@ async function getAllRecentMessages( return { messages: messages.reverse(), total } } +// ==================== window.sessionApi 垫片 ==================== + +async function sessionGetStats( + adapter: QueryAdapter, + sessionId: string +): Promise<{ hasIndex: boolean; sessionCount: number; gapThreshold: number }> { + try { + const rows = await pq<{ cnt: number }>(adapter, sessionId, 'SELECT COUNT(*) as cnt FROM chat_session', []) + const count = rows[0]?.cnt ?? 0 + return { hasIndex: count > 0, sessionCount: count, gapThreshold: 1800 } + } catch { + return { hasIndex: false, sessionCount: 0, gapThreshold: 1800 } + } +} + +async function sessionGenerate(adapter: QueryAdapter, sessionId: string, gapThreshold: number = 1800): Promise { + const messages = await pq<{ id: number; ts: number }>( + adapter, + sessionId, + 'SELECT id, ts FROM message ORDER BY ts ASC', + [] + ) + + if (messages.length === 0) return 0 + + type Session = { startTs: number; endTs: number; count: number } + const sessions: Session[] = [] + let current: Session = { startTs: messages[0].ts, endTs: messages[0].ts, count: 1 } + + for (let i = 1; i < messages.length; i++) { + const gap = messages[i].ts - messages[i - 1].ts + if (gap > gapThreshold) { + sessions.push(current) + current = { startTs: messages[i].ts, endTs: messages[i].ts, count: 1 } + } else { + current.endTs = messages[i].ts + current.count++ + } + } + sessions.push(current) + + // 写入数据库: 先清空再插入(通过后端 pluginQuery 只支持只读,需要后端端点) + // 这里通过 FetchAdapter 调用后端的 generate 端点 + const resp = await fetch(`/_web/sessions/${sessionId}/generate-index`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gapThreshold }), + }) + if (!resp.ok) throw new Error(`Failed to generate session index: ${resp.status}`) + const result = (await resp.json()) as { sessionCount: number } + return result.sessionCount +} + +async function sessionGetSessions( + adapter: QueryAdapter, + sessionId: string +): Promise> { + return pq( + adapter, + sessionId, + 'SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary FROM chat_session ORDER BY start_ts ASC', + [] + ) +} + +async function sessionHasIndex(adapter: QueryAdapter, sessionId: string): Promise { + const stats = await sessionGetStats(adapter, sessionId) + return stats.hasIndex +} + +async function sessionGetByTimeRange( + adapter: QueryAdapter, + sessionId: string, + startTs: number, + endTs: number +): Promise> { + return pq( + adapter, + sessionId, + 'SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary FROM chat_session WHERE start_ts >= ? AND end_ts <= ? ORDER BY start_ts ASC', + [startTs, endTs] + ) +} + +async function sessionGetRecent( + adapter: QueryAdapter, + sessionId: string, + limit: number +): Promise> { + return pq( + adapter, + sessionId, + 'SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary FROM chat_session ORDER BY start_ts DESC LIMIT ?', + [limit] + ) +} + /** - * 注入 window.chatApi 和 window.aiApi 垫片 + * 注入 window.chatApi / window.aiApi / window.sessionApi 垫片 */ export function installWebApiShims(adapter: QueryAdapter): void { if (!window.chatApi) { @@ -227,4 +324,25 @@ export function installWebApiShims(adapter: QueryAdapter): void { ) => searchMessages(adapter, sid, kw, filter, limit, offset, senderId) aiApi.getAllRecentMessages = (sid: string, filter?: any, limit?: number) => getAllRecentMessages(adapter, sid, filter, limit) + + // ===== window.sessionApi 垫片 ===== + if (!(window as any).sessionApi) { + ;(window as any).sessionApi = {} + } + const sessionApi = (window as any).sessionApi + sessionApi.getStats = (sid: string) => sessionGetStats(adapter, sid) + sessionApi.generate = (sid: string, gap?: number) => sessionGenerate(adapter, sid, gap) + sessionApi.hasIndex = (sid: string) => sessionHasIndex(adapter, sid) + sessionApi.clear = async (sid: string) => { + await fetch(`/_web/sessions/${sid}/clear-index`, { method: 'POST' }) + return true + } + sessionApi.getSessions = (sid: string) => sessionGetSessions(adapter, sid) + sessionApi.getByTimeRange = (sid: string, start: number, end: number) => + sessionGetByTimeRange(adapter, sid, start, end) + sessionApi.getRecent = (sid: string, limit: number) => sessionGetRecent(adapter, sid, limit) + sessionApi.updateGapThreshold = async () => true + sessionApi.generateSummary = async () => ({ success: false, error: 'Not available in web mode' }) + sessionApi.generateSummaries = async () => ({ success: 0, failed: 0, skipped: 0 }) + sessionApi.checkCanGenerateSummary = async () => ({}) } diff --git a/src/pages/home/components/ChangelogModal.vue b/src/pages/home/components/ChangelogModal.vue index 46a94ef9..2073de13 100644 --- a/src/pages/home/components/ChangelogModal.vue +++ b/src/pages/home/components/ChangelogModal.vue @@ -1,7 +1,7 @@