From 669518175966b27d96fdc8fff98755dcc0cd4649 Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 13 May 2026 21:25:50 +0800 Subject: [PATCH] fix(web): resolve runtime errors in web mode - Fix streamImport error handling: close db before deleting files on failure - Add POST /_web/sessions/:id/query endpoint for parameterized readonly SQL - Expose PreparedStatement.readonly property in DatabaseAdapter interface - Fix Fastify error handler to respect statusCode on non-ApiError errors - Implement FetchAdapter.pluginQuery via HTTP endpoint - Add web-api-shim.ts: window.chatApi/aiApi polyfills for chart-* and MessageList (pluginQuery, pluginCompute, getMemberActivity, getAvailableYears, message browsing methods) - Fix Sidebar: navigate to home after deleting the currently viewed session --- .../core/src/interfaces/database-adapter.ts | 6 + .../src/better-sqlite3-adapter.ts | 6 +- packages/server/src/http/routes/web.ts | 29 ++- packages/server/src/http/server.ts | 6 + packages/server/src/import/stream-import.ts | 34 ++- src/adapters/fetch.ts | 6 +- src/adapters/index.ts | 3 + src/adapters/web-api-shim.ts | 230 ++++++++++++++++++ src/components/common/Sidebar.vue | 8 +- 9 files changed, 307 insertions(+), 21 deletions(-) create mode 100644 src/adapters/web-api-shim.ts diff --git a/packages/core/src/interfaces/database-adapter.ts b/packages/core/src/interfaces/database-adapter.ts index 040f8b22..da947523 100644 --- a/packages/core/src/interfaces/database-adapter.ts +++ b/packages/core/src/interfaces/database-adapter.ts @@ -10,6 +10,12 @@ * 预编译语句接口 */ export interface PreparedStatement { + /** + * 语句是否为只读(SELECT / WITH ... SELECT) + * better-sqlite3 原生支持此属性,用于安全检查。 + */ + readonly?: boolean + /** * 执行查询并返回第一行结果 */ diff --git a/packages/node-runtime/src/better-sqlite3-adapter.ts b/packages/node-runtime/src/better-sqlite3-adapter.ts index 68bca069..d1a43344 100644 --- a/packages/node-runtime/src/better-sqlite3-adapter.ts +++ b/packages/node-runtime/src/better-sqlite3-adapter.ts @@ -9,7 +9,11 @@ import Database from 'better-sqlite3' import type { DatabaseAdapter, PreparedStatement, RunResult } from '@openchatlab/core' class BetterSqlitePreparedStatement implements PreparedStatement { - constructor(private stmt: Database.Statement) {} + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } get(...params: unknown[]): Record | undefined { return this.stmt.get(...params) as Record | undefined diff --git a/packages/server/src/http/routes/web.ts b/packages/server/src/http/routes/web.ts index 2a940b7d..418ee3bf 100644 --- a/packages/server/src/http/routes/web.ts +++ b/packages/server/src/http/routes/web.ts @@ -36,9 +36,7 @@ function resolveNativeBinding(): string | undefined { function ensureDb(dbManager: DatabaseManager, sessionId: string) { const db = dbManager.open(sessionId) if (!db) { - const err = new Error(`Session not found: ${sessionId}`) - ;(err as any).statusCode = 404 - throw err + throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 }) } return db } @@ -383,6 +381,31 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa }) }) + // ==================== 插件查询(参数化只读 SQL) ==================== + + server.post<{ + Params: { id: string } + Body: { sql: string; params?: unknown[] | Record } + }>('/_web/sessions/:id/query', async (request) => { + const db = ensureDb(dbManager, request.params.id) + const { sql, params = [] } = request.body as { sql: string; params?: unknown[] | Record } + + if (!sql || typeof sql !== 'string') { + throw Object.assign(new Error('Missing or invalid "sql" field'), { statusCode: 400 }) + } + + const stmt = db.prepare(sql.trim()) + + if (!stmt.readonly) { + throw Object.assign(new Error('Only READ-ONLY statements are allowed'), { statusCode: 403 }) + } + + if (Array.isArray(params)) { + return stmt.all(...params) + } + return stmt.all(params) + }) + // ==================== 导入管线 ==================== server.get('/_web/supported-formats', async () => { diff --git a/packages/server/src/http/server.ts b/packages/server/src/http/server.ts index 554b17fb..702ead50 100644 --- a/packages/server/src/http/server.ts +++ b/packages/server/src/http/server.ts @@ -30,6 +30,12 @@ export function createServer(): FastifyInstance { return } + const statusCode = (error as any).statusCode + if (statusCode && statusCode >= 400 && statusCode < 600) { + reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) + return + } + const err = serverError(error.message) reply.code(err.statusCode).send(errorResponse(err)) }) diff --git a/packages/server/src/import/stream-import.ts b/packages/server/src/import/stream-import.ts index 4e774bba..a97e658c 100644 --- a/packages/server/src/import/stream-import.ts +++ b/packages/server/src/import/stream-import.ts @@ -121,22 +121,25 @@ export async function streamImport( const sessionId = generateSessionId() const dbPath = dbManager.getDbPath(sessionId) + let db: ReturnType | null = null try { - const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + db = openBetterSqliteDatabase(dbPath, { nativeBinding }) db.exec(CHAT_DB_SCHEMA) - db.prepare( - `INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id, schema_version) + db! + .prepare( + `INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id, schema_version) VALUES (?, ?, ?, ?, ?, ?, ?, 4)` - ).run( - parsedMeta.name, - parsedMeta.platform, - parsedMeta.type, - Math.floor(Date.now() / 1000), - parsedMeta.groupId || null, - parsedMeta.groupAvatar || null, - parsedMeta.ownerId || null - ) + ) + .run( + parsedMeta.name, + parsedMeta.platform, + parsedMeta.type, + Math.floor(Date.now() / 1000), + parsedMeta.groupId || null, + parsedMeta.groupAvatar || null, + parsedMeta.ownerId || null + ) const insertMember = db.prepare( `INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar, roles) @@ -176,7 +179,7 @@ export async function streamImport( '[]' ) senderId = ( - db.prepare('SELECT id FROM member WHERE platform_id = ?').get(msg.senderPlatformId) as { id: number } + db!.prepare('SELECT id FROM member WHERE platform_id = ?').get(msg.senderPlatformId) as { id: number } )?.id if (senderId) memberIdMap.set(msg.senderPlatformId, senderId) } @@ -229,6 +232,11 @@ export async function streamImport( } } catch (err) { const msg = err instanceof Error ? err.message : String(err) + try { + db?.close() + } catch { + /* ignore */ + } try { fs.unlinkSync(dbPath) } catch { diff --git a/src/adapters/fetch.ts b/src/adapters/fetch.ts index a7c75c32..0b5cf311 100644 --- a/src/adapters/fetch.ts +++ b/src/adapters/fetch.ts @@ -357,10 +357,10 @@ export class FetchAdapter implements QueryAdapter { return get('/_web/supported-formats') } - // ==================== 插件系统(不支持) ==================== + // ==================== 插件系统 ==================== - pluginQuery>(_sessionId: string, _sql: string, _params?: unknown[]): Promise { - notImplemented('pluginQuery') + pluginQuery>(sessionId: string, sql: string, params?: unknown[]): Promise { + return post(`/_web/sessions/${sessionId}/query`, { sql, params: params ?? [] }) } pluginCompute(_fnString: string, _input: unknown): Promise { diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 72cc3145..e04356c5 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -30,6 +30,9 @@ export async function initAdapter(): Promise { } else { const { FetchAdapter } = await import('./fetch') _adapter = new FetchAdapter() + + const { installWebApiShims } = await import('./web-api-shim') + installWebApiShims(_adapter) } return _adapter! diff --git a/src/adapters/web-api-shim.ts b/src/adapters/web-api-shim.ts new file mode 100644 index 00000000..483e2e01 --- /dev/null +++ b/src/adapters/web-api-shim.ts @@ -0,0 +1,230 @@ +/** + * Web 模式下的 window.chatApi / window.aiApi 垫片 + * + * chart-* 包和 MessageList 等组件直接调用 window.chatApi / window.aiApi, + * 在非 Electron 环境下通过 pluginQuery 和 FetchAdapter 提供等价实现。 + */ + +import type { QueryAdapter } from './types' + +interface TimeFilter { + startTs?: number + endTs?: number + memberId?: number +} + +interface PaginatedMessages { + messages: any[] + hasMore: boolean + total?: number +} + +const MSG_SELECT = ` + SELECT + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + m.aliases, + m.avatar, + msg.content, + msg.ts as timestamp, + msg.type, + msg.reply_to_message_id as replyToMessageId, + reply_msg.content as replyToContent, + COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName + FROM message msg + 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 +` + +function buildConditions(filter?: TimeFilter, senderId?: number, keywords?: string[]) { + const conds: string[] = [] + const params: unknown[] = [] + + if (filter?.startTs != null) { + conds.push('msg.ts >= ?') + params.push(filter.startTs) + } + if (filter?.endTs != null) { + conds.push('msg.ts <= ?') + params.push(filter.endTs) + } + if (senderId != null) { + conds.push('msg.sender_id = ?') + params.push(senderId) + } + if (keywords && keywords.length > 0) { + const kwConds = keywords.map(() => 'msg.content LIKE ?') + conds.push(`(${kwConds.join(' OR ')})`) + params.push(...keywords.map((k) => `%${k}%`)) + } + + return { clause: conds.length > 0 ? 'AND ' + conds.join(' AND ') : '', params } +} + +function pq(adapter: QueryAdapter, sessionId: string, sql: string, params: unknown[] = []) { + return adapter.pluginQuery(sessionId, sql, params) +} + +async function getMessagesBefore( + adapter: QueryAdapter, + sessionId: string, + beforeId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): Promise { + const { clause, params } = buildConditions(filter, senderId, keywords) + const sql = `${MSG_SELECT} WHERE msg.id < ? ${clause} ORDER BY msg.id DESC LIMIT ?` + const rows = await pq(adapter, sessionId, sql, [beforeId, ...params, limit + 1]) + const hasMore = rows.length > limit + const messages = hasMore ? rows.slice(0, limit) : rows + return { messages: messages.reverse(), hasMore } +} + +async function getMessagesAfter( + adapter: QueryAdapter, + sessionId: string, + afterId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): Promise { + const { clause, params } = buildConditions(filter, senderId, keywords) + const sql = `${MSG_SELECT} WHERE msg.id > ? ${clause} ORDER BY msg.id ASC LIMIT ?` + const rows = await pq(adapter, sessionId, sql, [afterId, ...params, limit + 1]) + const hasMore = rows.length > limit + const messages = hasMore ? rows.slice(0, limit) : rows + return { messages, hasMore } +} + +async function getMessageContext( + adapter: QueryAdapter, + sessionId: string, + messageIds: number | number[], + contextSize: number = 20 +): Promise { + const ids = Array.isArray(messageIds) ? messageIds : [messageIds] + if (ids.length === 0) return [] + + const allIds = new Set() + + for (const id of ids) { + allIds.add(id) + if (contextSize > 0) { + const before = await pq<{ id: number }>( + adapter, + sessionId, + 'SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?', + [id, contextSize] + ) + before.forEach((r) => allIds.add(r.id)) + + const after = await pq<{ id: number }>( + adapter, + sessionId, + 'SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?', + [id, contextSize] + ) + after.forEach((r) => allIds.add(r.id)) + } + } + + const idList = Array.from(allIds).sort((a, b) => a - b) + const placeholders = idList.map(() => '?').join(', ') + const sql = `${MSG_SELECT} WHERE msg.id IN (${placeholders}) ORDER BY msg.id ASC` + return pq(adapter, sessionId, sql, idList) +} + +async function searchMessages( + adapter: QueryAdapter, + sessionId: string, + keywords: string[], + filter?: TimeFilter, + limit: number = 100, + offset: number = 0, + senderId?: number +): Promise<{ messages: any[]; total: number }> { + const { clause, params } = buildConditions(filter, senderId, keywords) + const countSql = `SELECT COUNT(*) as total FROM message msg JOIN member m ON msg.sender_id = m.id WHERE 1=1 ${clause}` + const countResult = await pq<{ total: number }>(adapter, sessionId, countSql, params) + const total = countResult[0]?.total ?? 0 + + const sql = `${MSG_SELECT} WHERE 1=1 ${clause} ORDER BY msg.ts DESC LIMIT ? OFFSET ?` + const messages = await pq(adapter, sessionId, sql, [...params, limit, offset]) + return { messages, total } +} + +async function getAllRecentMessages( + adapter: QueryAdapter, + sessionId: string, + filter?: TimeFilter, + limit: number = 100 +): Promise<{ messages: any[]; total: number }> { + const { clause, params } = buildConditions(filter) + const countSql = `SELECT COUNT(*) as total FROM message msg JOIN member m ON msg.sender_id = m.id WHERE 1=1 ${clause}` + const countResult = await pq<{ total: number }>(adapter, sessionId, countSql, params) + const total = countResult[0]?.total ?? 0 + + const sql = `${MSG_SELECT} WHERE 1=1 ${clause} ORDER BY msg.ts DESC LIMIT ?` + const messages = await pq(adapter, sessionId, sql, [...params, limit]) + return { messages: messages.reverse(), total } +} + +/** + * 注入 window.chatApi 和 window.aiApi 垫片 + */ +export function installWebApiShims(adapter: QueryAdapter): void { + if (!window.chatApi) { + ;(window as any).chatApi = {} + } + if (!(window as any).aiApi) { + ;(window as any).aiApi = {} + } + + const chatApi = (window as any).chatApi + const aiApi = (window as any).aiApi + + // ===== window.chatApi 垫片 ===== + chatApi.pluginQuery = (sid: string, sql: string, params?: unknown[]) => adapter.pluginQuery(sid, sql, params) + chatApi.pluginCompute = (fnString: string, input: unknown): Promise => { + const fn = new Function('return ' + fnString)() + return Promise.resolve(fn(input)) + } + chatApi.getMemberActivity = (sid: string, f?: any) => adapter.getMemberActivity(sid, f) + chatApi.getAvailableYears = (sid: string) => adapter.getAvailableYears(sid) + + // ===== window.aiApi 消息查询垫片 ===== + aiApi.getMessagesBefore = ( + sid: string, + beforeId: number, + limit: number, + filter?: any, + senderId?: number, + kw?: string[] + ) => getMessagesBefore(adapter, sid, beforeId, limit, filter, senderId, kw) + aiApi.getMessagesAfter = ( + sid: string, + afterId: number, + limit: number, + filter?: any, + senderId?: number, + kw?: string[] + ) => getMessagesAfter(adapter, sid, afterId, limit, filter, senderId, kw) + aiApi.getMessageContext = (sid: string, msgIds: number | number[], ctxSize?: number) => + getMessageContext(adapter, sid, msgIds, ctxSize) + aiApi.searchMessages = ( + sid: string, + kw: string[], + filter?: any, + limit?: number, + offset?: number, + senderId?: number + ) => searchMessages(adapter, sid, kw, filter, limit, offset, senderId) + aiApi.getAllRecentMessages = (sid: string, filter?: any, limit?: number) => + getAllRecentMessages(adapter, sid, filter, limit) +} diff --git a/src/components/common/Sidebar.vue b/src/components/common/Sidebar.vue index a300482e..cf93cddd 100644 --- a/src/components/common/Sidebar.vue +++ b/src/components/common/Sidebar.vue @@ -133,9 +133,15 @@ function openDeleteModal(session: AnalysisSession) { async function confirmDelete() { if (!deleteTarget.value) return - await sessionStore.deleteSession(deleteTarget.value.id) + const deletedId = deleteTarget.value.id + const isViewingDeleted = route.params.id === deletedId + await sessionStore.deleteSession(deletedId) showDeleteModal.value = false deleteTarget.value = null + + if (isViewingDeleted) { + router.push('/') + } } // 关闭删除确认弹窗