mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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('/')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭删除确认弹窗
|
||||
|
||||
Reference in new issue
Block a user