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
This commit is contained in:
digua committed 2026-05-13 21:25:50 +08:00
1 parent d14931bf1b
commit 6695181759
9 files changed
+307 -21

No files matched your search

@@ -10,6 +10,12 @@
* 预编译语句接口
*/
export interface PreparedStatement {
/**
* 语句是否为只读(SELECT / WITH ... SELECT)
* better-sqlite3 原生支持此属性,用于安全检查。
*/
readonly?: boolean
/**
* 执行查询并返回第一行结果
*/
@@ -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<string, unknown> | undefined {
return this.stmt.get(...params) as Record<string, unknown> | undefined
+26 -3
View File
@@ -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<string, unknown> }
}>('/_web/sessions/:id/query', async (request) => {
const db = ensureDb(dbManager, request.params.id)
const { sql, params = [] } = request.body as { sql: string; params?: unknown[] | Record<string, unknown> }
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 () => {
+6
View File
@@ -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))
})
+21 -13
View File
@@ -121,22 +121,25 @@ export async function streamImport(
const sessionId = generateSessionId()
const dbPath = dbManager.getDbPath(sessionId)
let db: ReturnType<typeof openBetterSqliteDatabase> | 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 {
+3 -3
View File
@@ -357,10 +357,10 @@ export class FetchAdapter implements QueryAdapter {
return get('/_web/supported-formats')
}
// ==================== 插件系统(不支持) ====================
// ==================== 插件系统 ====================
pluginQuery<T = Record<string, unknown>>(_sessionId: string, _sql: string, _params?: unknown[]): Promise<T[]> {
notImplemented('pluginQuery')
pluginQuery<T = Record<string, unknown>>(sessionId: string, sql: string, params?: unknown[]): Promise<T[]> {
return post<T[]>(`/_web/sessions/${sessionId}/query`, { sql, params: params ?? [] })
}
pluginCompute<T = unknown>(_fnString: string, _input: unknown): Promise<T> {
+3
View File
@@ -30,6 +30,9 @@ export async function initAdapter(): Promise<QueryAdapter> {
} else {
const { FetchAdapter } = await import('./fetch')
_adapter = new FetchAdapter()
const { installWebApiShims } = await import('./web-api-shim')
installWebApiShims(_adapter)
}
return _adapter!
+230
View File
@@ -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<T>(adapter: QueryAdapter, sessionId: string, sql: string, params: unknown[] = []) {
return adapter.pluginQuery<T>(sessionId, sql, params)
}
async function getMessagesBefore(
adapter: QueryAdapter,
sessionId: string,
beforeId: number,
limit: number = 50,
filter?: TimeFilter,
senderId?: number,
keywords?: string[]
): Promise<PaginatedMessages> {
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<any>(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<PaginatedMessages> {
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<any>(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<any[]> {
const ids = Array.isArray(messageIds) ? messageIds : [messageIds]
if (ids.length === 0) return []
const allIds = new Set<number>()
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<any>(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<any>(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<any>(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 = <T>(sid: string, sql: string, params?: unknown[]) => adapter.pluginQuery<T>(sid, sql, params)
chatApi.pluginCompute = <T>(fnString: string, input: unknown): Promise<T> => {
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)
}
+7 -1
View File
@@ -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('/')
}
}
// 关闭删除确认弹窗