refactor(core): extract remaining inline SQL from electron adapters

- Extract getPrivateChatMemberAvatar to @openchatlab/core
- Extract getExportSessionData (meta/members/messages SQL) to @openchatlab/core
- Extract loadSessionMessages for summary generation to @openchatlab/core
- Replace inline SQL in summary buildDeps with shared core query functions
- Extract createCompressionLlmAdapter factory to @openchatlab/node-runtime
- Simplify server and electron compression adapter creation via shared factory
This commit is contained in:
digua committed 2026-05-18 23:36:42 +08:00
1 parent 53fa23f488
commit 31359712b3
11 files changed
+246 -173

No files matched your search

+5 -17
View File
@@ -7,8 +7,10 @@
import Database from 'better-sqlite3'
import { completeSimple, type PiTextContent } from '@openchatlab/node-runtime'
import { loadSessionMessages, getChatSessionSummary, saveChatSessionSummary } from '@openchatlab/core'
import { getFastModelConfig, buildPiModel } from '../llm'
import { getDbPath, openDatabase } from '../../database/core'
import { wrapAsDatabaseAdapter } from '../../worker/core'
import { aiLogger } from '../logger'
import { t } from '../../i18n'
import {
@@ -24,18 +26,7 @@ function buildDeps(dbSessionId: string): SummaryDeps {
const db = openDatabase(dbSessionId, true)
if (!db) return null
try {
const sql = `
SELECT
COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName,
m.content
FROM message_context mc
JOIN message m ON m.id = mc.message_id
JOIN member mb ON mb.id = m.sender_id
WHERE mc.session_id = ?
ORDER BY m.ts ASC
LIMIT ?
`
return db.prepare(sql).all(chatSessionId, limit) as Array<{ senderName: string; content: string | null }>
return loadSessionMessages(wrapAsDatabaseAdapter(db), chatSessionId, limit)
} catch (error) {
aiLogger.error('Summary', `Failed to get session messages: ${error}`)
return null
@@ -46,7 +37,7 @@ function buildDeps(dbSessionId: string): SummaryDeps {
const dbPath = getDbPath(dbSessionId)
const db = new Database(dbPath)
try {
db.prepare('UPDATE chat_session SET summary = ? WHERE id = ?').run(summary, chatSessionId)
saveChatSessionSummary(wrapAsDatabaseAdapter(db), chatSessionId, summary)
} finally {
db.close()
}
@@ -56,10 +47,7 @@ function buildDeps(dbSessionId: string): SummaryDeps {
const db = openDatabase(dbSessionId, true)
if (!db) return null
try {
const row = db.prepare('SELECT summary FROM chat_session WHERE id = ?').get(chatSessionId) as
| { summary: string | null }
| undefined
return row?.summary || null
return getChatSessionSummary(wrapAsDatabaseAdapter(db), chatSessionId)
} catch {
return null
}
+11 -33
View File
@@ -20,6 +20,7 @@ import * as skillManager from '../ai/skills'
import {
checkAndCompress,
manualCompress,
createCompressionLlmAdapter,
type CompressionConfig,
type CompressionLlmAdapter,
completeSimple,
@@ -43,38 +44,15 @@ const compressionLogger = {
error: (cat: string, msg: string, extra?: Record<string, unknown>) => aiLogger.error(cat, msg, extra),
}
function createCompressionLlmAdapter(
activeAIConfig: AIServiceConfig,
onCompressing?: () => void
): CompressionLlmAdapter {
function buildCompressionAdapter(activeAIConfig: AIServiceConfig, onCompressing?: () => void): CompressionLlmAdapter {
const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '')
const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
const piModel = buildPiModel(activeAIConfig)
return {
contextWindow,
compress: async (prompt: string, maxTokens: number) => {
onCompressing?.()
try {
const result = await completeSimple(
piModel,
{
systemPrompt: undefined,
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
},
{ apiKey: activeAIConfig.apiKey, maxTokens }
)
const text = result.content
.filter((item): item is PiTextContent => item.type === 'text')
.map((item) => item.text)
.join('')
return text || null
} catch (error) {
aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) })
return null
}
},
}
return createCompressionLlmAdapter({
piModel: buildPiModel(activeAIConfig),
apiKey: activeAIConfig.apiKey,
contextWindow: modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
onCompressing,
onError: (error) => aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) }),
})
}
function toPiSimpleMessages(messages: Array<{ role: string; content: string }>, timestamp: number): PiMessage[] {
@@ -993,7 +971,7 @@ export function registerAIHandlers({ win }: IpcContext): void {
context.conversationId,
compressionConfig,
systemPromptForCompression,
createCompressionLlmAdapter(activeAIConfig, () => {
buildCompressionAdapter(activeAIConfig, () => {
win.webContents.send('agent:streamChunk', {
requestId,
chunk: {
@@ -1250,7 +1228,7 @@ export function registerAIHandlers({ win }: IpcContext): void {
conversationId,
compressionConfig,
systemPrompt,
createCompressionLlmAdapter(activeAIConfig),
buildCompressionAdapter(activeAIConfig),
getConversationManager(),
compressionLogger
)
+13 -59
View File
@@ -181,6 +181,8 @@ export async function mergeFilesWithTempDb(
import Database from 'better-sqlite3'
import { getDbPath } from '../database/core'
import { getExportSessionData } from '@openchatlab/core'
import { wrapAsDatabaseAdapter } from '../worker/core'
export async function exportSessionToTempFile(sessionId: string): Promise<string> {
const dbPath = getDbPath(sessionId)
@@ -191,74 +193,26 @@ export async function exportSessionToTempFile(sessionId: string): Promise<string
const db = new Database(dbPath, { readonly: true })
try {
const meta = db.prepare('SELECT * FROM meta').get() as {
name: string
platform: string
type: string
group_id?: string
group_avatar?: string
}
if (!meta) {
throw new Error('Cannot read session meta')
}
const members = db.prepare('SELECT platform_id, account_name, group_nickname, avatar FROM member').all() as Array<{
platform_id: string
account_name?: string
group_nickname?: string
avatar?: string
}>
const messages = db
.prepare(
`SELECT
m.platform_id as sender,
msg.sender_account_name as accountName,
msg.sender_group_nickname as groupNickname,
msg.ts as timestamp,
msg.type,
msg.content
FROM message msg
JOIN member m ON msg.sender_id = m.id
ORDER BY msg.ts`
)
.all() as Array<{
sender: string
accountName?: string
groupNickname?: string
timestamp: number
type: number
content?: string
}>
const data = getExportSessionData(wrapAsDatabaseAdapter(db))
const chatLabData: ChatLabFormat = {
chatlab: {
version: '0.0.1',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'ChatLab Export',
description: `Exported from session: ${meta.name}`,
description: `Exported from session: ${data.meta.name}`,
},
meta: {
name: meta.name,
platform: meta.platform as ChatPlatform,
type: meta.type as ChatType,
groupId: meta.group_id,
groupAvatar: meta.group_avatar,
name: data.meta.name,
platform: data.meta.platform as ChatPlatform,
type: data.meta.type as ChatType,
groupId: data.meta.groupId,
groupAvatar: data.meta.groupAvatar,
},
members: members.map((m) => ({
platformId: m.platform_id,
accountName: m.account_name || m.platform_id,
groupNickname: m.group_nickname || undefined,
avatar: m.avatar,
})),
messages: messages.map((msg) => ({
sender: msg.sender,
accountName: msg.accountName || msg.sender,
groupNickname: msg.groupNickname || undefined,
timestamp: msg.timestamp,
members: data.members,
messages: data.messages.map((msg) => ({
...msg,
type: msg.type as ChatLabMessage['type'],
content: msg.content ?? null,
})),
}
@@ -267,7 +221,7 @@ export async function exportSessionToTempFile(sessionId: string): Promise<string
const tempFilePath = path.join(tempDir, `export_${sessionId}_${Date.now()}.json`)
fs.writeFileSync(tempFilePath, JSON.stringify(chatLabData, null, 2), 'utf-8')
console.log(`[Merger] Exporting session to temp file: ${tempFilePath}, message count: ${messages.length}`)
console.log(`[Merger] Exporting session to temp file: ${tempFilePath}, message count: ${data.messages.length}`)
return tempFilePath
} finally {
+2 -36
View File
@@ -27,6 +27,7 @@ import {
buildSessionInfo,
getSummaryCount,
getLastPlatformMessageId,
getPrivateChatMemberAvatar,
type SessionOverview,
} from '@openchatlab/core'
import type { DatabaseAdapter } from '@openchatlab/core'
@@ -48,41 +49,6 @@ function isChatSessionDb(db: Database.Database): boolean {
return requiredTableCount.cnt === 3
}
/**
* 获取私聊对方成员的头像
* 逻辑参考 private-chat/index.vue 的 otherMemberAvatar
*/
function getPrivateChatMemberAvatar(
db: Database.Database,
sessionName: string,
ownerId: string | null | undefined
): string | null {
const members = db
.prepare(
`SELECT
m.platform_id as platformId,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name,
m.avatar
FROM member m
WHERE COALESCE(m.account_name, '') != '系统消息'
ORDER BY (SELECT COUNT(*) FROM message WHERE sender_id = m.id) DESC`
)
.all() as Array<{ platformId: string; name: string; avatar: string | null }>
if (members.length === 0) return null
if (ownerId) {
const other = members.find((m) => m.platformId !== ownerId)
if (other?.avatar) return other.avatar
}
const sameName = members.find((m) => m.name === sessionName)
if (sameName?.avatar) return sameName.avatar
const firstWithAvatar = members.find((m) => m.avatar)
return firstWithAvatar?.avatar || null
}
/**
* Resolve overview from cache (with compute-on-miss), or fall back to live SQL via core.
*/
@@ -135,7 +101,7 @@ export function getAllSessions(): any[] {
let memberAvatar: string | null = null
if (meta.type === 'private') {
memberAvatar = getPrivateChatMemberAvatar(db, meta.name, meta.ownerId)
memberAvatar = getPrivateChatMemberAvatar(asCoreDb(db), meta.name, meta.ownerId)
}
sessions.push({
+3
View File
@@ -39,12 +39,15 @@ export {
getChatSessionList,
getSessionsByTimeRange,
getRecentChatSessions,
loadSessionMessages,
getChatSessionSummary,
saveChatSessionSummary,
updateSessionGapThreshold,
clearSessionIndex,
generateSessionIndex,
generateIncrementalSessionIndex,
getPrivateChatMemberAvatar,
getExportSessionData,
getTimeRange,
getAvailableYears,
getMemberActivity,
+155
View File
@@ -602,6 +602,33 @@ export function getChatSessionList(db: DatabaseAdapter): ChatSessionItem[] {
}
}
/**
* Load messages for a chat session (for summary generation).
*/
export function loadSessionMessages(
db: DatabaseAdapter,
chatSessionId: number,
limit: number = 500
): Array<{ senderName: string; content: string | null }> | null {
try {
return db
.prepare(
`SELECT
COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName,
m.content
FROM message_context mc
JOIN message m ON m.id = mc.message_id
JOIN member mb ON mb.id = m.sender_id
WHERE mc.session_id = ?
ORDER BY m.ts ASC
LIMIT ?`
)
.all(chatSessionId, limit) as unknown as Array<{ senderName: string; content: string | null }>
} catch {
return null
}
}
/**
* Get summary text for a single chat session.
*/
@@ -811,3 +838,131 @@ export function generateIncrementalSessionIndex(
return newSessionCount
})
}
// ==================== Export data ====================
export interface ExportSessionData {
meta: {
name: string
platform: string
type: string
groupId?: string
groupAvatar?: string
}
members: Array<{
platformId: string
accountName: string
groupNickname?: string
avatar?: string
}>
messages: Array<{
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
}>
}
/**
* Query all data needed for session export (meta + members + messages).
*/
export function getExportSessionData(db: DatabaseAdapter): ExportSessionData {
const meta = db.prepare('SELECT * FROM meta').get() as {
name: string
platform: string
type: string
group_id?: string
group_avatar?: string
}
if (!meta) throw new Error('Cannot read session meta')
const members = db.prepare('SELECT platform_id, account_name, group_nickname, avatar FROM member').all() as Array<{
platform_id: string
account_name?: string
group_nickname?: string
avatar?: string
}>
const messages = db
.prepare(
`SELECT
m.platform_id as sender,
msg.sender_account_name as accountName,
msg.sender_group_nickname as groupNickname,
msg.ts as timestamp,
msg.type,
msg.content
FROM message msg
JOIN member m ON msg.sender_id = m.id
ORDER BY msg.ts`
)
.all() as unknown as Array<{
sender: string
accountName?: string
groupNickname?: string
timestamp: number
type: number
content?: string
}>
return {
meta: {
name: meta.name,
platform: meta.platform,
type: meta.type,
groupId: meta.group_id,
groupAvatar: meta.group_avatar,
},
members: members.map((m) => ({
platformId: m.platform_id,
accountName: m.account_name || m.platform_id,
groupNickname: m.group_nickname || undefined,
avatar: m.avatar,
})),
messages: messages.map((msg) => ({
sender: msg.sender,
accountName: msg.accountName || msg.sender,
groupNickname: msg.groupNickname || undefined,
timestamp: msg.timestamp,
type: msg.type,
content: msg.content ?? null,
})),
}
}
/**
* Get private chat partner's avatar.
* Finds the "other" member (not the owner) in a private chat, falling back to name match or first with avatar.
*/
export function getPrivateChatMemberAvatar(
db: DatabaseAdapter,
sessionName: string,
ownerId: string | null | undefined
): string | null {
const members = db
.prepare(
`SELECT
m.platform_id as platformId,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as name,
m.avatar
FROM member m
WHERE COALESCE(m.account_name, '') != '系统消息'
ORDER BY (SELECT COUNT(*) FROM message WHERE sender_id = m.id) DESC`
)
.all() as unknown as Array<{ platformId: string; name: string; avatar: string | null }>
if (members.length === 0) return null
if (ownerId) {
const other = members.find((m) => m.platformId !== ownerId)
if (other?.avatar) return other.avatar
}
const sameName = members.find((m) => m.name === sessionName)
if (sameName?.avatar) return sameName.avatar
const firstWithAvatar = members.find((m) => m.avatar)
return firstWithAvatar?.avatar || null
}
@@ -0,0 +1,46 @@
import { completeSimple, type Model as PiModel, type TextContent as PiTextContent } from '@mariozechner/pi-ai'
import type { CompressionLlmAdapter } from './types'
export interface CreateCompressionLlmAdapterOptions {
piModel: PiModel
apiKey: string
contextWindow?: number
onCompressing?: () => void
onError?: (error: unknown) => void
}
const DEFAULT_CONTEXT_WINDOW = 128000
/**
* Create a CompressionLlmAdapter from a PiModel and API key.
* Shared factory used by both Electron and Server.
*/
export function createCompressionLlmAdapter(options: CreateCompressionLlmAdapterOptions): CompressionLlmAdapter {
const { piModel, apiKey, onCompressing, onError } = options
const contextWindow = options.contextWindow ?? piModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW
return {
contextWindow,
compress: async (prompt: string, maxTokens: number) => {
onCompressing?.()
try {
const result = await completeSimple(
piModel,
{
systemPrompt: undefined,
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
},
{ apiKey, maxTokens }
)
const text = result.content
.filter((item): item is PiTextContent => item.type === 'text')
.map((item) => item.text)
.join('')
return text || null
} catch (error) {
onError?.(error)
return null
}
},
}
}
@@ -4,3 +4,4 @@
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types'
export { checkAndCompress, manualCompress } from './compressor'
export { createCompressionLlmAdapter, type CreateCompressionLlmAdapterOptions } from './adapter-factory'
+2 -1
View File
@@ -55,7 +55,8 @@ export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResu
// Compression
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './compression'
export { checkAndCompress, manualCompress } from './compression'
export { checkAndCompress, manualCompress, createCompressionLlmAdapter } from './compression'
export type { CreateCompressionLlmAdapterOptions } from './compression'
// Preprocessor
export type {
+2 -1
View File
@@ -96,7 +96,8 @@ export type {
// Compression
export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './ai'
export { checkAndCompress, manualCompress } from './ai'
export { checkAndCompress, manualCompress, createCompressionLlmAdapter } from './ai'
export type { CreateCompressionLlmAdapterOptions } from './ai'
// SkillManager
export { SkillManager } from './ai'
+6 -26
View File
@@ -7,10 +7,10 @@
import {
runAgentCore,
completeSimple,
checkAndCompress,
buildSystemPrompt,
createAiTranslate,
createCompressionLlmAdapter,
AgentEventHandler,
formatAIError,
type AgentStreamChunk,
@@ -18,8 +18,6 @@ import {
type SimpleHistoryMessage,
type AIConversationManager,
type CompressionConfig,
type CompressionLlmAdapter,
type PiTextContent,
type AgentTool,
type DataSnapshot,
type OwnerInfo,
@@ -103,29 +101,11 @@ export async function runServerAgent(options: RunAgentOptions): Promise<void> {
})
if (compressionConfig?.enabled) {
const llmAdapter: CompressionLlmAdapter = {
contextWindow: piModel.contextWindow ?? 128000,
compress: async (prompt: string, maxTokens: number) => {
handler.emitStatus('compressing', [])
try {
const result = await completeSimple(
piModel,
{
systemPrompt: undefined,
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any,
},
{ apiKey: llmConfig.apiKey, maxTokens }
)
const text = result.content
.filter((item): item is PiTextContent => item.type === 'text')
.map((item) => item.text)
.join('')
return text || null
} catch {
return null
}
},
}
const llmAdapter = createCompressionLlmAdapter({
piModel,
apiKey: llmConfig.apiKey,
onCompressing: () => handler.emitStatus('compressing', []),
})
const compressionResult = await checkAndCompress(
conversationId,
compressionConfig,