diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index 1f4d53477..c4655b068 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -10,7 +10,7 @@ import type { ToolExecutionContext, SemanticSearchToolService, RawMessage, - TimeFilter, + ToolTimeRange, } from '@openchatlab/tools' import { CoreDataProvider } from '@openchatlab/tools' import type { DatabaseAdapter } from '@openchatlab/core' @@ -50,7 +50,7 @@ export interface ServerToolContext { /** 当前用户平台 id(昵称匿名化 owner 识别) */ ownerPlatformId?: string /** 会话时间范围筛选(来自请求参数,供证据类工具继承) */ - timeFilter?: TimeFilter + timeFilter?: ToolTimeRange /** 关键词搜索消息条数上限 */ maxMessagesLimit?: number } diff --git a/apps/desktop/main/ai/tools/worker-data-provider.ts b/apps/desktop/main/ai/tools/worker-data-provider.ts index 3f4b6506f..aacf83d29 100644 --- a/apps/desktop/main/ai/tools/worker-data-provider.ts +++ b/apps/desktop/main/ai/tools/worker-data-provider.ts @@ -11,7 +11,7 @@ import type { SearchMessagesResult, MemberStatItem, SchemaTableInfo, - TimeFilter, + ToolTimeRange, ChatOverviewResult, MemberInfo, NameHistoryItem, @@ -52,7 +52,7 @@ export class WorkerDataProvider implements ToolDataProvider { async searchMessages( keywords: string[], - options?: { timeFilter?: TimeFilter; limit?: number; senderId?: number } + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } ): Promise { const result = await this.run(() => workerManager.searchMessages( @@ -69,7 +69,7 @@ export class WorkerDataProvider implements ToolDataProvider { async deepSearchMessages( keywords: string[], - options?: { timeFilter?: TimeFilter; limit?: number; senderId?: number } + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } ): Promise { const result = await this.run(() => workerManager.deepSearchMessages( @@ -95,7 +95,7 @@ export class WorkerDataProvider implements ToolDataProvider { return mapSearchMessages(messages) } - async getRecentMessages(options?: { timeFilter?: TimeFilter; limit?: number }): Promise { + async getRecentMessages(options?: { timeFilter?: ToolTimeRange; limit?: number }): Promise { const result = await this.run(() => workerManager.getRecentMessages(this.sessionId, options?.timeFilter, options?.limit ?? 50) ) @@ -123,7 +123,7 @@ export class WorkerDataProvider implements ToolDataProvider { })) } - async getMemberStats(options?: { timeFilter?: TimeFilter; top?: number }): Promise { + async getMemberStats(options?: { timeFilter?: ToolTimeRange; top?: number }): Promise { const top = options?.top ?? 20 const members = await this.run(() => workerManager.getMemberActivity(this.sessionId, options?.timeFilter)) return members.slice(0, top).map((m: any) => ({ @@ -137,7 +137,10 @@ export class WorkerDataProvider implements ToolDataProvider { return this.run(() => workerManager.getMemberNameHistory(this.sessionId, memberId)) } - async getTimeStats(type: 'hourly' | 'weekday' | 'daily', options?: { timeFilter?: TimeFilter }): Promise { + async getTimeStats( + type: 'hourly' | 'weekday' | 'daily', + options?: { timeFilter?: ToolTimeRange } + ): Promise { const filter = options?.timeFilter switch (type) { case 'weekday': @@ -154,7 +157,7 @@ export class WorkerDataProvider implements ToolDataProvider { return this.run(() => workerManager.getSegmentMessages(this.sessionId, segmentId, limit)) } - async getSegmentSummaries(options?: { limit?: number; timeFilter?: TimeFilter }): Promise { + async getSegmentSummaries(options?: { limit?: number; timeFilter?: ToolTimeRange }): Promise { return this.run(() => workerManager.getSegmentSummaries(this.sessionId, { limit: options?.limit, @@ -166,7 +169,7 @@ export class WorkerDataProvider implements ToolDataProvider { async getConversationBetween( memberId1: number, memberId2: number, - timeFilter?: TimeFilter, + timeFilter?: ToolTimeRange, limit?: number ): Promise { const result = await this.run(() => diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index 666628ce4..00e417498 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -17,6 +17,7 @@ "../../packages/sync/**/*", "../../packages/http-routes/**/*" ], + "exclude": ["**/node_modules/**", "**/dist/**", "../../**/*.test.ts", "../../**/*.spec.ts", "../../tests/**"], "compilerOptions": { "composite": true, "types": ["electron-vite/node"], diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts index c63b83485..fba0ea32b 100644 --- a/packages/config/src/loader.ts +++ b/packages/config/src/loader.ts @@ -148,14 +148,11 @@ export function writeConfigField(section: string, key: string, value: string | n fs.writeFileSync(CONFIG_TOML, lines.join('\n'), 'utf-8') } -/** - * 简单的深度合并(仅处理纯对象,不处理数组) - */ function deepMerge(base: Record, override: Record): Record { const result = { ...base } for (const [key, value] of Object.entries(override)) { - if (value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value)) { - result[key] = deepMerge((result[key] as Record) ?? {}, value as Record) + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + result[key] = { ...(result[key] as object), ...(value as object) } } else if (value !== undefined) { result[key] = value } diff --git a/packages/core/src/ai/chart-capability.ts b/packages/core/src/ai/chart-capability.ts deleted file mode 100644 index 306984f46..000000000 --- a/packages/core/src/ai/chart-capability.ts +++ /dev/null @@ -1 +0,0 @@ -export const CHART_CAPABILITY_SKILL_ID = 'chart_runtime' diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts index 1d1350c81..3fbaf4256 100644 --- a/packages/core/src/ai/index.ts +++ b/packages/core/src/ai/index.ts @@ -4,8 +4,12 @@ // 内置工具目录 export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog' -export { BUILTIN_TOOL_CATALOG, normalizeBuiltinToolName, normalizeBuiltinToolNames } from './tool-catalog' -export { CHART_CAPABILITY_SKILL_ID } from './chart-capability' +export { + BUILTIN_TOOL_CATALOG, + normalizeBuiltinToolName, + normalizeBuiltinToolNames, + CHART_CAPABILITY_SKILL_ID, +} from './tool-catalog' // LLM 模型系统类型 export type { diff --git a/packages/core/src/ai/tool-catalog.ts b/packages/core/src/ai/tool-catalog.ts index 7fcd85aed..ab3a7ad00 100644 --- a/packages/core/src/ai/tool-catalog.ts +++ b/packages/core/src/ai/tool-catalog.ts @@ -25,6 +25,8 @@ export function normalizeBuiltinToolNames(toolNames: readonly string[]): string[ return Array.from(new Set(toolNames.map(normalizeBuiltinToolName))) } +export const CHART_CAPABILITY_SKILL_ID = 'chart_runtime' + export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [ // Core 工具 { name: 'get_chat_overview', category: 'core' }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55431281f..b88ed1d1e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,14 +6,7 @@ */ // 抽象接口 -export type { - DatabaseAdapter, - PreparedStatement, - RunResult, - PathProvider, - NotificationBus, - NotificationPayload, -} from './interfaces' +export type { DatabaseAdapter, PreparedStatement, RunResult, PathProvider } from './interfaces' // 查询工具 export { diff --git a/packages/core/src/interfaces/index.ts b/packages/core/src/interfaces/index.ts index 01f24f462..6cae62002 100644 --- a/packages/core/src/interfaces/index.ts +++ b/packages/core/src/interfaces/index.ts @@ -1,3 +1,2 @@ export type { DatabaseAdapter, PreparedStatement, RunResult } from './database-adapter' export type { PathProvider } from './path-provider' -export type { NotificationBus, NotificationPayload } from './notification-bus' diff --git a/packages/core/src/interfaces/notification-bus.ts b/packages/core/src/interfaces/notification-bus.ts deleted file mode 100644 index 92ed2d0c5..000000000 --- a/packages/core/src/interfaces/notification-bus.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 通知总线抽象接口 - * - * 统一不同运行环境下的异步事件通知方式: - * - Electron:通过 BrowserWindow.webContents.send(IPC)通知前端 - * - Node 独立运行:通过 EventEmitter 或 SSE 通知消费者 - * - 浏览器:通过 postMessage / CustomEvent 通知 UI - */ - -export type NotificationPayload = Record - -export interface NotificationBus { - /** 发送通知事件 */ - emit(event: string, payload?: NotificationPayload): void - - /** 监听通知事件 */ - on(event: string, handler: (payload?: NotificationPayload) => void): void - - /** 取消监听 */ - off(event: string, handler: (payload?: NotificationPayload) => void): void -} diff --git a/packages/core/src/query/__tests__/message-query-functions.test.ts b/packages/core/src/query/__tests__/message-query-functions.test.ts index 974489a1d..f1dafcbaa 100644 --- a/packages/core/src/query/__tests__/message-query-functions.test.ts +++ b/packages/core/src/query/__tests__/message-query-functions.test.ts @@ -9,7 +9,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import type { AsyncSqlExecutor } from '../executor' +import type { AsyncSqlExecutor } from '../message-query-functions' import type { FullMessageRow } from '../message-sql' import { fetchMessagesBefore, diff --git a/packages/core/src/query/executor.ts b/packages/core/src/query/executor.ts deleted file mode 100644 index 41765bf5a..000000000 --- a/packages/core/src/query/executor.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Async SQL executor abstraction. - * - * Provides a platform-agnostic async interface for executing SQL queries. - * - Electron: wraps synchronous `better-sqlite3` calls via `Promise.resolve()` - * - CLI Web: wraps `pluginQuery` HTTP calls (natively async) - * - Server direct: wraps `DatabaseAdapter` for `/api/v1` or AI tool use - */ - -export interface AsyncSqlExecutor { - all(sql: string, params?: unknown[]): Promise - get(sql: string, params?: unknown[]): Promise -} diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index cc08cedc4..9bc47dd68 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -123,9 +123,6 @@ export { } from './message-sql' export type { FullMessageRow, MappedMessage, MsgQueryConditions } from './message-sql' -// Async SQL executor abstraction -export type { AsyncSqlExecutor } from './executor' - // Shared async message query functions (platform-agnostic) export { fetchMessagesBefore, @@ -138,7 +135,12 @@ export { fetchRecentTextMessages, fetchConversationBetween, } from './message-query-functions' -export type { AsyncPaginatedMessages, AsyncMessagesWithTotal, AsyncConversationData } from './message-query-functions' +export type { + AsyncSqlExecutor, + AsyncPaginatedMessages, + AsyncMessagesWithTotal, + AsyncConversationData, +} from './message-query-functions' // Member write operations (merge, delete, update aliases, DDL migration) export { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from './member-ops' diff --git a/packages/core/src/query/message-query-functions.ts b/packages/core/src/query/message-query-functions.ts index b7c3a3481..f6d91ea70 100644 --- a/packages/core/src/query/message-query-functions.ts +++ b/packages/core/src/query/message-query-functions.ts @@ -9,7 +9,6 @@ */ import type { TimeFilter } from '@openchatlab/shared-types' -import type { AsyncSqlExecutor } from './executor' import { FULL_MSG_SELECT, FULL_MSG_FROM, @@ -20,6 +19,11 @@ import { type MappedMessage, } from './message-sql' +export interface AsyncSqlExecutor { + all(sql: string, params?: unknown[]): Promise + get(sql: string, params?: unknown[]): Promise +} + // ==================== Result types ==================== export interface AsyncPaginatedMessages { diff --git a/packages/sync/src/pull-engine.test.ts b/packages/sync/src/pull-engine.test.ts index c5f591a39..0f7671fa8 100644 --- a/packages/sync/src/pull-engine.test.ts +++ b/packages/sync/src/pull-engine.test.ts @@ -69,6 +69,7 @@ function createEngine(options: { files: string[] importResult: Awaited> dataSource: DataSource + fetchParams?: FetchParams[] sessionUpdates?: Array<{ sessionId: string; updates: Partial }> pullResults?: Array<{ status: 'success' | 'error'; detail: string }> }): PullEngine { @@ -78,8 +79,9 @@ function createEngine(options: { _baseUrl: string, _remoteSessionId: string, _token: string, - _params: FetchParams + params: FetchParams ): Promise { + options.fetchParams?.push({ ...params }) const file = files.shift() if (!file) throw new Error('Unexpected retry fetch') return file @@ -218,4 +220,76 @@ describe('PullEngine', () => { assert.equal(pullResults.at(-1)?.status, 'error') assert.equal(sessionUpdates.at(-1)?.updates.lastStatus, 'error') }) + + it('continues pagination with nextOffset when nextSince is absent', async () => { + const session = createSession() + session.lastPullAt = 0 + const dataSource = createDataSource() + dataSource.sessions = [session] + const firstPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [ + { sender: 'u1', timestamp: 100, type: 0, content: 'page 1a' }, + { sender: 'u1', timestamp: 101, type: 0, content: 'page 1b' }, + ], + sync: { hasMore: true, nextOffset: 2, watermark: 200 }, + }) + const secondPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 200, type: 0, content: 'page 2' }], + sync: { hasMore: false, watermark: 200 }, + }) + const fetchParams: FetchParams[] = [] + const engine = createEngine({ + files: [firstPage, secondPage], + dataSource, + fetchParams, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(fetchParams.length, 2) + assert.equal(fetchParams[0]?.offset, undefined) + assert.equal(fetchParams[1]?.offset, 2) + }) + + it('persists the imported message cursor with overlap instead of the wall clock', async () => { + const session = createSession() + session.lastPullAt = 0 + const dataSource = createDataSource() + dataSource.sessions = [session] + const page = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 2000, type: 0, content: 'latest imported message' }], + sync: { hasMore: false, watermark: 999999 }, + }) + const sessionUpdates: Array<{ sessionId: string; updates: Partial }> = [] + const engine = createEngine({ + files: [page], + dataSource, + sessionUpdates, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 1940) + }) }) diff --git a/packages/sync/src/pull-engine.ts b/packages/sync/src/pull-engine.ts index e92a72171..bd2a5f1eb 100644 --- a/packages/sync/src/pull-engine.ts +++ b/packages/sync/src/pull-engine.ts @@ -102,6 +102,44 @@ function fileContainsMessages(filePath: string): boolean { } } +function getMaxMessageTimestampFromFile(filePath: string): number | null { + try { + let maxTs: number | null = null + const visitTimestamp = (value: unknown) => { + const ts = typeof value === 'string' && value.trim() !== '' ? Number(value) : value + if (typeof ts === 'number' && Number.isFinite(ts)) { + maxTs = maxTs === null ? ts : Math.max(maxTs, ts) + } + } + + if (filePath.endsWith('.jsonl')) { + const content = fs.readFileSync(filePath, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const obj = JSON.parse(trimmed) + if (obj._type === 'message') visitTimestamp(obj.timestamp) + } catch { + continue + } + } + return maxTs + } + + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + if (Array.isArray(parsed.messages)) { + for (const message of parsed.messages) { + visitTimestamp(message?.timestamp) + } + } + return maxTs + } catch { + return null + } +} + function cleanupTempFile(filePath: string): void { try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) @@ -193,6 +231,8 @@ export class PullEngine { let totalNewMessages = 0 let since = sess.lastPullAt + let offset: number | undefined + let nextPullSince = sess.lastPullAt let pageCount = 0 let resyncAttempted = false @@ -203,6 +243,7 @@ export class PullEngine { pageCount++ const tempFile = await this.fetcher.fetchToTempFile(ds.baseUrl, sess.remoteSessionId, ds.token, { since, + offset, limit: ds.pullLimit, }) @@ -224,6 +265,7 @@ export class PullEngine { await new Promise((r) => setTimeout(r, retryDelays[ri])) const retryFile = await this.fetcher.fetchToTempFile(ds.baseUrl, sess.remoteSessionId, ds.token, { since, + offset, limit: ds.pullLimit, }) const retryStat = fs.statSync(retryFile) @@ -233,6 +275,7 @@ export class PullEngine { cleanupTempFile(retryFile) continue } + const retryMaxTs = getMaxMessageTimestampFromFile(retryFile) const retryResult = await this.importTempFile(ds.baseUrl, sess, retryFile) cleanupTempFile(retryFile) @@ -240,6 +283,8 @@ export class PullEngine { resyncAttempted = true this.logger.info(`[Pull] Resetting since=0 for "${sess.name}" full resync`) since = 0 + offset = undefined + nextPullSince = 0 pageCount = 0 sess.targetSessionId = '' sess.lastPullAt = 0 @@ -279,6 +324,7 @@ export class PullEngine { this.dsManager.updateSession(sourceId, sess.id, { targetSessionId: retryResult.sessionId }) } totalNewMessages += retryResult.newMessageCount + if (retryMaxTs !== null) nextPullSince = Math.max(nextPullSince, retryMaxTs) this.progressMap.set(sess.id, { sessionId: sess.id, sessionName: sess.name, @@ -288,6 +334,10 @@ export class PullEngine { }) if (retrySync?.hasMore && retrySync.nextSince !== undefined) { since = retrySync.nextSince + offset = undefined + nextPullSince = Math.max(nextPullSince, retrySync.nextSince) + } else if (retrySync?.hasMore && retrySync.nextOffset !== undefined) { + offset = retrySync.nextOffset } retryHasMore = !!retrySync?.hasMore retrySuccess = true @@ -305,6 +355,7 @@ export class PullEngine { } const sync = sync0 + const maxMessageTs = getMaxMessageTimestampFromFile(tempFile) const result = await this.importTempFile(ds.baseUrl, sess, tempFile) cleanupTempFile(tempFile) @@ -312,6 +363,8 @@ export class PullEngine { resyncAttempted = true this.logger.info(`[Pull] Resetting since=0 for "${sess.name}" full resync`) since = 0 + offset = undefined + nextPullSince = 0 pageCount = 0 sess.targetSessionId = '' sess.lastPullAt = 0 @@ -350,6 +403,7 @@ export class PullEngine { } totalNewMessages += result.newMessageCount + if (maxMessageTs !== null) nextPullSince = Math.max(nextPullSince, maxMessageTs) this.progressMap.set(sess.id, { sessionId: sess.id, sessionName: sess.name, @@ -358,9 +412,20 @@ export class PullEngine { done: false, }) + if (sync?.nextSince !== undefined) nextPullSince = Math.max(nextPullSince, sync.nextSince) + if (!sync || !sync.hasMore) break - if (sync.nextSince !== undefined) since = sync.nextSince + // 游标推进优先使用时间戳链;旧数据源只返回 nextOffset 时,继续使用 offset 续拉同一个 since 窗口。 + if (sync.nextSince !== undefined) { + since = sync.nextSince + offset = undefined + } else if (sync.nextOffset !== undefined) { + offset = sync.nextOffset + } else { + this.logger.warn(`[Pull] "${sess.name}" returned hasMore=true without nextSince or nextOffset, stopping`) + break + } } catch (importErr) { cleanupTempFile(tempFile) throw importErr @@ -372,7 +437,7 @@ export class PullEngine { } this.dsManager.updateSession(sourceId, sess.id, { - lastPullAt: Math.floor(Date.now() / 1000) - PULL_OVERLAP_SECONDS, + lastPullAt: Math.max(0, nextPullSince - PULL_OVERLAP_SECONDS), lastStatus: 'success', lastNewMessages: totalNewMessages, lastError: '', diff --git a/packages/tools/src/definitions/analysis-tools.test.ts b/packages/tools/src/definitions/analysis-tools.test.ts index 542d231b1..bb0ad25ea 100644 --- a/packages/tools/src/definitions/analysis-tools.test.ts +++ b/packages/tools/src/definitions/analysis-tools.test.ts @@ -6,7 +6,7 @@ import { getSegmentSummariesTool } from './get-segment-summaries' import { searchMessagesTool } from './search-messages' import { schemaTool, sqlQueryTool } from './sql-query' import { SQL_TOOL_DEFS, createSqlToolDefinition } from '../sql' -import type { RawMessage, ToolDataProvider, ToolExecutionContext, TimeFilter } from '../types' +import type { RawMessage, ToolDataProvider, ToolExecutionContext, ToolTimeRange } from '../types' function createContext( dataProvider: Partial, @@ -28,7 +28,7 @@ function createSqlTool(name: string) { describe('high-risk analysis tool definitions', () => { it('search_messages passes filters to the provider and returns expanded context messages', async () => { - const contextFilter: TimeFilter = { startTs: 1710000000, endTs: 1710000100 } + const contextFilter: ToolTimeRange = { startTs: 1710000000, endTs: 1710000100 } const searchCalls: Array<{ keywords: string[]; options: unknown }> = [] const contextCalls: Array<{ ids: number[]; before: number; after: number }> = [] const expandedMessages: RawMessage[] = [ @@ -109,8 +109,8 @@ describe('high-risk analysis tool definitions', () => { }) it('get_segment_summaries filters empty and non-matching summaries after over-fetching', async () => { - const calls: Array<{ limit?: number; timeFilter?: TimeFilter }> = [] - const contextFilter: TimeFilter = { startTs: 1704067200, endTs: 1704153600 } + const calls: Array<{ limit?: number; timeFilter?: ToolTimeRange }> = [] + const contextFilter: ToolTimeRange = { startTs: 1704067200, endTs: 1704153600 } const context = createContext( { async getSegmentSummaries(options) { diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 12deefcbc..4a22b591c 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -56,7 +56,7 @@ export type { SearchMessagesResult, MemberStatItem, SchemaTableInfo, - TimeFilter, + ToolTimeRange, ToolCategory, TruncationStrategy, ChatOverviewResult, diff --git a/packages/tools/src/providers/core-data-provider.test.ts b/packages/tools/src/providers/core-data-provider.test.ts index a9bec289f..78a5045d1 100644 --- a/packages/tools/src/providers/core-data-provider.test.ts +++ b/packages/tools/src/providers/core-data-provider.test.ts @@ -13,6 +13,7 @@ import assert from 'node:assert/strict' import Database from 'better-sqlite3' import { CoreDataProvider } from './core-data-provider' import type { DatabaseAdapter, PreparedStatement, RunResult } from '@openchatlab/core' +import { openTestSqliteDatabase } from '../../../../tests/helpers/sqlite.mts' class Stmt implements PreparedStatement { readonly?: boolean @@ -57,7 +58,7 @@ describe('CoreDataProvider keyword search', () => { let provider: CoreDataProvider beforeEach(() => { - raw = new Database(':memory:') + raw = openTestSqliteDatabase() raw.exec(` CREATE TABLE member ( id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, diff --git a/packages/tools/src/providers/core-data-provider.ts b/packages/tools/src/providers/core-data-provider.ts index 66c23d2c8..51413ec2f 100644 --- a/packages/tools/src/providers/core-data-provider.ts +++ b/packages/tools/src/providers/core-data-provider.ts @@ -30,7 +30,7 @@ import type { SearchMessagesResult, MemberStatItem, SchemaTableInfo, - TimeFilter, + ToolTimeRange, ChatOverviewResult, MemberInfo, NameHistoryItem, @@ -45,7 +45,7 @@ export class CoreDataProvider implements ToolDataProvider { async searchMessages( keywords: string[], - options?: { timeFilter?: TimeFilter; limit?: number; senderId?: number } + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } ): Promise { const result = searchMessagesByKeywords(this.db, keywords, { startTs: options?.timeFilter?.startTs, @@ -68,7 +68,7 @@ export class CoreDataProvider implements ToolDataProvider { async deepSearchMessages( keywords: string[], - options?: { timeFilter?: TimeFilter; limit?: number; senderId?: number } + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } ): Promise { return this.searchMessages(keywords, options) } @@ -81,7 +81,7 @@ export class CoreDataProvider implements ToolDataProvider { return coreGetSearchMessageContext(this.db, messageIds, contextBefore, contextAfter) } - async getRecentMessages(options?: { timeFilter?: TimeFilter; limit?: number }): Promise { + async getRecentMessages(options?: { timeFilter?: ToolTimeRange; limit?: number }): Promise { const messages = coreGetRecentMessages(this.db, { limit: options?.limit ?? 50 }) return { messages: messages.map((m) => ({ @@ -108,7 +108,7 @@ export class CoreDataProvider implements ToolDataProvider { return getMembersWithAliases(this.db) } - async getMemberStats(options?: { timeFilter?: TimeFilter; top?: number }): Promise { + async getMemberStats(options?: { timeFilter?: ToolTimeRange; top?: number }): Promise { const top = options?.top ?? 20 const members = getMemberActivity(this.db, options?.timeFilter) return members.slice(0, top).map((m) => ({ @@ -122,7 +122,10 @@ export class CoreDataProvider implements ToolDataProvider { return coreGetMemberNameHistory(this.db, memberId) } - async getTimeStats(type: 'hourly' | 'weekday' | 'daily', options?: { timeFilter?: TimeFilter }): Promise { + async getTimeStats( + type: 'hourly' | 'weekday' | 'daily', + options?: { timeFilter?: ToolTimeRange } + ): Promise { const filter = options?.timeFilter switch (type) { case 'weekday': @@ -139,14 +142,14 @@ export class CoreDataProvider implements ToolDataProvider { return coreGetSegmentMessages(this.db, segmentId, limit) } - async getSegmentSummaries(options?: { limit?: number; timeFilter?: TimeFilter }): Promise { + async getSegmentSummaries(options?: { limit?: number; timeFilter?: ToolTimeRange }): Promise { return coreGetSegmentSummaries(this.db, options) } async getConversationBetween( memberId1: number, memberId2: number, - timeFilter?: TimeFilter, + timeFilter?: ToolTimeRange, limit?: number ): Promise { return coreGetConversationBetween(this.db, memberId1, memberId2, timeFilter, limit) diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 2100acb2c..6249b533f 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -43,7 +43,7 @@ export interface JsonSchema { // ==================== Time Filter ==================== -export interface TimeFilter { +export interface ToolTimeRange { startTs: number endTs: number } @@ -156,17 +156,17 @@ export interface ToolDataProvider { // === 基础查询 === searchMessages( keywords: string[], - options?: { timeFilter?: TimeFilter; limit?: number; senderId?: number } + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } ): Promise deepSearchMessages( keywords: string[], - options?: { timeFilter?: TimeFilter; limit?: number; senderId?: number } + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } ): Promise getSearchMessageContext(messageIds: number[], contextBefore: number, contextAfter: number): Promise - getRecentMessages(options?: { timeFilter?: TimeFilter; limit?: number }): Promise + getRecentMessages(options?: { timeFilter?: ToolTimeRange; limit?: number }): Promise getMessageContext(messageIds: number[], contextSize: number): Promise @@ -176,23 +176,23 @@ export interface ToolDataProvider { // === 成员相关 === getMembers(): Promise - getMemberStats(options?: { timeFilter?: TimeFilter; top?: number }): Promise + getMemberStats(options?: { timeFilter?: ToolTimeRange; top?: number }): Promise getMemberNameHistory(memberId: number): Promise // === 时间统计 === - getTimeStats(type: 'hourly' | 'weekday' | 'daily', options?: { timeFilter?: TimeFilter }): Promise + getTimeStats(type: 'hourly' | 'weekday' | 'daily', options?: { timeFilter?: ToolTimeRange }): Promise // === 段落相关 === getSegmentMessages(segmentId: number, limit?: number): Promise - getSegmentSummaries(options?: { limit?: number; timeFilter?: TimeFilter }): Promise + getSegmentSummaries(options?: { limit?: number; timeFilter?: ToolTimeRange }): Promise // === 对话查询 === getConversationBetween( memberId1: number, memberId2: number, - timeFilter?: TimeFilter, + timeFilter?: ToolTimeRange, limit?: number ): Promise @@ -288,7 +288,7 @@ export interface SegmentResult { export interface ToolExecutionContext { sessionId: string locale?: string - timeFilter?: TimeFilter + timeFilter?: ToolTimeRange abortSignal?: AbortSignal /** 抽象查询接口 */ dataProvider?: ToolDataProvider diff --git a/packages/tools/src/utils/time-params.ts b/packages/tools/src/utils/time-params.ts index 834b33cec..2e0394f9d 100644 --- a/packages/tools/src/utils/time-params.ts +++ b/packages/tools/src/utils/time-params.ts @@ -4,7 +4,7 @@ * 从 Electron 工具提取的共享实用函数,处理 start_time/end_time 字符串参数。 */ -import type { TimeFilter } from '../types' +import type { ToolTimeRange } from '../types' export interface ExtendedTimeParams { start_time?: string @@ -13,12 +13,12 @@ export interface ExtendedTimeParams { /** * 解析时间参数,返回时间过滤器 - * 优先级: start_time/end_time > contextTimeFilter + * 优先级: start_time/end_time > contextToolTimeRange */ export function parseExtendedTimeParams( params: ExtendedTimeParams, - contextTimeFilter?: TimeFilter -): TimeFilter | undefined { + contextToolTimeRange?: ToolTimeRange +): ToolTimeRange | undefined { if (params.start_time || params.end_time) { let startTs: number | undefined let endTs: number | undefined @@ -45,5 +45,5 @@ export function parseExtendedTimeParams( } } - return contextTimeFilter + return contextToolTimeRange } diff --git a/src/components/AIChat/ChatExplorer.vue b/src/components/AIChat/ChatExplorer.vue index 48f37632c..585ecee08 100644 --- a/src/components/AIChat/ChatExplorer.vue +++ b/src/components/AIChat/ChatExplorer.vue @@ -512,6 +512,8 @@ watch( :session-token-usage="sessionTokenUsage" :agent-status="agentStatus" :current-ai-chat-id="currentAIChatId" + :current-messages="messages" + :fallback-title="sessionName" :estimated-context-tokens="estimatedContextTokens" /> diff --git a/src/components/AIChat/chat/ChatStatusBar.vue b/src/components/AIChat/chat/ChatStatusBar.vue index cce0e5f81..e6d34525c 100644 --- a/src/components/AIChat/chat/ChatStatusBar.vue +++ b/src/components/AIChat/chat/ChatStatusBar.vue @@ -6,11 +6,18 @@ import { useToast } from '@/composables/useToast' import { usePromptStore } from '@/stores/prompt' import { useLayoutStore } from '@/stores/layout' import { useLLMStore } from '@/stores/llm' -import { exportConversation, type ExportFormat, type ExportMessage } from '@/utils/conversationExport' +import { + exportConversation, + getExportableConversationMessages, + hasExportableConversationMessages, + type ConversationExportSourceMessage, + type ExportFormat, +} from '@/utils/conversationExport' import type { AgentRuntimeStatus } from '@electron/shared/types' import { useAIService } from '@/services' import { getSupportedThinkingLevels, type ThinkingLevel } from '@openchatlab/core' import { useCacheService } from '@/services/cache/service' +import type { ChatMessage } from '@/composables/useAIChat' const { t } = useI18n() const toast = useToast() @@ -21,6 +28,8 @@ const props = defineProps<{ sessionTokenUsage: { totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number } agentStatus?: AgentRuntimeStatus | null currentAIChatId?: string | null + currentMessages?: ChatMessage[] + fallbackTitle?: string estimatedContextTokens?: number }>() @@ -129,39 +138,60 @@ function openModelSettings() { // 导出当前对话 const isExporting = ref(false) +const visibleExportMessages = computed(() => getExportableConversationMessages(props.currentMessages ?? [])) +const canExportConversation = computed(() => { + return Boolean(props.currentAIChatId) || hasExportableConversationMessages(props.currentMessages ?? []) +}) + +function getExportLabels() { + return { + createdAt: t('ai.chat.conversation.export.createdAt'), + user: t('ai.chat.conversation.export.user'), + assistant: t('ai.chat.conversation.export.assistant'), + } +} + +function toExportSourceMessages(messages: ConversationExportSourceMessage[]): ConversationExportSourceMessage[] { + return messages.map((message) => ({ + ...message, + timestamp: message.timestamp * 1000, + })) +} async function handleExportConversation() { - if (isExporting.value || !props.currentAIChatId) return + if (isExporting.value || !canExportConversation.value) return isExporting.value = true try { - const [conv, messages] = await Promise.all([ - useAIService().getAIChat(props.currentAIChatId), - useAIService().getMessages(props.currentAIChatId), - ]) + const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat + const labels = getExportLabels() + let title = props.fallbackTitle || t('ai.chat.conversation.newChat') + let createdAt = visibleExportMessages.value[0]?.timestamp ?? Date.now() + let messages = visibleExportMessages.value + + if (props.currentAIChatId) { + const [conv, persistedMessages] = await Promise.all([ + useAIService().getAIChat(props.currentAIChatId), + useAIService().getMessages(props.currentAIChatId), + ]) + + if (conv) { + title = conv.title || title + createdAt = conv.createdAt * 1000 + } + + const persistedExportMessages = getExportableConversationMessages(toExportSourceMessages(persistedMessages)) + if (persistedExportMessages.length > 0) { + messages = persistedExportMessages + } + } - if (!conv || messages.length === 0) { + if (messages.length === 0) { toast.warn(t('ai.chat.conversation.export.noMessages')) return } - const format = (aiGlobalSettings.value.exportFormat || 'markdown') as ExportFormat - const title = conv.title || t('ai.chat.conversation.newChat') - const labels = { - createdAt: t('ai.chat.conversation.export.createdAt'), - user: t('ai.chat.conversation.export.user'), - assistant: t('ai.chat.conversation.export.assistant'), - } - // 导出面向用户可见的问答内容,跳过压缩摘要等系统生成的内部消息。 - const messagesWithMs: ExportMessage[] = messages - .filter((msg) => msg.role === 'user' || msg.role === 'assistant') - .map((msg) => ({ - role: msg.role as ExportMessage['role'], - content: msg.content, - timestamp: msg.timestamp * 1000, - })) - - const result = await exportConversation(title, messagesWithMs, conv.createdAt * 1000, format, labels) + const result = await exportConversation(title, messages, createdAt, format, labels) if (result.success && result.filePath) { const filename = result.filePath.split('/').pop() || result.filePath @@ -341,9 +371,7 @@ const thinkingLevelLabel = computed(() => { {{ formatCompactNumber(modelContextWindow) }}
{{ t('ai.chat.statusBar.tokenUsageTitle') }}: {{ totalTokenUsageCompactText }}
-
- {{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }} -
+
{{ t('ai.chat.statusBar.cacheHit') }}: {{ cacheReadText }}
@@ -362,7 +390,7 @@ const thinkingLevelLabel = computed(() => {