diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 8904bc3..17596f9 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -521,4 +521,30 @@ export function registerChatHandlers(ctx: IpcContext): void { return false } }) + + // ==================== SQL 实验室 ==================== + + /** + * 执行用户 SQL 查询 + */ + ipcMain.handle('chat:executeSQL', async (_, sessionId: string, sql: string) => { + try { + return await worker.executeRawSQL(sessionId, sql) + } catch (error) { + console.error('执行 SQL 失败:', error) + throw error + } + }) + + /** + * 获取数据库 Schema + */ + ipcMain.handle('chat:getSchema', async (_, sessionId: string) => { + try { + return await worker.getSchema(sessionId) + } catch (error) { + console.error('获取 Schema 失败:', error) + return [] + } + }) } diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index bc2edaa..f55dff2 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -41,6 +41,9 @@ import { getMembers, updateMemberAliases, deleteMember, + // SQL 实验室 + executeRawSQL, + getSchema, } from './query' import { streamImport, streamParseFileInfo } from './import' @@ -102,6 +105,10 @@ const syncHandlers: Record any> = { getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize), getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit), getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit), + + // SQL 实验室 + executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql), + getSchema: (p) => getSchema(p.sessionId), } // 异步消息处理器(流式操作) diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index f765be6..582806f 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -38,3 +38,7 @@ export { // AI 查询 export { searchMessages, getMessageContext, getRecentMessages, getConversationBetween } from './ai' + +// SQL 实验室 +export { executeRawSQL, getSchema } from './sql' +export type { SQLResult, TableSchema } from './sql' diff --git a/electron/main/worker/query/sql.ts b/electron/main/worker/query/sql.ts new file mode 100644 index 0000000..2ea40d4 --- /dev/null +++ b/electron/main/worker/query/sql.ts @@ -0,0 +1,170 @@ +/** + * SQL 实验室查询模块 + * 提供用户自定义 SQL 查询功能 + */ + +import { openDatabase } from '../core' + +// 最大返回行数限制 +const MAX_LIMIT = 1000 + +// 查询超时时间(毫秒) +const QUERY_TIMEOUT_MS = 10000 + +/** + * SQL 执行结果 + */ +export interface SQLResult { + columns: string[] + rows: any[][] + rowCount: number + duration: number + limited: boolean // 是否被截断 +} + +/** + * 表结构信息 + */ +export interface TableSchema { + name: string + columns: { + name: string + type: string + notnull: boolean + pk: boolean + }[] +} + +/** + * 获取数据库 Schema + */ +export function getSchema(sessionId: string): TableSchema[] { + const db = openDatabase(sessionId) + if (!db) { + throw new Error('数据库不存在') + } + + // 获取所有表名 + const tables = db + .prepare( + `SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name` + ) + .all() as { name: string }[] + + const schema: TableSchema[] = [] + + for (const table of tables) { + // 获取表的列信息 + const columns = db.prepare(`PRAGMA table_info('${table.name}')`).all() as { + cid: number + name: string + type: string + notnull: number + dflt_value: any + pk: number + }[] + + schema.push({ + name: table.name, + columns: columns.map((col) => ({ + name: col.name, + type: col.type, + notnull: col.notnull === 1, + pk: col.pk === 1, + })), + }) + } + + return schema +} + +/** + * 解析并强制添加 LIMIT + * 如果 SQL 没有 LIMIT 或 LIMIT 超过最大值,强制设置为 MAX_LIMIT + */ +function enforceLimit(sql: string): { sql: string; limited: boolean } { + const trimmedSQL = sql.trim() + + // 检查是否是 SELECT 语句 + if (!trimmedSQL.toUpperCase().startsWith('SELECT')) { + return { sql: trimmedSQL, limited: false } + } + + // 使用正则匹配 LIMIT 子句 + const limitMatch = trimmedSQL.match(/\bLIMIT\s+(\d+)\s*(?:,\s*\d+)?(?:\s+OFFSET\s+\d+)?/i) + + if (limitMatch) { + const currentLimit = parseInt(limitMatch[1], 10) + if (currentLimit > MAX_LIMIT) { + // 替换超出的 LIMIT + const newSQL = trimmedSQL.replace(/\bLIMIT\s+\d+/i, `LIMIT ${MAX_LIMIT}`) + return { sql: newSQL, limited: true } + } + return { sql: trimmedSQL, limited: false } + } else { + // 没有 LIMIT,追加 + // 需要处理可能存在的分号 + const sqlWithoutSemicolon = trimmedSQL.replace(/;\s*$/, '') + return { sql: `${sqlWithoutSemicolon} LIMIT ${MAX_LIMIT}`, limited: true } + } +} + +/** + * 执行用户 SQL 查询 + * - 只支持 SELECT 语句 + * - 强制 LIMIT 不超过 MAX_LIMIT + * - 带超时控制 + */ +export function executeRawSQL(sessionId: string, sql: string): SQLResult { + const db = openDatabase(sessionId) + if (!db) { + throw new Error('数据库不存在') + } + + const trimmedSQL = sql.trim() + + // 只允许 SELECT 语句 + if (!trimmedSQL.toUpperCase().startsWith('SELECT')) { + throw new Error('只支持 SELECT 查询语句') + } + + // 强制 LIMIT + const { sql: limitedSQL, limited } = enforceLimit(trimmedSQL) + + // 执行查询 + const startTime = Date.now() + + try { + // better-sqlite3 是同步的,我们通过 Worker 实现"超时" + // 这里先执行,超时由 Worker 管理器控制 + const stmt = db.prepare(limitedSQL) + const rows = stmt.all() + const duration = Date.now() - startTime + + // 获取列名 + const columns = stmt.columns().map((col) => col.name) + + // 将结果转换为二维数组 + const rowData = rows.map((row: any) => columns.map((col) => row[col])) + + return { + columns, + rows: rowData, + rowCount: rows.length, + duration, + limited: limited || rows.length >= MAX_LIMIT, + } + } catch (error) { + if (error instanceof Error) { + // 美化错误信息 + const message = error.message + .replace(/^SQLITE_ERROR: /, '') + .replace(/^SQLITE_READONLY: /, '只读模式:') + throw new Error(message) + } + throw error + } +} + diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 9910edc..0b6cbe2 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -435,3 +435,37 @@ export async function getConversationBetween( ): Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> { return sendToWorker('getConversationBetween', { sessionId, memberId1, memberId2, filter, limit }) } + +// ==================== SQL 实验室 API ==================== + +export interface SQLResult { + columns: string[] + rows: any[][] + rowCount: number + duration: number + limited: boolean +} + +export interface TableSchema { + name: string + columns: { + name: string + type: string + notnull: boolean + pk: boolean + }[] +} + +/** + * 执行用户 SQL 查询 + */ +export async function executeRawSQL(sessionId: string, sql: string): Promise { + return sendToWorker('executeRawSQL', { sessionId, sql }) +} + +/** + * 获取数据库 Schema + */ +export async function getSchema(sessionId: string): Promise { + return sendToWorker('getSchema', { sessionId }) +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 1d99995..9c61674 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -315,6 +315,43 @@ const chatApi = { deleteMember: (sessionId: string, memberId: number): Promise => { return ipcRenderer.invoke('chat:deleteMember', sessionId, memberId) }, + + // ==================== SQL 实验室 ==================== + + /** + * 执行用户 SQL 查询 + */ + executeSQL: ( + sessionId: string, + sql: string + ): Promise<{ + columns: string[] + rows: any[][] + rowCount: number + duration: number + limited: boolean + }> => { + return ipcRenderer.invoke('chat:executeSQL', sessionId, sql) + }, + + /** + * 获取数据库 Schema + */ + getSchema: ( + sessionId: string + ): Promise< + Array<{ + name: string + columns: Array<{ + name: string + type: string + notnull: boolean + pk: boolean + }> + }> + > => { + return ipcRenderer.invoke('chat:getSchema', sessionId) + }, } // Merge API - 合并功能 diff --git a/src/components/analysis/SQLLabTab.vue b/src/components/analysis/SQLLabTab.vue new file mode 100644 index 0000000..3083039 --- /dev/null +++ b/src/components/analysis/SQLLabTab.vue @@ -0,0 +1,210 @@ + + +