From 6ec736c05a7e0556bf653da5a86b37d8b8e36c3d Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 10 Jun 2026 19:48:03 +0800 Subject: [PATCH] feat(export): simplify message export and support multiple formats --- AGENTS.md | 1 - apps/desktop/main/ipc/messages.ts | 16 +- apps/desktop/main/worker/dbWorker.ts | 12 +- apps/desktop/main/worker/query/index.ts | 16 +- .../main/worker/query/session/export.ts | 78 +--- .../main/worker/query/session/filter.ts | 63 --- .../main/worker/query/session/index.ts | 17 +- .../main/worker/query/session/types.ts | 42 -- apps/desktop/main/worker/workerManager.ts | 77 +--- apps/desktop/preload/apis/ai.ts | 8 +- apps/desktop/preload/index.d.ts | 54 +-- packages/core/src/index.ts | 8 - .../core/src/query/__tests__/filter.test.ts | 148 ------- packages/core/src/query/filter.ts | 295 ------------ packages/core/src/query/index.ts | 11 - packages/http-routes/src/index.ts | 1 - packages/http-routes/src/register.ts | 2 - .../http-routes/src/routes/web/ai-filter.ts | 38 -- packages/http-routes/src/routes/web/export.ts | 26 +- .../src/export/format-exporter.ts | 155 +++++++ packages/node-runtime/src/export/index.ts | 3 + packages/node-runtime/src/index.ts | 7 +- .../src/services/export-service.ts | 17 +- .../MessageExport/ConditionPanel.vue | 255 ++--------- .../MessageExport/FilterHistory.vue | 200 --------- .../MessageExport/MessageExportModal.vue | 419 +++--------------- src/components/MessageExport/PreviewPanel.vue | 416 ----------------- src/components/MessageExport/SessionPanel.vue | 275 ------------ src/i18n/locales/en-US/analysis.json | 4 +- src/i18n/locales/ja-JP/analysis.json | 4 +- src/i18n/locales/zh-CN/analysis.json | 4 +- src/i18n/locales/zh-TW/analysis.json | 4 +- src/services/ai/fetch.ts | 87 ++-- src/services/ai/types.ts | 66 +-- src/services/ai/web.ts | 189 -------- src/services/index.ts | 1 - 36 files changed, 368 insertions(+), 2651 deletions(-) delete mode 100644 apps/desktop/main/worker/query/session/filter.ts delete mode 100644 packages/core/src/query/__tests__/filter.test.ts delete mode 100644 packages/core/src/query/filter.ts delete mode 100644 packages/http-routes/src/routes/web/ai-filter.ts create mode 100644 packages/node-runtime/src/export/format-exporter.ts delete mode 100644 src/components/MessageExport/FilterHistory.vue delete mode 100644 src/components/MessageExport/PreviewPanel.vue delete mode 100644 src/components/MessageExport/SessionPanel.vue delete mode 100644 src/services/ai/web.ts diff --git a/AGENTS.md b/AGENTS.md index 4a823cab..30988ee2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,6 @@ - 文档:开始开发前,请先查看公开开发指南 `docs/cn/contributing/development.md`。如果工作区存在 `.docs/`,再查看 `./.docs/README.md` 并阅读与当前需求相关的文档;`.docs/` 是可选的个人或团队私有开发上下文,可用于维护任务、决策、AI 协作记忆和临时规划,公开文档与公开 PR 不应依赖 `.docs/` 才能理解。 - 目标:用最小改动快速交付正确、可维护、可回归的业务结果 -- 边界:只解决当前需求,不做与需求无关的重构和"顺手优化" - 每次完成任务后,对产生修改的文件进行类型检查、lint检查和format格式化,指定修改的文件路径去执行,确保代码质量 - 在执行检查时,如果有其他与本次修改无关的报错,也需要一并修复 diff --git a/apps/desktop/main/ipc/messages.ts b/apps/desktop/main/ipc/messages.ts index e1d1a2c7..8a1f54c8 100644 --- a/apps/desktop/main/ipc/messages.ts +++ b/apps/desktop/main/ipc/messages.ts @@ -1,16 +1,12 @@ /** * 消息导出 IPC 处理器 - * - * filterMessagesWithContext / getMultipleSessionsMessages 已迁移到共享 HTTP 路由。 - * 仅保留导出功能(需要文件系统写入 + 进度推送)。 */ import { ipcMain } from 'electron' import type { IpcContext } from './types' import * as worker from '../worker/workerManager' -export function registerMessagesHandlers({ win }: IpcContext): void { - // 导出(需要文件系统写入 + 进度推送) +export function registerMessagesHandlers(_ctx: IpcContext): void { ipcMain.handle( 'ai:exportFilterResultToFile', async ( @@ -19,18 +15,12 @@ export function registerMessagesHandlers({ win }: IpcContext): void { sessionId: string sessionName: string outputDir: string - filterMode: 'condition' | 'session' - keywords?: string[] + format?: 'txt' | 'json' | 'markdown' timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - segmentIds?: number[] } ) => { try { - return await worker.exportFilterResultToFile(params, (progress) => { - win.webContents.send('ai:exportProgress', progress) - }) + return await worker.exportFilterResultToFile(params) } catch (error) { console.error('Failed to export filtered results:', error) return { success: false, error: String(error) } diff --git a/apps/desktop/main/worker/dbWorker.ts b/apps/desktop/main/worker/dbWorker.ts index 5dfec4f2..f58a81a5 100644 --- a/apps/desktop/main/worker/dbWorker.ts +++ b/apps/desktop/main/worker/dbWorker.ts @@ -68,9 +68,7 @@ import { getSegmentSummariesInWorker, searchSegments, getSegmentMessages, - // 自定义筛选 - filterMessagesWithContext, - getMultipleSessionsMessages, + // 导出 exportFilterResultToFile, // NLP 查询 getWordFrequency, @@ -238,11 +236,6 @@ const syncHandlers: Record any> = { searchSegments: (p) => searchSegments(p.sessionId, p.keywords, p.timeFilter, p.limit, p.previewCount), getSegmentMessages: (p) => getSegmentMessages(p.sessionId, p.segmentId, p.limit), - // 自定义筛选(支持分页) - filterMessagesWithContext: (p) => - filterMessagesWithContext(p.sessionId, p.keywords, p.timeFilter, p.senderIds, p.contextSize, p.page, p.pageSize), - getMultipleSessionsMessages: (p) => getMultipleSessionsMessages(p.sessionId, p.segmentIds, p.page, p.pageSize), - // NLP 查询 getWordFrequency: (p) => getWordFrequency(p), segmentText: (p) => segmentText(p.text, p.locale, p.minLength), @@ -272,8 +265,7 @@ const asyncHandlers: Record Promise incrementalImport: (p, id) => incrementalImport(p.sessionId, p.filePath, id, p.options), // Dry-run 分析(新会话) analyzeNewImport: (p, id) => analyzeNewImport(p.filePath, id), - // 导出筛选结果到文件(支持进度报告) - exportFilterResultToFile: async (p, id) => exportFilterResultToFile(p, id), + exportFilterResultToFile: async (p) => exportFilterResultToFile(p), } // 处理消息 diff --git a/apps/desktop/main/worker/query/index.ts b/apps/desktop/main/worker/query/index.ts index 245744b6..c603a5da 100644 --- a/apps/desktop/main/worker/query/index.ts +++ b/apps/desktop/main/worker/query/index.ts @@ -91,24 +91,10 @@ export { searchSegments, getSegmentMessages, DEFAULT_SESSION_GAP_THRESHOLD, - // 自定义筛选 - filterMessagesWithContext, - getMultipleSessionsMessages, // 导出功能 exportFilterResultToFile, } from './session' -export type { - ChatSessionItem, - SessionSearchResultItem, - SessionMessagesResult, - ContextBlock, - FilterStats, - FilterMessage, - PaginationInfo, - FilterResultWithPagination, - ExportFilterParams, - ExportProgress, -} from './session' +export type { ChatSessionItem, SessionSearchResultItem, SessionMessagesResult } from './session' // NLP 查询 export { getWordFrequency, segmentText, getPosTags } from './nlp' diff --git a/apps/desktop/main/worker/query/session/export.ts b/apps/desktop/main/worker/query/session/export.ts index b9baaf60..ad6f1dea 100644 --- a/apps/desktop/main/worker/query/session/export.ts +++ b/apps/desktop/main/worker/query/session/export.ts @@ -1,75 +1,45 @@ /** * Export module — Electron worker adapter. * - * Thin wrapper around @openchatlab/node-runtime markdown exporter. - * Provides Electron-specific wiring: worker progress IPC, filesystem output, - * and readonly better-sqlite3 database opening. + * Uses @openchatlab/node-runtime format exporter for multi-format output. + * Provides Electron-specific wiring: filesystem write and readonly DB opening. */ import * as fs from 'fs' import * as path from 'path' -import { parentPort } from 'worker_threads' import { BetterSqliteAdapter } from '@openchatlab/node-runtime' -import { exportFilterResultToMarkdown } from '@openchatlab/node-runtime' -import type { ExportProgress } from '@openchatlab/node-runtime' +import { exportWithFormat } from '@openchatlab/node-runtime' +import type { ExportFormat } from '@openchatlab/node-runtime' import { openReadonlyDatabase } from './core' -import type { ExportFilterParams } from './types' -export type { ExportFilterParams, ExportProgress } from './types' +export function exportFilterResultToFile(params: { + sessionId: string + sessionName: string + outputDir: string + format?: ExportFormat + timeFilter?: { startTs: number; endTs: number } +}): { success: boolean; filePath?: string; error?: string } { + const format: ExportFormat = params.format || 'txt' -function sendExportProgress(requestId: string, progress: ExportProgress): void { - parentPort?.postMessage({ - id: requestId, - type: 'progress', - payload: progress, - }) -} - -/** - * Export filter results to a Markdown file on disk. - * Delegates to shared engine; provides fs.createWriteStream as the ExportWriter. - */ -export function exportFilterResultToFile( - params: ExportFilterParams, - requestId?: string -): { success: boolean; filePath?: string; error?: string } { - const timestamp = Date.now() - const fileName = `${params.sessionName}_筛选结果_${timestamp}.md` - const filePath = path.join(params.outputDir, fileName) - - const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' }) - - const result = exportFilterResultToMarkdown( + const result = exportWithFormat( { sessionId: params.sessionId, sessionName: params.sessionName, - filterMode: params.filterMode, - keywords: params.keywords, + format, timeFilter: params.timeFilter, - senderIds: params.senderIds, - contextSize: params.contextSize, - segmentIds: params.segmentIds, }, - { - openDatabase(sessionId: string) { - const rawDb = openReadonlyDatabase(sessionId) - if (!rawDb) return null - return new BetterSqliteAdapter(rawDb) - }, - onProgress: requestId ? (progress) => sendExportProgress(requestId, progress) : undefined, - }, - { - write(chunk: string) { - writeStream.write(chunk) - }, - end() { - writeStream.end() - }, + (sessionId) => { + const rawDb = openReadonlyDatabase(sessionId) + if (!rawDb) return null + return new BetterSqliteAdapter(rawDb) } ) - if (result.success) { - return { success: true, filePath } + if (!result.success) { + return { success: false, error: result.error } } - return { success: false, error: result.error } + + const filePath = path.join(params.outputDir, result.filename) + fs.writeFileSync(filePath, result.content, 'utf8') + return { success: true, filePath } } diff --git a/apps/desktop/main/worker/query/session/filter.ts b/apps/desktop/main/worker/query/session/filter.ts deleted file mode 100644 index cac74885..00000000 --- a/apps/desktop/main/worker/query/session/filter.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Filter module — thin adapter over @openchatlab/core filter algorithms. - * Opens/closes the DB and delegates to core. - */ - -import { BetterSqliteAdapter } from '@openchatlab/node-runtime' -import { openReadonlyDatabase } from './core' -import type { FilterResultWithPagination } from '@openchatlab/core' -import { - filterMessagesWithContext as filterCore, - getMultipleSessionsMessages as getMultiSessionsCore, -} from '@openchatlab/core' - -export type { FilterMessage, ContextBlock, FilterResultWithPagination } from '@openchatlab/core' - -const EMPTY_RESULT = (page: number, pageSize: number): FilterResultWithPagination => ({ - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page, pageSize, totalBlocks: 0, totalHits: 0, hasMore: false }, -}) - -export function filterMessagesWithContext( - sessionId: string, - keywords?: string[], - timeFilter?: { startTs: number; endTs: number }, - senderIds?: number[], - contextSize: number = 10, - page: number = 1, - pageSize: number = 50 -): FilterResultWithPagination { - const rawDb = openReadonlyDatabase(sessionId) - if (!rawDb) return EMPTY_RESULT(page, pageSize) - - const db = new BetterSqliteAdapter(rawDb) - try { - return filterCore(db, { keywords, timeFilter, senderIds, contextSize, page, pageSize }) - } catch (error) { - console.error('filterMessagesWithContext error:', error) - return EMPTY_RESULT(page, pageSize) - } finally { - db.close() - } -} - -export function getMultipleSessionsMessages( - sessionId: string, - segmentIds: number[], - page: number = 1, - pageSize: number = 50 -): FilterResultWithPagination { - const rawDb = openReadonlyDatabase(sessionId) - if (!rawDb) return EMPTY_RESULT(page, pageSize) - - const db = new BetterSqliteAdapter(rawDb) - try { - return getMultiSessionsCore(db, segmentIds, page, pageSize) - } catch (error) { - console.error('getMultipleSessionsMessages error:', error) - return EMPTY_RESULT(page, pageSize) - } finally { - db.close() - } -} diff --git a/apps/desktop/main/worker/query/session/index.ts b/apps/desktop/main/worker/query/session/index.ts index 8e5b1d03..e80c154e 100644 --- a/apps/desktop/main/worker/query/session/index.ts +++ b/apps/desktop/main/worker/query/session/index.ts @@ -4,19 +4,7 @@ */ // Types — core types re-exported via ./types, Electron-only types kept there -export type { - ChatSessionItem, - SessionIndexStats, - SessionSearchResultItem, - SessionMessagesResult, - FilterMessage, - ContextBlock, - FilterStats, - PaginationInfo, - FilterResultWithPagination, - ExportFilterParams, - ExportProgress, -} from './types' +export type { ChatSessionItem, SessionIndexStats, SessionSearchResultItem, SessionMessagesResult } from './types' export { DEFAULT_SESSION_GAP_THRESHOLD } from './types' @@ -40,8 +28,5 @@ export { // AI 工具专用查询 export { searchSegments, getSegmentMessages } from './aiTools' -// 自定义筛选 -export { filterMessagesWithContext, getMultipleSessionsMessages } from './filter' - // 导出功能 export { exportFilterResultToFile } from './export' diff --git a/apps/desktop/main/worker/query/session/types.ts b/apps/desktop/main/worker/query/session/types.ts index c25183d5..413c67fa 100644 --- a/apps/desktop/main/worker/query/session/types.ts +++ b/apps/desktop/main/worker/query/session/types.ts @@ -9,45 +9,3 @@ export type { ChatSessionItem, SessionIndexStats } from '@openchatlab/core' // AI tool types — re-exported from core via aiTools.ts export type { SessionSearchResultItem, SessionMessagesResult } from './aiTools' - -// Filter types — re-exported from @openchatlab/core -export type { - FilterMessage, - ContextBlock, - FilterStats, - PaginationInfo, - FilterResultWithPagination, -} from '@openchatlab/core' - -/** - * 导出筛选结果参数 - */ -export interface ExportFilterParams { - sessionId: string - sessionName: string - outputDir: string - filterMode: 'condition' | 'session' - // 条件筛选参数 - keywords?: string[] - timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - // 会话筛选参数 - segmentIds?: number[] -} - -/** - * 导出进度类型 - */ -export interface ExportProgress { - /** 阶段 */ - stage: 'preparing' | 'exporting' | 'done' | 'error' - /** 当前处理的块索引(从 1 开始) */ - currentBlock: number - /** 总块数 */ - totalBlocks: number - /** 百分比(0-100) */ - percentage: number - /** 状态消息 */ - message: string -} diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index b0a7977c..ba5510e4 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -887,87 +887,20 @@ export async function getSegmentSummaries( return sendToWorker('getSegmentSummaries', { sessionId, options }) } -// ==================== 自定义筛选 API ==================== +// ==================== 导出 API ==================== -import type { - FilterMessage, - ContextBlock, - FilterStats, - PaginationInfo, - FilterResultWithPagination, -} from '@openchatlab/core' -export type { FilterMessage, ContextBlock, FilterStats, PaginationInfo, FilterResultWithPagination } - -/** - * 按条件筛选消息并扩充上下文(支持分页) - */ -export async function filterMessagesWithContext( - sessionId: string, - keywords?: string[], - timeFilter?: { startTs: number; endTs: number }, - senderIds?: number[], - contextSize?: number, - page?: number, - pageSize?: number -): Promise { - return sendToWorker('filterMessagesWithContext', { - sessionId, - keywords, - timeFilter, - senderIds, - contextSize, - page, - pageSize, - }) -} - -/** - * 获取多个会话的完整消息(支持分页) - */ -export async function getMultipleSessionsMessages( - sessionId: string, - segmentIds: number[], - page?: number, - pageSize?: number -): Promise { - return sendToWorker('getMultipleSessionsMessages', { sessionId, segmentIds, page, pageSize }) -} - -/** - * 导出筛选结果参数 - */ -export interface ExportFilterParams { +export interface ExportFileParams { sessionId: string sessionName: string outputDir: string - filterMode: 'condition' | 'session' - keywords?: string[] + format?: 'txt' | 'json' | 'markdown' timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - segmentIds?: number[] } -/** - * 导出进度回调类型 - */ -export interface ExportProgress { - stage: 'preparing' | 'exporting' | 'done' | 'error' - currentBlock: number - totalBlocks: number - percentage: number - message: string -} - -/** - * 导出筛选结果到文件(后端生成) - * 使用 10 分钟超时,支持大数据量导出和进度回调 - */ export async function exportFilterResultToFile( - params: ExportFilterParams, - onProgress?: (progress: ExportProgress) => void + params: ExportFileParams ): Promise<{ success: boolean; filePath?: string; error?: string }> { - return sendToWorkerWithProgress('exportFilterResultToFile', params, onProgress as any, 600000) + return sendToWorker('exportFilterResultToFile', params) } // ==================== 增量导入 ==================== diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts index ac2ba5d1..0e807319 100644 --- a/apps/desktop/preload/apis/ai.ts +++ b/apps/desktop/preload/apis/ai.ts @@ -37,18 +37,12 @@ export interface PreprocessConfig { // ==================== AI API (IPC-only subset) ==================== export const aiApi = { - // filterMessagesWithContext / getMultipleSessionsMessages migrated to shared HTTP routes. - exportFilterResultToFile: (params: { sessionId: string sessionName: string outputDir: string - filterMode: 'condition' | 'session' - keywords?: string[] + format?: 'txt' | 'json' | 'markdown' timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - segmentIds?: number[] }): Promise<{ success: boolean; filePath?: string; error?: string }> => { return ipcRenderer.invoke('ai:exportFilterResultToFile', params) }, diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index e2ea3480..58e393e0 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -110,51 +110,6 @@ interface Api { } } -interface FilterMessage { - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number - replyToMessageId: string | null - replyToContent: string | null - replyToSenderName: string | null - isHit: boolean -} - -interface ContextBlock { - startTs: number - endTs: number - messages: FilterMessage[] - hitCount: number -} - -interface FilterResult { - blocks: ContextBlock[] - stats: { - totalMessages: number - hitMessages: number - totalChars: number - } -} - -// 分页信息类型 -interface PaginationInfo { - page: number - pageSize: number - totalBlocks: number - totalHits: number - hasMore: boolean -} - -// 带分页的筛选结果类型 -interface FilterResultWithPagination extends FilterResult { - pagination: PaginationInfo -} - /** * AiApi — IPC-only subset * @@ -166,12 +121,8 @@ interface AiApi { sessionId: string sessionName: string outputDir: string - filterMode: 'condition' | 'session' - keywords?: string[] + format?: 'txt' | 'json' | 'markdown' timeFilter?: TimeFilter - senderIds?: number[] - contextSize?: number - segmentIds?: number[] }) => Promise<{ success: boolean; filePath?: string; error?: string }> onExportProgress: (callback: (progress: ExportProgress) => void) => () => void showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }> @@ -420,9 +371,6 @@ export { DesensitizeRule, PreprocessConfig, TokenUsage, - FilterMessage, - ContextBlock, - FilterResult, ApiServerApi, ApiServerConfig, ApiServerStatus, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7dc618d3..8dd62d13 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,8 +97,6 @@ export { fetchAllRecentMessages, fetchRecentTextMessages, fetchConversationBetween, - filterMessagesWithContext, - getMultipleSessionsMessages, updateMemberAliases, mergeMembers, deleteMember, @@ -169,12 +167,6 @@ export type { AsyncPaginatedMessages, AsyncMessagesWithTotal, AsyncConversationData, - FilterMessage, - ContextBlock, - FilterStats, - PaginationInfo, - FilterResultWithPagination, - FilterOptions, SqlExecutionOptions, SqlExecutionResult, TableSchema, diff --git a/packages/core/src/query/__tests__/filter.test.ts b/packages/core/src/query/__tests__/filter.test.ts deleted file mode 100644 index 30c9a813..00000000 --- a/packages/core/src/query/__tests__/filter.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Tests for shared filter algorithms (filterMessagesWithContext, getMultipleSessionsMessages). - * - * Uses a mock DatabaseAdapter to verify the filtering, context expansion, - * range merging, and pagination logic. - * - * Run: npx tsx --test packages/core/src/query/__tests__/filter.test.ts - */ - -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces/database-adapter' -import type { FullMessageRow } from '../message-sql' -import { filterMessagesWithContext, getMultipleSessionsMessages } from '../filter' - -function makeRow(id: number, content: string, ts: number, senderId: number = 1): FullMessageRow { - return { - id, - senderId, - senderName: `User${senderId}`, - senderPlatformId: `u${senderId}`, - aliasesJson: '[]', - senderAvatar: null, - content, - timestamp: ts, - type: 0, - replyToMessageId: null, - replyToContent: null, - replyToSenderName: null, - } -} - -type LightRow = { id: number; ts: number; senderId: number; content: string | null } - -function createMockDb(lightRows: LightRow[], fullRows: FullMessageRow[]): DatabaseAdapter { - let callCount = 0 - return { - exec() { - /* no-op for test */ - }, - prepare(): PreparedStatement { - callCount++ - const isLightQuery = callCount === 1 - return { - get(): Record | undefined { - return undefined - }, - all(..._params: unknown[]): Record[] { - if (isLightQuery) { - return lightRows as unknown as Record[] - } - const limit = - typeof _params[_params.length - 2] === 'number' ? (_params[_params.length - 2] as number) : fullRows.length - const offset = typeof _params[_params.length - 1] === 'number' ? (_params[_params.length - 1] as number) : 0 - return fullRows.slice(offset, offset + limit) as unknown as Record[] - }, - run(): RunResult { - return { changes: 0 } - }, - } - }, - transaction(fn: () => T): T { - return fn() - }, - pragma() { - return undefined - }, - close() { - /* no-op for test */ - }, - } -} - -describe('filterMessagesWithContext', () => { - it('returns empty result when no messages match', () => { - const lightRows: LightRow[] = [ - { id: 1, ts: 100, senderId: 1, content: 'hello' }, - { id: 2, ts: 200, senderId: 2, content: 'world' }, - ] - const db = createMockDb(lightRows, []) - const result = filterMessagesWithContext(db, { keywords: ['notfound'] }) - assert.equal(result.blocks.length, 0) - assert.equal(result.stats.hitMessages, 0) - assert.equal(result.pagination.totalHits, 0) - }) - - it('matches keywords case-insensitively', () => { - const lightRows: LightRow[] = [ - { id: 1, ts: 100, senderId: 1, content: 'Hello World' }, - { id: 2, ts: 200, senderId: 2, content: 'foo bar' }, - ] - const fullRows = [makeRow(1, 'Hello World', 100)] - const db = createMockDb(lightRows, fullRows) - const result = filterMessagesWithContext(db, { keywords: ['hello'], contextSize: 0 }) - assert.equal(result.pagination.totalHits, 1) - assert.equal(result.blocks.length, 1) - assert.equal(result.blocks[0].messages[0].isHit, true) - }) - - it('filters by sender IDs', () => { - const lightRows: LightRow[] = [ - { id: 1, ts: 100, senderId: 1, content: 'msg1' }, - { id: 2, ts: 200, senderId: 2, content: 'msg2' }, - { id: 3, ts: 300, senderId: 1, content: 'msg3' }, - ] - const fullRows = [makeRow(1, 'msg1', 100, 1), makeRow(3, 'msg3', 300, 1)] - const db = createMockDb(lightRows, fullRows) - const result = filterMessagesWithContext(db, { senderIds: [1], contextSize: 0 }) - assert.equal(result.pagination.totalHits, 2) - }) - - it('merges overlapping context ranges', () => { - const lightRows: LightRow[] = Array.from({ length: 10 }, (_, i) => ({ - id: i + 1, - ts: (i + 1) * 100, - senderId: 1, - content: i === 2 || i === 4 ? 'MATCH' : 'other', - })) - const fullRows = lightRows.map((r) => makeRow(r.id, r.content ?? '', r.ts)) - const db = createMockDb(lightRows, fullRows) - const result = filterMessagesWithContext(db, { keywords: ['match'], contextSize: 2 }) - assert.equal(result.pagination.totalHits, 2) - assert.equal(result.blocks.length, 1, 'adjacent hits should merge into one block') - }) - - it('paginates blocks correctly', () => { - const lightRows: LightRow[] = Array.from({ length: 100 }, (_, i) => ({ - id: i + 1, - ts: (i + 1) * 100, - senderId: 1, - content: i % 20 === 0 ? 'HIT' : 'other', - })) - const fullRows = lightRows.map((r) => makeRow(r.id, r.content ?? '', r.ts)) - const db = createMockDb(lightRows, fullRows) - const result = filterMessagesWithContext(db, { keywords: ['hit'], contextSize: 0, pageSize: 2, page: 1 }) - assert.equal(result.blocks.length, 2) - assert.equal(result.pagination.hasMore, true) - assert.equal(result.pagination.totalBlocks, 5) - }) -}) - -describe('getMultipleSessionsMessages', () => { - it('returns empty result for empty segmentIds', () => { - const db = createMockDb([], []) - const result = getMultipleSessionsMessages(db, []) - assert.equal(result.blocks.length, 0) - }) -}) diff --git a/packages/core/src/query/filter.ts b/packages/core/src/query/filter.ts deleted file mode 100644 index 0b94df35..00000000 --- a/packages/core/src/query/filter.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Message filter with context expansion and pagination. - * - * Two-phase query architecture: - * 1. Lightweight pass: fetch id/ts/senderId/content to identify hits - * 2. Full pass: load complete message data only for the current page's ranges - */ - -import type { DatabaseAdapter } from '../interfaces/database-adapter' -import { FULL_MSG_COLUMNS, FULL_MSG_FROM, mapMessageRow, type FullMessageRow, type MappedMessage } from './message-sql' - -// ==================== Types ==================== - -export interface FilterMessage extends MappedMessage { - isHit: boolean -} - -export interface ContextBlock { - startTs: number - endTs: number - messages: FilterMessage[] - hitCount: number -} - -export interface FilterStats { - totalMessages: number - hitMessages: number - totalChars: number -} - -export interface PaginationInfo { - page: number - pageSize: number - totalBlocks: number - totalHits: number - hasMore: boolean -} - -export interface FilterResultWithPagination { - blocks: ContextBlock[] - stats: FilterStats - pagination: PaginationInfo -} - -export interface FilterOptions { - keywords?: string[] - timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - page?: number - pageSize?: number -} - -// ==================== Helpers ==================== - -function emptyResult(page: number, pageSize: number): FilterResultWithPagination { - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page, pageSize, totalBlocks: 0, totalHits: 0, hasMore: false }, - } -} - -// ==================== Core filter algorithm ==================== - -/** - * Filter messages by keyword/sender/time with surrounding context, paginated by blocks. - */ -export function filterMessagesWithContext( - db: DatabaseAdapter, - options: FilterOptions = {} -): FilterResultWithPagination { - const { keywords, timeFilter, senderIds, contextSize = 10, page = 1, pageSize = 50 } = options - - // Phase 1: lightweight scan - const lightSql = ` - SELECT id, ts, sender_id as senderId, content - FROM message - ${timeFilter ? 'WHERE ts >= ? AND ts <= ?' : ''} - ORDER BY ts ASC, id ASC - ` - const params: unknown[] = [] - if (timeFilter) { - params.push(timeFilter.startTs, timeFilter.endTs) - } - - const lightRows = db.prepare(lightSql).all(...params) as Array<{ - id: number - ts: number - senderId: number - content: string | null - }> - - const hitIndexes: number[] = [] - for (let i = 0; i < lightRows.length; i++) { - const row = lightRows[i] - let isHit = true - - if (keywords && keywords.length > 0) { - const content = (row.content || '').toLowerCase() - isHit = keywords.some((kw) => content.includes(kw.toLowerCase())) - } - if (isHit && senderIds && senderIds.length > 0) { - isHit = senderIds.includes(row.senderId) - } - if (isHit) { - hitIndexes.push(i) - } - } - - if (hitIndexes.length === 0) { - return emptyResult(page, pageSize) - } - - // Merge overlapping context ranges - const ranges: Array<{ start: number; end: number; hitIndexes: number[] }> = [] - for (const hitIndex of hitIndexes) { - const start = Math.max(0, hitIndex - contextSize) - const end = Math.min(lightRows.length - 1, hitIndex + contextSize) - - if (ranges.length > 0) { - const lastRange = ranges[ranges.length - 1] - if (start <= lastRange.end + 1) { - lastRange.end = Math.max(lastRange.end, end) - lastRange.hitIndexes.push(hitIndex) - continue - } - } - ranges.push({ start, end, hitIndexes: [hitIndex] }) - } - - const totalBlocks = ranges.length - const totalHits = hitIndexes.length - - // Paginate blocks - const startIdx = (page - 1) * pageSize - const endIdx = Math.min(startIdx + pageSize, totalBlocks) - const pageRanges = ranges.slice(startIdx, endIdx) - const hasMore = endIdx < totalBlocks - - if (pageRanges.length === 0) { - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: totalHits, totalChars: 0 }, - pagination: { page, pageSize, totalBlocks, totalHits, hasMore: false }, - } - } - - // Phase 2: load full messages for current page ranges - const blocks: ContextBlock[] = [] - let totalMessages = 0 - let totalChars = 0 - - for (const range of pageRanges) { - const blockSql = ` - SELECT ${FULL_MSG_COLUMNS} - ${FULL_MSG_FROM} - ${timeFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''} - ORDER BY msg.ts ASC, msg.id ASC - LIMIT ? OFFSET ? - ` - const blockParams: unknown[] = [] - if (timeFilter) { - blockParams.push(timeFilter.startTs, timeFilter.endTs) - } - blockParams.push(range.end - range.start + 1, range.start) - - const rows = db.prepare(blockSql).all(...blockParams) as unknown as FullMessageRow[] - const mapped = rows.map(mapMessageRow) - - const hitIndexSet = new Set(range.hitIndexes.map((idx) => idx - range.start)) - const blockMessages: FilterMessage[] = mapped.map((msg, i) => ({ - ...msg, - isHit: hitIndexSet.has(i), - })) - - for (const msg of mapped) { - totalChars += msg.content.length - } - - if (blockMessages.length > 0) { - blocks.push({ - startTs: blockMessages[0].timestamp, - endTs: blockMessages[blockMessages.length - 1].timestamp, - messages: blockMessages, - hitCount: range.hitIndexes.length, - }) - totalMessages += blockMessages.length - } - } - - let estimatedTotalChars = totalChars - if (page === 1 && totalBlocks > pageSize && blocks.length > 0) { - const avgCharsPerBlock = totalChars / blocks.length - estimatedTotalChars = Math.round(avgCharsPerBlock * totalBlocks) - } - - return { - blocks, - stats: { - totalMessages: page === 1 ? totalMessages : 0, - hitMessages: totalHits, - totalChars: page === 1 ? (totalBlocks > pageSize ? estimatedTotalChars : totalChars) : 0, - }, - pagination: { page, pageSize, totalBlocks, totalHits, hasMore }, - } -} - -/** - * Get messages from multiple chat sessions, paginated by session blocks. - */ -export function getMultipleSessionsMessages( - db: DatabaseAdapter, - segmentIds: number[], - page: number = 1, - pageSize: number = 50 -): FilterResultWithPagination { - if (segmentIds.length === 0) { - return emptyResult(page, pageSize) - } - - const sessionsSql = ` - SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount - FROM segment - WHERE id IN (${segmentIds.map(() => '?').join(',')}) - ORDER BY start_ts ASC - ` - const allSessions = db.prepare(sessionsSql).all(...segmentIds) as Array<{ - id: number - startTs: number - endTs: number - messageCount: number - }> - - const totalBlocks = allSessions.length - const startIdx = (page - 1) * pageSize - const endIdx = Math.min(startIdx + pageSize, totalBlocks) - const pageSessions = allSessions.slice(startIdx, endIdx) - const hasMore = endIdx < totalBlocks - - if (pageSessions.length === 0) { - return { - blocks: [], - stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 }, - pagination: { page, pageSize, totalBlocks, totalHits: 0, hasMore: false }, - } - } - - const blocks: ContextBlock[] = [] - let totalMessages = 0 - let totalChars = 0 - - const messagesSql = ` - SELECT ${FULL_MSG_COLUMNS} - FROM message_context mc - JOIN message msg ON msg.id = mc.message_id - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE mc.segment_id = ? - ORDER BY msg.ts ASC - ` - - for (const session of pageSessions) { - const rows = db.prepare(messagesSql).all(session.id) as unknown as FullMessageRow[] - const mapped = rows.map(mapMessageRow) - - const blockMessages: FilterMessage[] = mapped.map((msg) => ({ - ...msg, - isHit: false, - })) - - for (const msg of mapped) { - totalChars += msg.content.length - } - - blocks.push({ - startTs: session.startTs, - endTs: session.endTs, - messages: blockMessages, - hitCount: 0, - }) - totalMessages += rows.length - } - - return { - blocks, - stats: { - totalMessages: page === 1 ? totalMessages : 0, - hitMessages: 0, - totalChars: page === 1 ? totalChars : 0, - }, - pagination: { page, pageSize, totalBlocks, totalHits: 0, hasMore }, - } -} diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index e52eda96..694d9579 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -138,17 +138,6 @@ export type { AsyncPaginatedMessages, AsyncMessagesWithTotal, AsyncConversationD // Member write operations (merge, delete, update aliases, DDL migration) export { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from './member-ops' -// Filter with context (keyword/sender/time filter + pagination) -export { filterMessagesWithContext, getMultipleSessionsMessages } from './filter' -export type { - FilterMessage, - ContextBlock, - FilterStats, - PaginationInfo, - FilterResultWithPagination, - FilterOptions, -} from './filter' - // Advanced analytics export { getCatchphraseAnalysis, diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index a7ddcab3..7634ac8c 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -37,5 +37,4 @@ export { registerAiChatRoutes } from './routes/web/ai-chats' export { registerAiSummaryRoutes } from './routes/web/ai-summaries' export { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' export { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' -export { registerAiFilterRoutes } from './routes/web/ai-filter' export { registerAiToolRoutes } from './routes/web/ai-tools' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index c6b51c09..fff58d4f 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -22,7 +22,6 @@ import { registerAiSkillRoutes } from './routes/web/ai-skills' import { registerAiLlmRoutes } from './routes/web/ai-llm' import { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' import { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' -import { registerAiFilterRoutes } from './routes/web/ai-filter' import { registerAiToolRoutes } from './routes/web/ai-tools' import { registerAiChatRoutes } from './routes/web/ai-chats' import { registerAiSummaryRoutes } from './routes/web/ai-summaries' @@ -73,7 +72,6 @@ export function registerSharedRoutes( registerAiLlmRoutes(server, ctx) registerAiLlmStreamRoutes(server, ctx) registerAiAgentStreamRoutes(server, ctx) - registerAiFilterRoutes(server, ctx) registerAiToolRoutes(server, ctx) registerAiChatRoutes(server, ctx) registerAiSummaryRoutes(server, ctx) diff --git a/packages/http-routes/src/routes/web/ai-filter.ts b/packages/http-routes/src/routes/web/ai-filter.ts deleted file mode 100644 index 0cb1df2c..00000000 --- a/packages/http-routes/src/routes/web/ai-filter.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import type { HttpRouteContext } from '../../context' -import { filterMessagesWithContext, getMultipleSessionsMessages } from '@openchatlab/core' - -export function registerAiFilterRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { - server.post<{ - Body: { - sessionId: string - keywords?: string[] - timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - page?: number - pageSize?: number - } - }>('/_web/ai/filter-messages', async (request, reply) => { - const { sessionId, keywords, timeFilter, senderIds, contextSize, page, pageSize } = request.body - const db = ctx.dbManager.open(sessionId) - if (!db) return reply.code(404).send({ error: `Session not found: ${sessionId}` }) - - return filterMessagesWithContext(db, { keywords, timeFilter, senderIds, contextSize, page, pageSize }) - }) - - server.post<{ - Body: { - sessionId: string - segmentIds: number[] - page?: number - pageSize?: number - } - }>('/_web/ai/multiple-sessions-messages', async (request, reply) => { - const { sessionId, segmentIds, page, pageSize } = request.body - const db = ctx.dbManager.open(sessionId) - if (!db) return reply.code(404).send({ error: `Session not found: ${sessionId}` }) - - return getMultipleSessionsMessages(db, segmentIds, page, pageSize) - }) -} diff --git a/packages/http-routes/src/routes/web/export.ts b/packages/http-routes/src/routes/web/export.ts index 2dd809e5..5df39255 100644 --- a/packages/http-routes/src/routes/web/export.ts +++ b/packages/http-routes/src/routes/web/export.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify' import type { HttpRouteContext } from '../../context' import { exportService } from '@openchatlab/node-runtime' +import type { ExportFormat } from '@openchatlab/node-runtime' export function registerExportRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { const { sessionAdapter: adapter } = ctx @@ -9,35 +10,28 @@ export function registerExportRoutes(server: FastifyInstance, ctx: HttpRouteCont Params: { id: string } Body: { sessionName: string - filterMode: 'condition' | 'session' - keywords?: string[] + format?: ExportFormat timeFilter?: { startTs: number; endTs: number } - senderIds?: number[] - contextSize?: number - segmentIds?: number[] } - }>('/_web/sessions/:id/export/markdown', async (request, reply) => { + }>('/_web/sessions/:id/export', async (request, reply) => { const { id } = request.params const body = request.body as any const sessionName = body?.sessionName || id + const format: ExportFormat = body?.format || 'txt' - const { result, content } = exportService.exportMarkdown(adapter, { + const result = exportService.exportFormatted(adapter, { sessionId: id, sessionName, - filterMode: body.filterMode || 'condition', - keywords: body.keywords, + format, timeFilter: body.timeFilter, - senderIds: body.senderIds, - contextSize: body.contextSize, - segmentIds: body.segmentIds, }) if (!result.success) { - return reply.code(500).send({ error: result.error }) + return reply.code(result.totalMessages === 0 ? 404 : 500).send({ error: result.error }) } - reply.header('Content-Type', 'text/markdown; charset=utf-8') - reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(sessionName)}_export.md"`) - return reply.send(content) + reply.header('Content-Type', result.mimeType) + reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`) + return reply.send(result.content) }) } diff --git a/packages/node-runtime/src/export/format-exporter.ts b/packages/node-runtime/src/export/format-exporter.ts new file mode 100644 index 00000000..d8d0606a --- /dev/null +++ b/packages/node-runtime/src/export/format-exporter.ts @@ -0,0 +1,155 @@ +/** + * Multi-format export: txt / json / markdown. + * Simplified for time-range-only filtering (no keyword/context block logic). + */ + +import type { DatabaseAdapter } from '@openchatlab/core' + +export type ExportFormat = 'txt' | 'json' | 'markdown' + +export interface FormatExportParams { + sessionId: string + sessionName: string + format: ExportFormat + timeFilter?: { startTs: number; endTs: number } +} + +export interface FormatExportResult { + success: boolean + error?: string + totalMessages: number + content: string + filename: string + mimeType: string +} + +interface MessageRow { + ts: number + senderName: string + content: string | null +} + +function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endTs: number }): MessageRow[] { + const hasFilter = !!timeFilter + const sql = ` + SELECT msg.ts, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + msg.content + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${hasFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''} + ORDER BY msg.ts ASC, msg.id ASC + ` + const params: unknown[] = [] + if (hasFilter) { + params.push(timeFilter!.startTs, timeFilter!.endTs) + } + return db.prepare(sql).all(...params) as unknown as MessageRow[] +} + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleString() +} + +function formatTimeShort(ts: number): string { + return new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +function exportAsTxt(messages: MessageRow[], sessionName: string): string { + const lines: string[] = [`${sessionName}\n`] + let lastDate = '' + for (const msg of messages) { + const date = new Date(msg.ts * 1000).toLocaleDateString() + if (date !== lastDate) { + lines.push(`\n--- ${date} ---\n`) + lastDate = date + } + lines.push(`${formatTimeShort(msg.ts)} ${msg.senderName}: ${msg.content || '[non-text]'}`) + } + return lines.join('\n') +} + +function exportAsJson(messages: MessageRow[], sessionName: string): string { + const data = { + sessionName, + exportTime: new Date().toISOString(), + totalMessages: messages.length, + messages: messages.map((msg) => ({ + timestamp: msg.ts, + time: formatTime(msg.ts), + sender: msg.senderName, + content: msg.content || '', + })), + } + return JSON.stringify(data, null, 2) +} + +function exportAsMarkdown(messages: MessageRow[], sessionName: string): string { + const lines: string[] = [`# ${sessionName}\n`, `> Export time: ${new Date().toLocaleString()}\n`] + let lastDate = '' + for (const msg of messages) { + const date = new Date(msg.ts * 1000).toLocaleDateString() + if (date !== lastDate) { + lines.push(`\n## ${date}\n`) + lastDate = date + } + lines.push(`**${formatTimeShort(msg.ts)} ${msg.senderName}**: ${msg.content || '*[non-text]*'}`) + } + return lines.join('\n') +} + +const FORMAT_CONFIG: Record = { + txt: { ext: 'txt', mime: 'text/plain; charset=utf-8' }, + json: { ext: 'json', mime: 'application/json; charset=utf-8' }, + markdown: { ext: 'md', mime: 'text/markdown; charset=utf-8' }, +} + +export function exportWithFormat( + params: FormatExportParams, + openDatabase: (sessionId: string) => DatabaseAdapter | null +): FormatExportResult { + const db = openDatabase(params.sessionId) + if (!db) { + return { success: false, error: 'Cannot open database', totalMessages: 0, content: '', filename: '', mimeType: '' } + } + + try { + const messages = queryMessages(db, params.timeFilter) + if (messages.length === 0) { + return { + success: false, + error: 'No messages found in the specified range', + totalMessages: 0, + content: '', + filename: '', + mimeType: '', + } + } + + const { ext, mime } = FORMAT_CONFIG[params.format] + let content: string + switch (params.format) { + case 'txt': + content = exportAsTxt(messages, params.sessionName) + break + case 'json': + content = exportAsJson(messages, params.sessionName) + break + case 'markdown': + content = exportAsMarkdown(messages, params.sessionName) + break + } + + const filename = `${params.sessionName}_export.${ext}` + return { success: true, totalMessages: messages.length, content, filename, mimeType: mime } + } catch (error) { + return { + success: false, + error: String(error), + totalMessages: 0, + content: '', + filename: '', + mimeType: '', + } + } +} diff --git a/packages/node-runtime/src/export/index.ts b/packages/node-runtime/src/export/index.ts index dfb8c764..cd94849e 100644 --- a/packages/node-runtime/src/export/index.ts +++ b/packages/node-runtime/src/export/index.ts @@ -7,3 +7,6 @@ export type { ExportDeps, ExportResult, } from './markdown-exporter' + +export { exportWithFormat } from './format-exporter' +export type { ExportFormat, FormatExportParams, FormatExportResult } from './format-exporter' diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index f32435fa..cd882daf 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -261,8 +261,8 @@ export type { PiModelConfig, BuildPiModelOptions } from './ai' export { fetchRemoteModels, validateApiKey } from './ai' export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './ai' -// Markdown export engine -export { exportFilterResultToMarkdown } from './export' +// Export engine +export { exportFilterResultToMarkdown, exportWithFormat } from './export' export type { ExportFilterParams, ExportProgress, @@ -270,6 +270,9 @@ export type { ExportWriter, ExportDeps, ExportResult, + ExportFormat, + FormatExportParams, + FormatExportResult, } from './export' // Session cache (overview + members JSON file cache) diff --git a/packages/node-runtime/src/services/export-service.ts b/packages/node-runtime/src/services/export-service.ts index 2ebdcea7..2410e600 100644 --- a/packages/node-runtime/src/services/export-service.ts +++ b/packages/node-runtime/src/services/export-service.ts @@ -1,10 +1,11 @@ /** * Shared export service. * - * Wraps node-runtime's Markdown exporter with adapter-based DB opening. + * Wraps node-runtime's exporters with adapter-based DB opening. */ -import { exportFilterResultToMarkdown, type ExportFilterParams, type ExportResult } from '../export' +import { exportFilterResultToMarkdown, exportWithFormat, type ExportFilterParams, type ExportResult } from '../export' +import type { ExportFormat, FormatExportResult } from '../export' import type { SessionRuntimeAdapter } from './adapters' export function exportMarkdown( @@ -30,3 +31,15 @@ export function exportMarkdown( ) return { result, content: chunks.join('') } } + +export function exportFormatted( + adapter: SessionRuntimeAdapter, + params: { + sessionId: string + sessionName: string + format: ExportFormat + timeFilter?: { startTs: number; endTs: number } + } +): FormatExportResult { + return exportWithFormat(params, (sessionId) => adapter.openReadonly(sessionId)) +} diff --git a/src/components/MessageExport/ConditionPanel.vue b/src/components/MessageExport/ConditionPanel.vue index 5d716934..db3e7b51 100644 --- a/src/components/MessageExport/ConditionPanel.vue +++ b/src/components/MessageExport/ConditionPanel.vue @@ -1,39 +1,25 @@ diff --git a/src/components/MessageExport/FilterHistory.vue b/src/components/MessageExport/FilterHistory.vue deleted file mode 100644 index 7a2420bf..00000000 --- a/src/components/MessageExport/FilterHistory.vue +++ /dev/null @@ -1,200 +0,0 @@ - - - diff --git a/src/components/MessageExport/MessageExportModal.vue b/src/components/MessageExport/MessageExportModal.vue index 68362a4a..70861ffd 100644 --- a/src/components/MessageExport/MessageExportModal.vue +++ b/src/components/MessageExport/MessageExportModal.vue @@ -1,22 +1,14 @@