Compare commits

...

2 Commits
main ... aitest

Author SHA1 Message Date
cc
a9470bcb9a 瞎改了一通,现在完全不能用了 2026-04-12 11:32:06 +08:00
cc
599fd1af26 feat: ai功能的初次提交 2026-04-11 23:12:03 +08:00
33 changed files with 9577 additions and 2 deletions

View File

@@ -31,6 +31,10 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService' import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService' import { insightService } from './services/insightService'
import { aiAnalysisService } from './services/aiAnalysisService'
import { aiAgentService } from './services/aiAgentService'
import { aiAssistantService } from './services/aiAssistantService'
import { aiSkillService } from './services/aiSkillService'
import { bizService } from './services/bizService' import { bizService } from './services/bizService'
// 配置自动更新 // 配置自动更新
@@ -1598,6 +1602,14 @@ const runLegacySnsCacheMigration = async (
return { copied, skipped, totalFiles: total } return { copied, skipped, totalFiles: total }
} }
async function ensureAiSqlLabConnected(): Promise<{ success: boolean; error?: string }> {
const connectResult = await chatService.connect()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
return { success: true }
}
// 注册 IPC 处理器 // 注册 IPC 处理器
function registerIpcHandlers() { function registerIpcHandlers() {
registerNotificationHandlers() registerNotificationHandlers()
@@ -1651,6 +1663,305 @@ function registerIpcHandlers() {
return insightService.generateFootprintInsight(payload) return insightService.generateFootprintInsight(payload)
}) })
// ==================== AI Analysis V2 ====================
ipcMain.handle('ai:listConversations', async (_, payload?: { page?: number; pageSize?: number }) =>
aiAnalysisService.listConversations(payload?.page, payload?.pageSize)
)
ipcMain.handle('ai:createConversation', async (_, payload?: { title?: string }) =>
aiAnalysisService.createConversation(payload?.title || '')
)
ipcMain.handle('ai:renameConversation', async (_, payload: { conversationId: string; title: string }) =>
aiAnalysisService.renameConversation(payload.conversationId, payload.title)
)
ipcMain.handle('ai:deleteConversation', async (_, conversationId: string) =>
aiAnalysisService.deleteConversation(conversationId)
)
ipcMain.handle('ai:listMessages', async (_, payload: { conversationId: string; limit?: number }) =>
aiAnalysisService.listMessages(payload.conversationId, payload.limit)
)
ipcMain.handle('ai:exportConversation', async (_, payload: { conversationId: string }) =>
aiAnalysisService.exportConversation(payload.conversationId)
)
ipcMain.handle('ai:getToolCatalog', async () => aiAnalysisService.getToolCatalog())
ipcMain.handle('ai:executeTool', async (_, payload: { name: string; args?: Record<string, any> }) =>
aiAnalysisService.executeTool(payload.name, payload.args || {})
)
ipcMain.handle('ai:cancelToolTest', async (_, payload?: { taskId?: string }) =>
aiAnalysisService.cancelToolTest(payload?.taskId)
)
ipcMain.handle('ai:getMessageContext', async (_, sessionId: string, messageIds: number | number[], contextSize?: number) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return []
return chatService.getMessageContextForAI(sessionId, messageIds, contextSize)
})
ipcMain.handle('ai:getSearchMessageContext', async (_, sessionId: string, messageIds: number[], contextBefore?: number, contextAfter?: number) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return []
return chatService.getSearchMessageContextForAI(sessionId, messageIds, contextBefore, contextAfter)
})
ipcMain.handle('ai:getRecentMessages', async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return { messages: [], total: 0 }
return chatService.getRecentMessagesForAI(sessionId, filter, limit)
})
ipcMain.handle('ai:getAllRecentMessages', async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return { messages: [], total: 0 }
return chatService.getRecentMessagesForAI(sessionId, filter, limit)
})
ipcMain.handle('ai:getConversationBetween', async (
_,
sessionId: string,
memberId1: number,
memberId2: number,
filter?: { startTs?: number; endTs?: number },
limit?: number
) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) {
return { messages: [], total: 0, member1Name: '', member2Name: '' }
}
return chatService.getConversationBetweenForAI(sessionId, memberId1, memberId2, filter, limit)
})
ipcMain.handle('ai:getMessagesBefore', async (
_,
sessionId: string,
beforeId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return { messages: [], hasMore: false }
return chatService.getMessagesBeforeForAI(sessionId, beforeId, limit, filter, senderId, keywords)
})
ipcMain.handle('ai:getMessagesAfter', async (
_,
sessionId: string,
afterId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return { messages: [], hasMore: false }
return chatService.getMessagesAfterForAI(sessionId, afterId, limit, filter, senderId, keywords)
})
ipcMain.handle('ai:searchSessions', async (
_,
sessionId: string,
keywords?: string[],
timeFilter?: { startTs?: number; endTs?: number },
limit?: number,
previewCount?: number
) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return []
return chatService.searchSessionsForAI(sessionId, keywords, timeFilter, limit, previewCount)
})
ipcMain.handle('ai:getSessionMessages', async (_, sessionId: string, chatSessionId: string | number, limit?: number) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return null
return chatService.getSessionMessagesForAI(sessionId, chatSessionId, limit)
})
ipcMain.handle('ai:getSessionSummaries', async (
_,
sessionId: string,
options?: { sessionIds?: string[]; limit?: number; previewCount?: number }
) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) return []
return chatService.getSessionSummariesForAI(sessionId, options)
})
const agentRequestToRunId = new Map<string, string>()
const terminatedAgentRequests = new Set<string>()
const markAgentRequestTerminated = (requestId: string) => {
const normalized = String(requestId || '').trim()
if (!normalized) return
terminatedAgentRequests.add(normalized)
setTimeout(() => {
terminatedAgentRequests.delete(normalized)
}, 120_000)
}
ipcMain.handle('agent:runStream', async (event, requestId: string, payload: {
mode?: 'chat' | 'sql'
conversationId?: string
userInput: string
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
sqlContext?: { schemaText?: string; targetHint?: string }
}) => {
const normalizedRequestId = String(requestId || '').trim() || randomUUID()
terminatedAgentRequests.delete(normalizedRequestId)
const startResult = await aiAgentService.runStream(payload, {
onChunk: (chunk) => {
if (terminatedAgentRequests.has(normalizedRequestId)) return
try {
event.sender.send('agent:streamChunk', { requestId: normalizedRequestId, chunk })
} catch {
// ignore sender errors
}
},
onFinished: (result) => {
if (terminatedAgentRequests.has(normalizedRequestId)) {
agentRequestToRunId.delete(normalizedRequestId)
terminatedAgentRequests.delete(normalizedRequestId)
return
}
try {
if (!result.success) {
event.sender.send('agent:error', {
requestId: normalizedRequestId,
error: result.error || '执行失败',
result: {
success: false,
runId: result.runId,
conversationId: result.conversationId,
error: result.error || ''
}
})
} else {
event.sender.send('agent:complete', {
requestId: normalizedRequestId,
result: {
success: true,
runId: result.runId,
conversationId: result.conversationId,
error: ''
}
})
}
} catch {
// ignore sender errors
} finally {
agentRequestToRunId.delete(normalizedRequestId)
}
}
})
if (startResult.success && startResult.runId) {
agentRequestToRunId.set(normalizedRequestId, startResult.runId)
}
return {
success: Boolean(startResult.success),
requestId: normalizedRequestId
}
})
ipcMain.handle('agent:abort', async (event, payload: string | { requestId?: string; runId?: string; conversationId?: string }) => {
if (typeof payload === 'string') {
const requestId = payload
const runId = agentRequestToRunId.get(requestId) || payload
markAgentRequestTerminated(requestId)
const result = await aiAgentService.abort({ runId })
if (result?.success) {
agentRequestToRunId.delete(requestId)
try {
event.sender.send('agent:cancel', { requestId, runId })
} catch {
// ignore sender errors
}
}
return result
}
const requestId = String(payload?.requestId || '').trim()
if (requestId) {
const runId = agentRequestToRunId.get(requestId)
if (runId) {
markAgentRequestTerminated(requestId)
agentRequestToRunId.delete(requestId)
const result = await aiAgentService.abort({ runId })
if (result?.success) {
try {
event.sender.send('agent:cancel', { requestId, runId })
} catch {
// ignore sender errors
}
}
return result
}
}
const result = await aiAgentService.abort(payload || {})
if (result?.success && requestId) {
markAgentRequestTerminated(requestId)
agentRequestToRunId.delete(requestId)
try {
event.sender.send('agent:cancel', { requestId, runId: String(payload?.runId || '') })
} catch {
// ignore sender errors
}
}
return result
})
ipcMain.handle('assistant:getAll', async () => aiAssistantService.getAll())
ipcMain.handle('assistant:getConfig', async (_, id: string) => aiAssistantService.getConfig(id))
ipcMain.handle('assistant:create', async (_, payload: any) => aiAssistantService.create(payload || {}))
ipcMain.handle('assistant:update', async (_, payload: { id: string; updates: any }) =>
aiAssistantService.update(payload.id, payload.updates || {})
)
ipcMain.handle('assistant:delete', async (_, id: string) => aiAssistantService.delete(id))
ipcMain.handle('assistant:reset', async (_, id: string) => aiAssistantService.reset(id))
ipcMain.handle('assistant:getBuiltinCatalog', async () => aiAssistantService.getBuiltinCatalog())
ipcMain.handle('assistant:getBuiltinToolCatalog', async () => aiAssistantService.getBuiltinToolCatalog())
ipcMain.handle('assistant:importFromMd', async (_, rawMd: string) => aiAssistantService.importFromMd(rawMd))
ipcMain.handle('skill:getAll', async () => aiSkillService.getAll())
ipcMain.handle('skill:getConfig', async (_, id: string) => aiSkillService.getConfig(id))
ipcMain.handle('skill:create', async (_, rawMd: string) => aiSkillService.create(rawMd))
ipcMain.handle('skill:update', async (_, payload: { id: string; rawMd: string }) =>
aiSkillService.update(payload.id, payload.rawMd)
)
ipcMain.handle('skill:delete', async (_, id: string) => aiSkillService.delete(id))
ipcMain.handle('skill:getBuiltinCatalog', async () => aiSkillService.getBuiltinCatalog())
ipcMain.handle('skill:importFromMd', async (_, rawMd: string) => aiSkillService.importFromMd(rawMd))
ipcMain.handle('llm:getConfig', async () => ({
success: true,
config: {
apiBaseUrl: String(configService?.get('aiModelApiBaseUrl') || ''),
apiKey: String(configService?.get('aiModelApiKey') || ''),
model: String(configService?.get('aiModelApiModel') || 'gpt-4o-mini')
}
}))
ipcMain.handle('llm:setConfig', async (_, payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => {
if (typeof payload?.apiBaseUrl === 'string') configService?.set('aiModelApiBaseUrl', payload.apiBaseUrl)
if (typeof payload?.apiKey === 'string') configService?.set('aiModelApiKey', payload.apiKey)
if (typeof payload?.model === 'string') configService?.set('aiModelApiModel', payload.model)
return { success: true }
})
ipcMain.handle('llm:listModels', async () => ({
success: true,
models: [
{ id: 'gpt-4o-mini', label: 'gpt-4o-mini' },
{ id: 'gpt-4o', label: 'gpt-4o' },
{ id: 'gpt-5-mini', label: 'gpt-5-mini' }
]
}))
ipcMain.handle('chat:getSchema', async (_, payload?: { sessionId?: string }) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
return wcdbService.sqlLabGetSchema(payload)
})
ipcMain.handle('chat:executeSQL', async (_, payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}) => {
const connectResult = await ensureAiSqlLabConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
return wcdbService.sqlLabExecuteReadonly(payload)
})
ipcMain.handle('config:clear', async () => { ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false) const result = setSystemLaunchAtStartup(false)

View File

@@ -276,6 +276,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
format: 'csv' | 'json', format: 'csv' | 'json',
filePath: string filePath: string
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath), ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
getSchema: (payload?: { sessionId?: string }) => ipcRenderer.invoke('chat:getSchema', payload),
executeSQL: (payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}) => ipcRenderer.invoke('chat:executeSQL', payload),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback) ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback) return () => ipcRenderer.removeListener('wcdb-change', callback)
@@ -540,5 +547,337 @@ contextBridge.exposeInMainWorld('electronAPI', {
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
},
aiApi: {
listConversations: (payload?: { page?: number; pageSize?: number }) =>
ipcRenderer.invoke('ai:listConversations', payload),
createConversation: (payload?: { title?: string }) =>
ipcRenderer.invoke('ai:createConversation', payload),
renameConversation: (payload: { conversationId: string; title: string }) =>
ipcRenderer.invoke('ai:renameConversation', payload),
deleteConversation: (conversationId: string) =>
ipcRenderer.invoke('ai:deleteConversation', conversationId),
listMessages: (payload: { conversationId: string; limit?: number }) =>
ipcRenderer.invoke('ai:listMessages', payload),
exportConversation: (payload: { conversationId: string }) =>
ipcRenderer.invoke('ai:exportConversation', payload),
getMessageContext: (sessionId: string, messageIds: number | number[], contextSize?: number) =>
ipcRenderer.invoke('ai:getMessageContext', sessionId, messageIds, contextSize),
getSearchMessageContext: (sessionId: string, messageIds: number[], contextBefore?: number, contextAfter?: number) =>
ipcRenderer.invoke('ai:getSearchMessageContext', sessionId, messageIds, contextBefore, contextAfter),
getRecentMessages: (sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) =>
ipcRenderer.invoke('ai:getRecentMessages', sessionId, filter, limit),
getAllRecentMessages: (sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) =>
ipcRenderer.invoke('ai:getAllRecentMessages', sessionId, filter, limit),
getConversationBetween: (
sessionId: string,
memberId1: number,
memberId2: number,
filter?: { startTs?: number; endTs?: number },
limit?: number
) => ipcRenderer.invoke('ai:getConversationBetween', sessionId, memberId1, memberId2, filter, limit),
getMessagesBefore: (
sessionId: string,
beforeId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => ipcRenderer.invoke('ai:getMessagesBefore', sessionId, beforeId, limit, filter, senderId, keywords),
getMessagesAfter: (
sessionId: string,
afterId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords),
searchSessions: (
sessionId: string,
keywords?: string[],
timeFilter?: { startTs?: number; endTs?: number },
limit?: number,
previewCount?: number
) => ipcRenderer.invoke('ai:searchSessions', sessionId, keywords, timeFilter, limit, previewCount),
getSessionMessages: (sessionId: string, chatSessionId: string | number, limit?: number) =>
ipcRenderer.invoke('ai:getSessionMessages', sessionId, chatSessionId, limit),
getSessionSummaries: (
sessionId: string,
options?: { sessionIds?: string[]; limit?: number; previewCount?: number }
) => ipcRenderer.invoke('ai:getSessionSummaries', sessionId, options),
getToolCatalog: () => ipcRenderer.invoke('ai:getToolCatalog'),
executeTool: (payload: { name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke('ai:executeTool', payload),
cancelToolTest: (payload?: { taskId?: string }) =>
ipcRenderer.invoke('ai:cancelToolTest', payload)
},
agentApi: {
runStream: (payload: {
mode?: 'chat' | 'sql'
conversationId?: string
userInput: string
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
sqlContext?: { schemaText?: string; targetHint?: string }
}, onChunk?: (chunk: any) => void) => {
const requestId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const promise = new Promise<{ success: boolean; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean }; error?: string }>((resolve) => {
let settled = false
const cleanup = () => {
ipcRenderer.removeListener('agent:streamChunk', chunkHandler)
ipcRenderer.removeListener('agent:cancel', cancelHandler)
ipcRenderer.removeListener('agent:error', errorHandler)
ipcRenderer.removeListener('agent:complete', completeHandler)
}
const settle = (value: { success: boolean; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean }; error?: string }) => {
if (settled) return
settled = true
cleanup()
resolve(value)
}
const chunkHandler = (_: unknown, data: { requestId: string; chunk: any }) => {
if (data?.requestId !== requestId) return
if (onChunk) onChunk(data.chunk)
}
const errorHandler = (_: unknown, data: { requestId: string; error?: string; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean } }) => {
if (data?.requestId !== requestId) return
settle({
success: false,
error: data?.error || data?.result?.error || '执行失败',
result: data?.result
})
}
const cancelHandler = (_: unknown, data: { requestId: string; runId?: string }) => {
if (data?.requestId !== requestId) return
settle({
success: false,
error: '任务已取消',
result: {
success: false,
runId: data?.runId || '',
conversationId: '',
error: '任务已取消',
canceled: true
} }
}) })
}
const completeHandler = (_: unknown, data: { requestId: string; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean } }) => {
if (data?.requestId !== requestId) return
if (data?.result?.error) {
settle({ success: false, error: data.result.error, result: data.result })
return
}
settle({ success: Boolean(data?.result?.success ?? true), result: data?.result })
}
ipcRenderer.on('agent:streamChunk', chunkHandler)
ipcRenderer.on('agent:cancel', cancelHandler)
ipcRenderer.on('agent:error', errorHandler)
ipcRenderer.on('agent:complete', completeHandler)
ipcRenderer.invoke('agent:runStream', requestId, payload).then((result: { success?: boolean; error?: string }) => {
if (result?.success === false) {
settle({ success: false, error: result.error || '启动失败' })
}
}).catch((error) => {
settle({ success: false, error: String(error) })
})
})
return { requestId, promise }
},
abort: (payload: string | { requestId?: string; runId?: string; conversationId?: string }) =>
ipcRenderer.invoke('agent:abort', payload)
},
assistantApi: {
getAll: () => ipcRenderer.invoke('assistant:getAll'),
getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id),
create: (payload: any) => ipcRenderer.invoke('assistant:create', payload),
update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload),
delete: (id: string) => ipcRenderer.invoke('assistant:delete', id),
reset: (id: string) => ipcRenderer.invoke('assistant:reset', id),
getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'),
getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'),
importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd)
},
skillApi: {
getAll: () => ipcRenderer.invoke('skill:getAll'),
getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id),
create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd),
update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload),
delete: (id: string) => ipcRenderer.invoke('skill:delete', id),
getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'),
importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd)
},
llmApi: {
getConfig: () => ipcRenderer.invoke('llm:getConfig'),
setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) =>
ipcRenderer.invoke('llm:setConfig', payload),
listModels: () => ipcRenderer.invoke('llm:listModels')
},
})
contextBridge.exposeInMainWorld('aiApi', {
listConversations: (payload?: { page?: number; pageSize?: number }) => ipcRenderer.invoke('ai:listConversations', payload),
createConversation: (payload?: { title?: string }) => ipcRenderer.invoke('ai:createConversation', payload),
renameConversation: (payload: { conversationId: string; title: string }) => ipcRenderer.invoke('ai:renameConversation', payload),
deleteConversation: (conversationId: string) => ipcRenderer.invoke('ai:deleteConversation', conversationId),
listMessages: (payload: { conversationId: string; limit?: number }) => ipcRenderer.invoke('ai:listMessages', payload),
exportConversation: (payload: { conversationId: string }) => ipcRenderer.invoke('ai:exportConversation', payload),
getMessageContext: (sessionId: string, messageIds: number | number[], contextSize?: number) =>
ipcRenderer.invoke('ai:getMessageContext', sessionId, messageIds, contextSize),
getSearchMessageContext: (sessionId: string, messageIds: number[], contextBefore?: number, contextAfter?: number) =>
ipcRenderer.invoke('ai:getSearchMessageContext', sessionId, messageIds, contextBefore, contextAfter),
getRecentMessages: (sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) =>
ipcRenderer.invoke('ai:getRecentMessages', sessionId, filter, limit),
getAllRecentMessages: (sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) =>
ipcRenderer.invoke('ai:getAllRecentMessages', sessionId, filter, limit),
getConversationBetween: (
sessionId: string,
memberId1: number,
memberId2: number,
filter?: { startTs?: number; endTs?: number },
limit?: number
) => ipcRenderer.invoke('ai:getConversationBetween', sessionId, memberId1, memberId2, filter, limit),
getMessagesBefore: (
sessionId: string,
beforeId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => ipcRenderer.invoke('ai:getMessagesBefore', sessionId, beforeId, limit, filter, senderId, keywords),
getMessagesAfter: (
sessionId: string,
afterId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords),
searchSessions: (
sessionId: string,
keywords?: string[],
timeFilter?: { startTs?: number; endTs?: number },
limit?: number,
previewCount?: number
) => ipcRenderer.invoke('ai:searchSessions', sessionId, keywords, timeFilter, limit, previewCount),
getSessionMessages: (sessionId: string, chatSessionId: string | number, limit?: number) =>
ipcRenderer.invoke('ai:getSessionMessages', sessionId, chatSessionId, limit),
getSessionSummaries: (
sessionId: string,
options?: { sessionIds?: string[]; limit?: number; previewCount?: number }
) => ipcRenderer.invoke('ai:getSessionSummaries', sessionId, options),
getToolCatalog: () => ipcRenderer.invoke('ai:getToolCatalog'),
executeTool: (payload: { name: string; args?: Record<string, any> }) => ipcRenderer.invoke('ai:executeTool', payload),
cancelToolTest: (payload?: { taskId?: string }) => ipcRenderer.invoke('ai:cancelToolTest', payload)
})
contextBridge.exposeInMainWorld('agentApi', {
runStream: (payload: {
mode?: 'chat' | 'sql'
conversationId?: string
userInput: string
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
sqlContext?: { schemaText?: string; targetHint?: string }
}, onChunk?: (chunk: any) => void) => {
const requestId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const promise = new Promise<{ success: boolean; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean }; error?: string }>((resolve) => {
let settled = false
const cleanup = () => {
ipcRenderer.removeListener('agent:streamChunk', chunkHandler)
ipcRenderer.removeListener('agent:cancel', cancelHandler)
ipcRenderer.removeListener('agent:error', errorHandler)
ipcRenderer.removeListener('agent:complete', completeHandler)
}
const settle = (value: { success: boolean; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean }; error?: string }) => {
if (settled) return
settled = true
cleanup()
resolve(value)
}
const chunkHandler = (_: unknown, data: { requestId: string; chunk: any }) => {
if (data?.requestId !== requestId) return
if (onChunk) onChunk(data.chunk)
}
const errorHandler = (_: unknown, data: { requestId: string; error?: string; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean } }) => {
if (data?.requestId !== requestId) return
settle({
success: false,
error: data?.error || data?.result?.error || '执行失败',
result: data?.result
})
}
const cancelHandler = (_: unknown, data: { requestId: string; runId?: string }) => {
if (data?.requestId !== requestId) return
settle({
success: false,
error: '任务已取消',
result: {
success: false,
runId: data?.runId || '',
conversationId: '',
error: '任务已取消',
canceled: true
}
})
}
const completeHandler = (_: unknown, data: { requestId: string; result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean } }) => {
if (data?.requestId !== requestId) return
if (data?.result?.error) {
settle({ success: false, error: data.result.error, result: data.result })
return
}
settle({ success: Boolean(data?.result?.success ?? true), result: data?.result })
}
ipcRenderer.on('agent:streamChunk', chunkHandler)
ipcRenderer.on('agent:cancel', cancelHandler)
ipcRenderer.on('agent:error', errorHandler)
ipcRenderer.on('agent:complete', completeHandler)
ipcRenderer.invoke('agent:runStream', requestId, payload).then((result: { success?: boolean; error?: string }) => {
if (result?.success === false) {
settle({ success: false, error: result.error || '启动失败' })
}
}).catch((error) => {
settle({ success: false, error: String(error) })
})
})
return { requestId, promise }
},
abort: (payload: string | { requestId?: string; runId?: string; conversationId?: string }) => ipcRenderer.invoke('agent:abort', payload)
})
contextBridge.exposeInMainWorld('assistantApi', {
getAll: () => ipcRenderer.invoke('assistant:getAll'),
getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id),
create: (payload: any) => ipcRenderer.invoke('assistant:create', payload),
update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload),
delete: (id: string) => ipcRenderer.invoke('assistant:delete', id),
reset: (id: string) => ipcRenderer.invoke('assistant:reset', id),
getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'),
getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'),
importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd)
})
contextBridge.exposeInMainWorld('skillApi', {
getAll: () => ipcRenderer.invoke('skill:getAll'),
getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id),
create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd),
update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload),
delete: (id: string) => ipcRenderer.invoke('skill:delete', id),
getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'),
importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd)
})
contextBridge.exposeInMainWorld('llmApi', {
getConfig: () => ipcRenderer.invoke('llm:getConfig'),
setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => ipcRenderer.invoke('llm:setConfig', payload),
listModels: () => ipcRenderer.invoke('llm:listModels')
})

View File

@@ -0,0 +1,454 @@
import http from 'http'
import https from 'https'
import { randomUUID } from 'crypto'
import { URL } from 'url'
import { ConfigService } from './config'
import { aiAnalysisService, type AiAnalysisRunEvent } from './aiAnalysisService'
export interface TokenUsage {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
export interface AgentRuntimeStatus {
phase: 'idle' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'error' | 'aborted'
round?: number
currentTool?: string
toolsUsed?: number
updatedAt: number
totalUsage?: TokenUsage
}
export interface AgentStreamChunk {
runId: string
conversationId?: string
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error'
content?: string
thinkTag?: string
thinkDurationMs?: number
toolName?: string
toolParams?: Record<string, unknown>
toolResult?: unknown
error?: string
isFinished?: boolean
usage?: TokenUsage
status?: AgentRuntimeStatus
}
export interface AgentRunPayload {
mode?: 'chat' | 'sql'
conversationId?: string
userInput: string
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
sqlContext?: {
schemaText?: string
targetHint?: string
}
}
interface ActiveAgentRun {
runId: string
mode: 'chat' | 'sql'
conversationId?: string
innerRunId?: string
aborted: boolean
}
function normalizeText(value: unknown, fallback = ''): string {
const text = String(value ?? '').trim()
return text || fallback
}
function parseOptionalInt(value: unknown): number | undefined {
const n = Number(value)
return Number.isFinite(n) ? Math.floor(n) : undefined
}
function buildApiUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '')
const suffix = path.startsWith('/') ? path : `/${path}`
return `${base}${suffix}`
}
function extractSqlText(raw: string): string {
const text = normalizeText(raw)
if (!text) return ''
const fenced = text.match(/```(?:sql)?\s*([\s\S]*?)```/i)
if (fenced?.[1]) return fenced[1].trim()
return text
}
class AiAgentService {
private readonly config = ConfigService.getInstance()
private readonly runs = new Map<string, ActiveAgentRun>()
private getSharedModelConfig(): { apiBaseUrl: string; apiKey: string; model: string } {
return {
apiBaseUrl: normalizeText(this.config.get('aiModelApiBaseUrl')),
apiKey: normalizeText(this.config.get('aiModelApiKey')),
model: normalizeText(this.config.get('aiModelApiModel'), 'gpt-4o-mini')
}
}
private emitStatus(
run: ActiveAgentRun,
onChunk: (chunk: AgentStreamChunk) => void,
phase: AgentRuntimeStatus['phase'],
extra?: Partial<AgentRuntimeStatus>
): void {
onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'status',
status: {
phase,
updatedAt: Date.now(),
...extra
}
})
}
private mapRunEventToChunk(
run: ActiveAgentRun,
event: AiAnalysisRunEvent
): AgentStreamChunk | null {
run.innerRunId = event.runId
run.conversationId = event.conversationId || run.conversationId
if (event.stage === 'llm_round_started') {
return {
runId: run.runId,
conversationId: run.conversationId,
type: 'think',
content: event.message,
thinkTag: 'round'
}
}
if (event.stage === 'tool_start') {
return {
runId: run.runId,
conversationId: run.conversationId,
type: 'tool_start',
toolName: event.toolName,
toolParams: (event.data || {}) as Record<string, unknown>
}
}
if (event.stage === 'tool_done' || event.stage === 'tool_error') {
return {
runId: run.runId,
conversationId: run.conversationId,
type: 'tool_result',
toolName: event.toolName,
toolResult: event.data || { status: event.status, durationMs: event.durationMs }
}
}
if (event.stage === 'completed') {
return {
runId: run.runId,
conversationId: run.conversationId,
type: 'status',
status: { phase: 'completed', updatedAt: Date.now() }
}
}
if (event.stage === 'aborted') {
return {
runId: run.runId,
conversationId: run.conversationId,
type: 'status',
status: { phase: 'aborted', updatedAt: Date.now() }
}
}
if (event.stage === 'error') {
return {
runId: run.runId,
conversationId: run.conversationId,
type: 'status',
status: { phase: 'error', updatedAt: Date.now() }
}
}
return null
}
private async callModel(payload: any, apiBaseUrl: string, apiKey: string): Promise<any> {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const body = JSON.stringify(payload)
const urlObj = new URL(endpoint)
return new Promise((resolve, reject) => {
const requestFn = urlObj.protocol === 'https:' ? https.request : http.request
const req = requestFn({
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body).toString(),
Authorization: `Bearer ${apiKey}`
}
}, (res) => {
let data = ''
res.on('data', (chunk) => { data += String(chunk) })
res.on('end', () => {
try {
resolve(JSON.parse(data || '{}'))
} catch (error) {
reject(new Error(`AI 响应解析失败: ${String(error)}`))
}
})
})
req.setTimeout(45_000, () => {
req.destroy()
reject(new Error('AI 请求超时'))
})
req.on('error', reject)
req.write(body)
req.end()
})
}
async runStream(
payload: AgentRunPayload,
runtime: {
onChunk: (chunk: AgentStreamChunk) => void
onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void
}
): Promise<{ success: boolean; runId: string }> {
const runId = randomUUID()
const mode = payload.mode === 'sql' ? 'sql' : 'chat'
const run: ActiveAgentRun = {
runId,
mode,
conversationId: normalizeText(payload.conversationId) || undefined,
aborted: false
}
this.runs.set(runId, run)
this.execute(run, payload, runtime).catch((error) => {
runtime.onChunk({
runId,
conversationId: run.conversationId,
type: 'error',
error: String((error as Error)?.message || error),
isFinished: true
})
runtime.onFinished?.({
success: false,
runId,
conversationId: run.conversationId,
error: String((error as Error)?.message || error)
})
this.runs.delete(runId)
})
return { success: true, runId }
}
private async execute(
run: ActiveAgentRun,
payload: AgentRunPayload,
runtime: {
onChunk: (chunk: AgentStreamChunk) => void
onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void
}
): Promise<void> {
if (run.mode === 'sql') {
await this.executeSqlMode(run, payload, runtime)
return
}
this.emitStatus(run, runtime.onChunk, 'thinking')
const result = await aiAnalysisService.sendMessage(
normalizeText(payload.conversationId),
normalizeText(payload.userInput),
{
assistantId: normalizeText(payload.assistantId),
activeSkillId: normalizeText(payload.activeSkillId),
chatScope: payload.chatScope === 'group' ? 'group' : 'private'
},
{
onRunEvent: (event) => {
const mapped = this.mapRunEventToChunk(run, event)
if (mapped) runtime.onChunk(mapped)
}
}
)
if (run.aborted) {
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'error',
error: '任务已取消',
isFinished: true
})
runtime.onFinished?.({
success: false,
runId: run.runId,
conversationId: run.conversationId,
error: '任务已取消'
})
this.runs.delete(run.runId)
return
}
if (!result.success || !result.result) {
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'error',
error: result.error || '执行失败',
isFinished: true
})
runtime.onFinished?.({
success: false,
runId: run.runId,
conversationId: run.conversationId,
error: result.error || '执行失败'
})
this.runs.delete(run.runId)
return
}
run.conversationId = result.result.conversationId || run.conversationId
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'content',
content: result.result.assistantText
})
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'done',
usage: result.result.usage,
isFinished: true
})
runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId })
this.runs.delete(run.runId)
}
private async executeSqlMode(
run: ActiveAgentRun,
payload: AgentRunPayload,
runtime: {
onChunk: (chunk: AgentStreamChunk) => void
onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void
}
): Promise<void> {
const { apiBaseUrl, apiKey, model } = this.getSharedModelConfig()
if (!apiBaseUrl || !apiKey) {
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'error',
error: '请先在设置 > AI 通用中配置模型',
isFinished: true
})
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: '模型未配置' })
this.runs.delete(run.runId)
return
}
this.emitStatus(run, runtime.onChunk, 'thinking')
const schemaText = normalizeText(payload.sqlContext?.schemaText)
const targetHint = normalizeText(payload.sqlContext?.targetHint)
const systemPrompt = [
'你是 WeFlow SQL Lab 助手。',
'只输出一段只读 SQL。',
'禁止输出解释、Markdown、注释、DML、DDL。'
].join('\n')
const userPrompt = [
targetHint ? `目标数据源: ${targetHint}` : '',
schemaText ? `可用 Schema:\n${schemaText}` : '',
`需求: ${normalizeText(payload.userInput)}`
].filter(Boolean).join('\n\n')
const res = await this.callModel({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.1,
stream: false
}, apiBaseUrl, apiKey)
if (run.aborted) {
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'error',
error: '任务已取消',
isFinished: true
})
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: '任务已取消' })
this.runs.delete(run.runId)
return
}
const rawContent = normalizeText(res?.choices?.[0]?.message?.content)
const sql = extractSqlText(rawContent)
const usage: TokenUsage = {
promptTokens: parseOptionalInt(res?.usage?.prompt_tokens),
completionTokens: parseOptionalInt(res?.usage?.completion_tokens),
totalTokens: parseOptionalInt(res?.usage?.total_tokens)
}
if (!sql) {
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'error',
error: 'SQL 生成失败',
isFinished: true
})
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: 'SQL 生成失败' })
this.runs.delete(run.runId)
return
}
for (let i = 0; i < sql.length; i += 36) {
if (run.aborted) break
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'content',
content: sql.slice(i, i + 36)
})
}
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'done',
usage,
isFinished: true
})
runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId })
this.runs.delete(run.runId)
}
async abort(payload: { runId?: string; conversationId?: string }): Promise<{ success: boolean }> {
const runId = normalizeText(payload.runId)
const conversationId = normalizeText(payload.conversationId)
if (runId) {
const run = this.runs.get(runId)
if (run) {
run.aborted = true
if (run.mode === 'chat') {
await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId })
}
}
return { success: true }
}
if (conversationId) {
for (const run of this.runs.values()) {
if (run.conversationId !== conversationId) continue
run.aborted = true
if (run.mode === 'chat') {
await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId })
}
}
return { success: true }
}
return { success: true }
}
}
export const aiAgentService = new AiAgentService()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
你是 WeFlow 的 AI 分析助手。
目标:
- 精准完成用户在聊天数据上的查询、总结、分析、回忆任务。
- 优先使用本地工具获取证据,禁止猜测或捏造。
- 默认输出简洁中文,先给结论,再给关键依据。
工作原则:
- Token 节约优先:默认只请求必要字段,只有用户明确需要或证据不足时再升级 detailLevel。
- 先范围后细节:优先定位会话/时间范围,再拉取具体时间轴或消息。
- 可解释性:最终结论尽量附带来源范围与统计口径。
- 语音消息不能臆测:必须先拿语音 ID再点名转写再总结。
- 联系人排行题(“谁聊得最多/最常联系”)命中 get_member_stats 后必须直接给出“前N名+消息数”。
- 除非用户明确要求,联系人排行默认不包含群聊和公众号。
- 用户提到“最近/近期/lately/recent”但未给时间窗时默认按近30天口径统计并写明口径。
- 用户提到联系人简称如“lr”先把它当联系人缩写处理优先命中个人会话不要默认落到群聊。
- 用户问“我和X聊了什么”时必须交付“主题总结”不要贴原始逐条聊天流水。
Agent执行要求
- 用户输入直接进入推理,本地不做关键词分流,你自主决定工具计划。
- 当用户说“今天凌晨/昨晚/某段时间的聊天”,优先调用 get_time_stats。
- 拿到活跃会话后,调用 get_recent_messages 对多个会话逐个抽样阅读,不要只读一个会话就停止。
- 如果初步探索后用户目标仍模糊,主动提出 1 个关键澄清问题继续多轮对话。
- 仅当你确认任务完成时,输出结束标记 `[[WF_DONE]]`,并紧跟 `<final_answer>...</final_answer>`
- 若还未完成,不要输出结束标记,继续调用工具。
语音处理硬规则:
- 当用户涉及“语音内容”时,先调用 ai_list_voice_messages。
- 让系统返回候选 ID 后,再调用 ai_transcribe_voice_messages 指定 ID。
- 未转写成功的语音不可作为事实依据。

View File

@@ -0,0 +1,6 @@
你会收到 conversation_summary历史压缩摘要
使用方式:
- 默认把摘要作为历史背景,不逐字复述。
- 若摘要与最近消息冲突,以最近消息为准。
- 若用户追问很久之前的细节,优先重新调用工具检索,不依赖旧记忆。

View File

@@ -0,0 +1,8 @@
工具get_message_context
何时用:
- 需要核对少量关键消息原文,避免全量展开。
调用建议:
- 只传必要 itemssessionId + localId每次少量<=20
- 默认 minimal需要上下文再用 standard/full。

View File

@@ -0,0 +1,9 @@
工具search_sessions
何时用:
- 用户未明确具体会话,但给了关键词/关系词(如“老婆”“买车”)。
调用建议:
- 首次调用 detailLevel=minimal。
- 默认 limit 8~12避免拉太多候选。
- 当候选歧义较大时再升级 detailLevel=standard/full。

View File

@@ -0,0 +1,9 @@
工具get_recent_messages
何时用:
- 已确定候选会话,需要“先看一点”理解上下文。
Agent策略
- 每个候选会话先抽样 6~20 条,按时间顺序阅读。
- 不要只读一个会话就结束;优先覆盖多会话后再总结。
- 如果出现明显分歧场景(工作/家庭/感情)需主动向用户确认分析目标。

View File

@@ -0,0 +1,8 @@
工具get_session_summaries
何时用:
- 输出总结或分析后,用于来源说明与可解释卡片。
调用建议:
- 默认 minimal 即可,输出 range/session_count/message_count/db_refs。
- 只有排错或审计时再请求 full。

View File

@@ -0,0 +1,9 @@
工具get_time_stats
何时用:
- 用户提到“今天凌晨/昨晚/某个时间段”的聊天分析。
Agent策略
- 第一步必须先扫时间窗活跃会话,不要直接下结论。
- 拿到活跃会话后,再调用 get_recent_messages 逐个会话抽样阅读。
- 若用户目标仍不清晰,先追问 1 个关键澄清问题再继续。

View File

@@ -0,0 +1,9 @@
工具search_messages
何时用:
- 回忆事件经过、梳理时间线、提取关键节点。
调用建议:
- 默认 detailLevel=minimal。
- 先小批次 limit40~120不够再分页 offset。
- 需要引用原文证据时,可搭配 get_message_context。

View File

@@ -0,0 +1,9 @@
工具get_member_stats
何时用:
- 用户问“谁联系最密切”“谁聊得最多”“最常联系的是谁”。
调用建议:
- 该问题优先调用本工具,而不是先跑时间轴。
- 默认 detailLevel=minimallimit 5~10。
- 需要区分群聊时再设置 includeGroups=true。

View File

@@ -0,0 +1,8 @@
工具get_chat_overview
何时用:
- 用户问“多少、占比、趋势、对比”。
调用建议:
- 仅在统计问题时调用,避免无谓聚合。
- 默认 detailLevel=minimal有统计追问再升到 standard/full。

View File

@@ -0,0 +1,8 @@
工具ai_list_voice_messages
何时用:
- 用户提到“语音里说了什么”。
调用建议:
- 第一步先拿 ID 清单,默认 detailLevel=minimal仅 IDs
- 如用户需要挑选依据,再用 standard/full 查看更多元数据。

View File

@@ -0,0 +1,9 @@
工具ai_transcribe_voice_messages
何时用:
- 已明确拿到语音 ID且用户需要读取语音内容。
调用建议:
- 必须显式传 ids 或 items。
- 单次控制在小批次(建议 <=5失败可重试。
- 转写成功后再参与总结;失败项单独标注,不混入结论。

View File

@@ -0,0 +1,444 @@
import { randomUUID } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { ConfigService } from './config'
export type AssistantChatType = 'group' | 'private'
export type AssistantToolCategory = 'core' | 'analysis'
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: AssistantChatType[]
supportedLocales?: string[]
}
export interface AssistantConfigFull extends AssistantSummary {}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: AssistantChatType[]
supportedLocales?: string[]
imported: boolean
}
const GENERAL_CN_MD = `---
id: general_cn
name: 通用分析助手
supportedLocales:
- zh
presetQuestions:
- 最近都在聊什么?
- 谁是最活跃的人?
- 帮我总结一下最近一周的重要聊天
- 帮我找一下关于“旅游”的讨论
allowedBuiltinTools:
- get_time_stats
- search_sessions
- get_recent_messages
- search_messages
- get_message_context
- ai_list_voice_messages
- ai_transcribe_voice_messages
- get_chat_overview
- get_session_summaries
- get_member_stats
---
你是 WeFlow 的全局聊天分析助手。请使用工具获取证据,给出简洁、准确、可执行的结论。
输出要求:
1. 先结论,再证据。
2. 若证据不足,明确说明不足并建议下一步。
3. 涉及语音内容时,必须先列语音 ID再按 ID 转写。
4. 默认中文输出,除非用户明确指定其他语言。`
const GENERAL_EN_MD = `---
id: general_en
name: General Analysis Assistant
supportedLocales:
- en
presetQuestions:
- What have people been discussing recently?
- Who are the most active contacts?
- Summarize my key chat topics this week
allowedBuiltinTools:
- get_time_stats
- search_sessions
- get_recent_messages
- search_messages
- get_message_context
- ai_list_voice_messages
- ai_transcribe_voice_messages
- get_chat_overview
- get_session_summaries
- get_member_stats
---
You are WeFlow's global chat analysis assistant.
Always ground your answers in tool evidence, stay concise, and clearly call out uncertainty when data is insufficient.`
const GENERAL_JA_MD = `---
id: general_ja
name: 汎用分析アシスタント
supportedLocales:
- ja
presetQuestions:
- 最近どんな話題が多い?
- 一番アクティブな相手は誰?
- 今週の重要な会話を要約して
allowedBuiltinTools:
- get_time_stats
- search_sessions
- get_recent_messages
- search_messages
- get_message_context
- ai_list_voice_messages
- ai_transcribe_voice_messages
- get_chat_overview
- get_session_summaries
- get_member_stats
---
あなたは WeFlow のグローバルチャット分析アシスタントです。
ツールから得た根拠に基づき、簡潔かつ正確に回答してください。`
const BUILTIN_ASSISTANTS = [
{ id: 'general_cn', raw: GENERAL_CN_MD },
{ id: 'general_en', raw: GENERAL_EN_MD },
{ id: 'general_ja', raw: GENERAL_JA_MD }
] as const
function normalizeText(value: unknown, fallback = ''): string {
const text = String(value ?? '').trim()
return text || fallback
}
function parseInlineList(text: string): string[] {
const raw = normalizeText(text)
if (!raw) return []
return raw
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function splitFrontmatter(raw: string): { frontmatter: string; body: string } {
const normalized = String(raw || '')
if (!normalized.startsWith('---')) {
return { frontmatter: '', body: normalized.trim() }
}
const end = normalized.indexOf('\n---', 3)
if (end < 0) return { frontmatter: '', body: normalized.trim() }
return {
frontmatter: normalized.slice(3, end).trim(),
body: normalized.slice(end + 4).trim()
}
}
function parseAssistantMarkdown(raw: string): AssistantConfigFull {
const { frontmatter, body } = splitFrontmatter(raw)
const lines = frontmatter ? frontmatter.split('\n') : []
const data: Record<string, unknown> = {}
let currentArrayKey = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
const kv = trimmed.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/)
if (kv) {
const key = kv[1]
const value = kv[2]
if (!value) {
data[key] = []
currentArrayKey = key
} else {
data[key] = value
currentArrayKey = ''
}
continue
}
const arr = trimmed.match(/^- (.+)$/)
if (arr && currentArrayKey) {
const next = Array.isArray(data[currentArrayKey]) ? data[currentArrayKey] as string[] : []
next.push(arr[1].trim())
data[currentArrayKey] = next
}
}
const id = normalizeText(data.id)
const name = normalizeText(data.name, id || 'assistant')
const applicableChatTypes = Array.isArray(data.applicableChatTypes)
? (data.applicableChatTypes as string[]).filter((item): item is AssistantChatType => item === 'group' || item === 'private')
: parseInlineList(String(data.applicableChatTypes || '')).filter((item): item is AssistantChatType => item === 'group' || item === 'private')
const supportedLocales = Array.isArray(data.supportedLocales)
? (data.supportedLocales as string[]).map((item) => item.trim()).filter(Boolean)
: parseInlineList(String(data.supportedLocales || ''))
const presetQuestions = Array.isArray(data.presetQuestions)
? (data.presetQuestions as string[]).map((item) => item.trim()).filter(Boolean)
: parseInlineList(String(data.presetQuestions || ''))
const allowedBuiltinTools = Array.isArray(data.allowedBuiltinTools)
? (data.allowedBuiltinTools as string[]).map((item) => item.trim()).filter(Boolean)
: parseInlineList(String(data.allowedBuiltinTools || ''))
const builtinId = normalizeText(data.builtinId)
return {
id,
name,
systemPrompt: body,
presetQuestions,
allowedBuiltinTools,
builtinId: builtinId || undefined,
applicableChatTypes,
supportedLocales
}
}
function toMarkdown(config: AssistantConfigFull): string {
const lines = [
'---',
`id: ${config.id}`,
`name: ${config.name}`
]
if (config.builtinId) lines.push(`builtinId: ${config.builtinId}`)
if (config.supportedLocales && config.supportedLocales.length > 0) {
lines.push('supportedLocales:')
config.supportedLocales.forEach((item) => lines.push(` - ${item}`))
}
if (config.applicableChatTypes && config.applicableChatTypes.length > 0) {
lines.push('applicableChatTypes:')
config.applicableChatTypes.forEach((item) => lines.push(` - ${item}`))
}
if (config.presetQuestions && config.presetQuestions.length > 0) {
lines.push('presetQuestions:')
config.presetQuestions.forEach((item) => lines.push(` - ${item}`))
}
if (config.allowedBuiltinTools && config.allowedBuiltinTools.length > 0) {
lines.push('allowedBuiltinTools:')
config.allowedBuiltinTools.forEach((item) => lines.push(` - ${item}`))
}
lines.push('---')
lines.push('')
lines.push(config.systemPrompt || '')
return lines.join('\n')
}
function defaultBuiltinToolCatalog(): Array<{ name: string; category: AssistantToolCategory }> {
return [
{ name: 'get_time_stats', category: 'core' },
{ name: 'search_sessions', category: 'core' },
{ name: 'get_recent_messages', category: 'core' },
{ name: 'search_messages', category: 'core' },
{ name: 'get_message_context', category: 'core' },
{ name: 'ai_list_voice_messages', category: 'core' },
{ name: 'ai_transcribe_voice_messages', category: 'core' },
{ name: 'get_chat_overview', category: 'analysis' },
{ name: 'get_session_summaries', category: 'analysis' },
{ name: 'get_member_stats', category: 'analysis' },
{ name: 'activate_skill', category: 'analysis' }
]
}
class AiAssistantService {
private readonly config = ConfigService.getInstance()
private initialized = false
private readonly cache = new Map<string, AssistantConfigFull>()
private getRootDirCandidates(): string[] {
const dbPath = normalizeText(this.config.get('dbPath'))
const wxid = normalizeText(this.config.get('myWxid'))
const roots: string[] = []
if (dbPath && wxid) {
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2'))
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai'))
}
roots.push(join(process.cwd(), 'data', 'wf_ai_v2'))
return roots
}
private async getRootDir(): Promise<string> {
const roots = this.getRootDirCandidates()
const dir = roots[0]
await mkdir(dir, { recursive: true })
return dir
}
private async getAssistantsDir(): Promise<string> {
const root = await this.getRootDir()
const dir = join(root, 'assistants')
await mkdir(dir, { recursive: true })
return dir
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return
const dir = await this.getAssistantsDir()
for (const builtin of BUILTIN_ASSISTANTS) {
const filePath = join(dir, `${builtin.id}.md`)
if (!existsSync(filePath)) {
const parsed = parseAssistantMarkdown(builtin.raw)
const config: AssistantConfigFull = {
...parsed,
builtinId: parsed.id
}
await writeFile(filePath, toMarkdown(config), 'utf8')
}
}
this.cache.clear()
const files = await readdir(dir)
for (const fileName of files) {
if (!fileName.endsWith('.md')) continue
const filePath = join(dir, fileName)
try {
const raw = await readFile(filePath, 'utf8')
const parsed = parseAssistantMarkdown(raw)
if (!parsed.id) continue
this.cache.set(parsed.id, parsed)
} catch {
// ignore broken file
}
}
this.initialized = true
}
async getAll(): Promise<AssistantSummary[]> {
await this.ensureInitialized()
return Array.from(this.cache.values())
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
.map((assistant) => ({ ...assistant }))
}
async getConfig(id: string): Promise<AssistantConfigFull | null> {
await this.ensureInitialized()
const key = normalizeText(id)
const config = this.cache.get(key)
return config ? { ...config } : null
}
async create(
payload: Omit<AssistantConfigFull, 'id'> & { id?: string }
): Promise<{ success: boolean; id?: string; error?: string }> {
await this.ensureInitialized()
const id = normalizeText(payload.id, `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`)
if (this.cache.has(id)) return { success: false, error: '助手 ID 已存在' }
const config: AssistantConfigFull = {
id,
name: normalizeText(payload.name, '新助手'),
systemPrompt: normalizeText(payload.systemPrompt),
presetQuestions: Array.isArray(payload.presetQuestions) ? payload.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : [],
allowedBuiltinTools: Array.isArray(payload.allowedBuiltinTools) ? payload.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : [],
builtinId: normalizeText(payload.builtinId) || undefined,
applicableChatTypes: Array.isArray(payload.applicableChatTypes) ? payload.applicableChatTypes : [],
supportedLocales: Array.isArray(payload.supportedLocales) ? payload.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : []
}
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${id}.md`), toMarkdown(config), 'utf8')
this.cache.set(id, config)
return { success: true, id }
}
async update(
id: string,
updates: Partial<AssistantConfigFull>
): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
const existing = this.cache.get(key)
if (!existing) return { success: false, error: '助手不存在' }
const next: AssistantConfigFull = {
...existing,
...updates,
id: key,
name: normalizeText(updates.name, existing.name),
systemPrompt: updates.systemPrompt == null ? existing.systemPrompt : normalizeText(updates.systemPrompt),
presetQuestions: Array.isArray(updates.presetQuestions) ? updates.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : existing.presetQuestions,
allowedBuiltinTools: Array.isArray(updates.allowedBuiltinTools) ? updates.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : existing.allowedBuiltinTools,
applicableChatTypes: Array.isArray(updates.applicableChatTypes) ? updates.applicableChatTypes : existing.applicableChatTypes,
supportedLocales: Array.isArray(updates.supportedLocales) ? updates.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : existing.supportedLocales
}
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${key}.md`), toMarkdown(next), 'utf8')
this.cache.set(key, next)
return { success: true }
}
async delete(id: string): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
if (key === 'general_cn' || key === 'general_en' || key === 'general_ja') {
return { success: false, error: '默认助手不可删除' }
}
const dir = await this.getAssistantsDir()
const filePath = join(dir, `${key}.md`)
if (existsSync(filePath)) {
await rm(filePath, { force: true })
}
this.cache.delete(key)
return { success: true }
}
async reset(id: string): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
const existing = this.cache.get(key)
if (!existing?.builtinId) {
return { success: false, error: '该助手不支持重置' }
}
const builtin = BUILTIN_ASSISTANTS.find((item) => item.id === existing.builtinId)
if (!builtin) return { success: false, error: '内置模板不存在' }
const parsed = parseAssistantMarkdown(builtin.raw)
const config: AssistantConfigFull = {
...parsed,
id: key,
builtinId: existing.builtinId
}
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${key}.md`), toMarkdown(config), 'utf8')
this.cache.set(key, config)
return { success: true }
}
async getBuiltinCatalog(): Promise<BuiltinAssistantInfo[]> {
await this.ensureInitialized()
return BUILTIN_ASSISTANTS.map((builtin) => {
const parsed = parseAssistantMarkdown(builtin.raw)
const imported = Array.from(this.cache.values()).some((config) => config.builtinId === builtin.id || config.id === builtin.id)
return {
id: parsed.id,
name: parsed.name,
systemPrompt: parsed.systemPrompt,
applicableChatTypes: parsed.applicableChatTypes,
supportedLocales: parsed.supportedLocales,
imported
}
})
}
async getBuiltinToolCatalog(): Promise<Array<{ name: string; category: AssistantToolCategory }>> {
return defaultBuiltinToolCatalog()
}
async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const parsed = parseAssistantMarkdown(rawMd)
if (!parsed.id) return { success: false, error: '缺少 id' }
if (this.cache.has(parsed.id)) return { success: false, error: '助手 ID 已存在' }
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${parsed.id}.md`), toMarkdown(parsed), 'utf8')
this.cache.set(parsed.id, parsed)
return { success: true, id: parsed.id }
} catch (error) {
return { success: false, error: String((error as Error)?.message || error) }
}
}
}
export const aiAssistantService = new AiAssistantService()

View File

@@ -0,0 +1,395 @@
import { existsSync } from 'fs'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { ConfigService } from './config'
export type SkillChatScope = 'all' | 'group' | 'private'
export interface SkillSummary {
id: string
name: string
description: string
tags: string[]
chatScope: SkillChatScope
tools: string[]
builtinId?: string
}
export interface SkillDef extends SkillSummary {
prompt: string
}
export interface BuiltinSkillInfo extends SkillSummary {
imported: boolean
}
const SKILL_DEEP_TIMELINE_MD = `---
id: deep_timeline
name: 深度时间线追踪
description: 适合还原某段时间内发生了什么,强调事件顺序与证据引用。
tags:
- timeline
- evidence
chatScope: all
tools:
- get_time_stats
- search_sessions
- get_recent_messages
- search_messages
- get_message_context
- get_session_summaries
---
你是“深度时间线追踪”技能。
执行步骤:
1. 先按时间窗扫描活跃会话,必要时补关键词筛选候选会话。
2. 对候选会话先抽样,再拉取时间轴。
3. 对关键节点用 get_message_context 校对原文。
4. 最后输出“结论 + 关键节点 + 来源范围”。`
const SKILL_CONTACT_FOCUS_MD = `---
id: contact_focus
name: 联系人关系聚焦
description: 用于“我和谁聊得最多/关系变化”这类问题,强调联系人维度。
tags:
- contacts
- relation
chatScope: private
tools:
- get_member_stats
- get_chat_overview
- get_recent_messages
- search_messages
- get_session_summaries
---
你是“联系人关系聚焦”技能。
执行步骤:
1. 优先调用 get_member_stats 得到候选联系人排名。
2. 针对 Top 联系人读取抽样消息并补充时间轴。
3. 如果用户问题涉及“变化趋势”,补 get_chat_overview。
4. 输出时必须给出对比口径(时间窗、样本范围、消息数量)。`
const SKILL_VOICE_AUDIT_MD = `---
id: voice_audit
name: 语音证据审计
description: 对语音消息进行“先列ID再转写再总结”的合规分析。
tags:
- voice
- audit
chatScope: all
tools:
- ai_list_voice_messages
- ai_transcribe_voice_messages
- get_session_summaries
---
你是“语音证据审计”技能。
硬规则:
1. 必须先调用 ai_list_voice_messages 获取语音 ID 清单。
2. 仅能转写用户明确指定的 ID单轮最多 5 条。
3. 未转写成功的语音不得作为事实。
4. 输出包含“已转写 / 失败 / 待确认”三段。`
const BUILTIN_SKILLS = [
{ id: 'deep_timeline', raw: SKILL_DEEP_TIMELINE_MD },
{ id: 'contact_focus', raw: SKILL_CONTACT_FOCUS_MD },
{ id: 'voice_audit', raw: SKILL_VOICE_AUDIT_MD }
] as const
function normalizeText(value: unknown, fallback = ''): string {
const text = String(value ?? '').trim()
return text || fallback
}
function parseInlineList(text: string): string[] {
const raw = normalizeText(text)
if (!raw) return []
return raw
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function splitFrontmatter(raw: string): { frontmatter: string; body: string } {
const normalized = String(raw || '')
if (!normalized.startsWith('---')) {
return { frontmatter: '', body: normalized.trim() }
}
const end = normalized.indexOf('\n---', 3)
if (end < 0) return { frontmatter: '', body: normalized.trim() }
return {
frontmatter: normalized.slice(3, end).trim(),
body: normalized.slice(end + 4).trim()
}
}
function normalizeChatScope(value: unknown): SkillChatScope {
const scope = normalizeText(value).toLowerCase()
if (scope === 'group' || scope === 'private') return scope
return 'all'
}
function parseSkillMarkdown(raw: string): SkillDef {
const { frontmatter, body } = splitFrontmatter(raw)
const lines = frontmatter ? frontmatter.split('\n') : []
const data: Record<string, unknown> = {}
let currentArrayKey = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
const kv = trimmed.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/)
if (kv) {
const key = kv[1]
const value = kv[2]
if (!value) {
data[key] = []
currentArrayKey = key
} else {
data[key] = value
currentArrayKey = ''
}
continue
}
const arr = trimmed.match(/^- (.+)$/)
if (arr && currentArrayKey) {
const next = Array.isArray(data[currentArrayKey]) ? data[currentArrayKey] as string[] : []
next.push(arr[1].trim())
data[currentArrayKey] = next
}
}
const id = normalizeText(data.id)
const name = normalizeText(data.name, id || 'skill')
const description = normalizeText(data.description)
const tags = Array.isArray(data.tags)
? (data.tags as string[]).map((item) => item.trim()).filter(Boolean)
: parseInlineList(String(data.tags || ''))
const tools = Array.isArray(data.tools)
? (data.tools as string[]).map((item) => item.trim()).filter(Boolean)
: parseInlineList(String(data.tools || ''))
const chatScope = normalizeChatScope(data.chatScope)
const builtinId = normalizeText(data.builtinId)
return {
id,
name,
description,
tags,
chatScope,
tools,
prompt: body,
builtinId: builtinId || undefined
}
}
function serializeSkillMarkdown(skill: SkillDef): string {
const lines = [
'---',
`id: ${skill.id}`,
`name: ${skill.name}`,
`description: ${skill.description}`,
`chatScope: ${skill.chatScope}`
]
if (skill.builtinId) lines.push(`builtinId: ${skill.builtinId}`)
if (skill.tags.length > 0) {
lines.push('tags:')
skill.tags.forEach((tag) => lines.push(` - ${tag}`))
}
if (skill.tools.length > 0) {
lines.push('tools:')
skill.tools.forEach((tool) => lines.push(` - ${tool}`))
}
lines.push('---')
lines.push('')
lines.push(skill.prompt || '')
return lines.join('\n')
}
class AiSkillService {
private readonly config = ConfigService.getInstance()
private initialized = false
private readonly cache = new Map<string, SkillDef>()
private getRootDirCandidates(): string[] {
const dbPath = normalizeText(this.config.get('dbPath'))
const wxid = normalizeText(this.config.get('myWxid'))
const roots: string[] = []
if (dbPath && wxid) {
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2'))
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai'))
}
roots.push(join(process.cwd(), 'data', 'wf_ai_v2'))
return roots
}
private async getRootDir(): Promise<string> {
const roots = this.getRootDirCandidates()
const dir = roots[0]
await mkdir(dir, { recursive: true })
return dir
}
private async getSkillsDir(): Promise<string> {
const root = await this.getRootDir()
const dir = join(root, 'skills')
await mkdir(dir, { recursive: true })
return dir
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return
const dir = await this.getSkillsDir()
for (const builtin of BUILTIN_SKILLS) {
const filePath = join(dir, `${builtin.id}.md`)
if (!existsSync(filePath)) {
const parsed = parseSkillMarkdown(builtin.raw)
const config: SkillDef = {
...parsed,
builtinId: parsed.id
}
await writeFile(filePath, serializeSkillMarkdown(config), 'utf8')
continue
}
try {
const raw = await readFile(filePath, 'utf8')
const parsed = parseSkillMarkdown(raw)
if (!parsed.builtinId) {
parsed.builtinId = builtin.id
await writeFile(filePath, serializeSkillMarkdown(parsed), 'utf8')
}
} catch {
// ignore broken file
}
}
this.cache.clear()
const files = await readdir(dir)
for (const fileName of files) {
if (!fileName.endsWith('.md')) continue
const filePath = join(dir, fileName)
try {
const raw = await readFile(filePath, 'utf8')
const parsed = parseSkillMarkdown(raw)
if (!parsed.id) continue
this.cache.set(parsed.id, parsed)
} catch {
// ignore broken file
}
}
this.initialized = true
}
async getAll(): Promise<SkillSummary[]> {
await this.ensureInitialized()
return Array.from(this.cache.values())
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
tags: [...skill.tags],
chatScope: skill.chatScope,
tools: [...skill.tools],
builtinId: skill.builtinId
}))
}
async getConfig(id: string): Promise<SkillDef | null> {
await this.ensureInitialized()
const key = normalizeText(id)
const value = this.cache.get(key)
return value ? {
...value,
tags: [...value.tags],
tools: [...value.tools]
} : null
}
async create(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
await this.ensureInitialized()
try {
const parsed = parseSkillMarkdown(rawMd)
if (!parsed.id) return { success: false, error: '缺少 id' }
if (this.cache.has(parsed.id)) return { success: false, error: '技能 ID 已存在' }
const dir = await this.getSkillsDir()
await writeFile(join(dir, `${parsed.id}.md`), serializeSkillMarkdown(parsed), 'utf8')
this.cache.set(parsed.id, parsed)
return { success: true, id: parsed.id }
} catch (error) {
return { success: false, error: String((error as Error)?.message || error) }
}
}
async update(id: string, rawMd: string): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
const existing = this.cache.get(key)
if (!existing) return { success: false, error: '技能不存在' }
try {
const parsed = parseSkillMarkdown(rawMd)
parsed.id = key
if (existing.builtinId && !parsed.builtinId) parsed.builtinId = existing.builtinId
const dir = await this.getSkillsDir()
await writeFile(join(dir, `${key}.md`), serializeSkillMarkdown(parsed), 'utf8')
this.cache.set(key, parsed)
return { success: true }
} catch (error) {
return { success: false, error: String((error as Error)?.message || error) }
}
}
async delete(id: string): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
const dir = await this.getSkillsDir()
const filePath = join(dir, `${key}.md`)
if (existsSync(filePath)) {
await rm(filePath, { force: true })
}
this.cache.delete(key)
return { success: true }
}
async getBuiltinCatalog(): Promise<BuiltinSkillInfo[]> {
await this.ensureInitialized()
return BUILTIN_SKILLS.map((builtin) => {
const parsed = parseSkillMarkdown(builtin.raw)
const imported = Array.from(this.cache.values()).some((skill) => skill.builtinId === parsed.id || skill.id === parsed.id)
return {
id: parsed.id,
name: parsed.name,
description: parsed.description,
tags: parsed.tags,
chatScope: parsed.chatScope,
tools: parsed.tools,
imported
}
})
}
async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
return this.create(rawMd)
}
async getAutoSkillMenu(
chatScope: SkillChatScope,
allowedTools?: string[]
): Promise<string | null> {
await this.ensureInitialized()
const compatible = Array.from(this.cache.values()).filter((skill) => {
if (skill.chatScope !== 'all' && skill.chatScope !== chatScope) return false
if (!allowedTools || allowedTools.length === 0) return true
return skill.tools.every((tool) => allowedTools.includes(tool))
})
if (compatible.length === 0) return null
const lines = compatible.slice(0, 15).map((skill) => `- ${skill.id}: ${skill.name} - ${skill.description}`)
return [
'你可以按需调用工具 activate_skill 以激活对应技能。',
'当用户问题明显匹配某个技能时,先调用 activate_skill 获取执行手册。',
'若问题简单或不匹配技能,可直接回答。',
'',
...lines
].join('\n')
}
}
export const aiSkillService = new AiSkillService()

View File

@@ -174,6 +174,36 @@ interface GetContactsOptions {
lite?: boolean lite?: boolean
} }
interface AiTimeFilter {
startTs?: number
endTs?: number
}
interface AiMessageResult {
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}
interface AiSessionSearchResult {
id: string
startTs: number
endTs: number
messageCount: number
isComplete: boolean
previewMessages: AiMessageResult[]
}
interface ExportSessionStats { interface ExportSessionStats {
totalMessages: number totalMessages: number
voiceMessages: number voiceMessages: number
@@ -8474,6 +8504,451 @@ class ChatService {
} }
} }
private normalizeAiFilter(filter?: AiTimeFilter): { begin: number; end: number } {
const begin = this.normalizeTimestampSeconds(Number(filter?.startTs || 0))
const end = this.normalizeTimestampSeconds(Number(filter?.endTs || 0))
return { begin, end }
}
private hashSenderId(senderUsername: string): number {
const text = String(senderUsername || '').trim().toLowerCase()
if (!text) return 0
let hash = 5381
for (let i = 0; i < text.length; i += 1) {
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
private messageMatchesKeywords(message: Message, keywords?: string[]): boolean {
if (!Array.isArray(keywords) || keywords.length === 0) return true
const text = String(message.parsedContent || message.rawContent || '').toLowerCase()
if (!text) return false
return keywords.every((keyword) => {
const token = String(keyword || '').trim().toLowerCase()
if (!token) return true
return text.includes(token)
})
}
private toAiMessage(sessionId: string, message: Message): AiMessageResult {
const senderUsername = String(message.senderUsername || '').trim()
const senderName = senderUsername || (message.isSend === 1 ? '我' : '未知成员')
const content = String(message.parsedContent || message.rawContent || '').trim()
return {
id: message.localId,
localId: message.localId,
sessionId,
senderName,
senderPlatformId: senderUsername,
senderUsername,
content,
timestamp: Number(message.createTime || 0),
type: Number(message.localType || 0),
isSend: message.isSend,
replyToMessageId: message.messageKey || null,
replyToContent: message.quotedContent || null,
replyToSenderName: message.quotedSender || null
}
}
private async fetchMessagesByCursorWithKey(
sessionId: string,
key: { sortSeq?: number; createTime?: number; localId?: number },
limit: number,
ascending: boolean,
beginTimestamp = 0,
endTimestamp = 0
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
const batchSize = Math.max(limit + 8, Math.min(240, limit * 2))
const cursorResult = await wcdbService.openMessageCursorWithKey(
sessionId,
batchSize,
ascending,
beginTimestamp,
endTimestamp,
key
)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建游标失败' }
}
try {
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit)
if (!collected.success) {
return { success: false, error: collected.error || '读取消息失败' }
}
return {
success: true,
messages: collected.messages || [],
hasMore: collected.hasMore === true
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {})
}
}
async getRecentMessagesForAI(
sessionId: string,
filter?: AiTimeFilter,
limit = 100
): Promise<{ messages: AiMessageResult[]; total: number }> {
const normalizedLimit = Math.max(1, Math.min(500, Number(limit || 100)))
const { begin, end } = this.normalizeAiFilter(filter)
const result = await this.getLatestMessages(sessionId, normalizedLimit)
if (!result.success || !Array.isArray(result.messages)) {
return { messages: [], total: 0 }
}
const bounded = result.messages.filter((message) => {
if (begin > 0 && Number(message.createTime || 0) < begin) return false
if (end > 0 && Number(message.createTime || 0) > end) return false
return String(message.parsedContent || message.rawContent || '').trim().length > 0
})
return {
messages: bounded.slice(-normalizedLimit).map((message) => this.toAiMessage(sessionId, message)),
total: bounded.length
}
}
async getMessagesBeforeForAI(
sessionId: string,
beforeId: number,
limit = 50,
filter?: AiTimeFilter,
senderId?: number,
keywords?: string[]
): Promise<{ messages: AiMessageResult[]; hasMore: boolean }> {
const base = await this.getMessageById(sessionId, Number(beforeId))
if (!base.success || !base.message) {
return { messages: [], hasMore: false }
}
const normalizedLimit = Math.max(1, Math.min(300, Number(limit || 50)))
const { begin, end } = this.normalizeAiFilter(filter)
const cursor = await this.fetchMessagesByCursorWithKey(
sessionId,
{
sortSeq: base.message.sortSeq,
createTime: base.message.createTime,
localId: base.message.localId
},
Math.max(normalizedLimit * 2, normalizedLimit + 12),
false,
begin,
end
)
if (!cursor.success) {
return { messages: [], hasMore: false }
}
const filtered = (cursor.messages || []).filter((message) => {
if (senderId && senderId > 0) {
const hashed = this.hashSenderId(String(message.senderUsername || ''))
if (hashed !== senderId) return false
}
return this.messageMatchesKeywords(message, keywords)
})
const sliced = filtered.slice(-normalizedLimit)
return {
messages: sliced.map((message) => this.toAiMessage(sessionId, message)),
hasMore: cursor.hasMore === true || filtered.length > normalizedLimit
}
}
async getMessagesAfterForAI(
sessionId: string,
afterId: number,
limit = 50,
filter?: AiTimeFilter,
senderId?: number,
keywords?: string[]
): Promise<{ messages: AiMessageResult[]; hasMore: boolean }> {
const base = await this.getMessageById(sessionId, Number(afterId))
if (!base.success || !base.message) {
return { messages: [], hasMore: false }
}
const normalizedLimit = Math.max(1, Math.min(300, Number(limit || 50)))
const { begin, end } = this.normalizeAiFilter(filter)
const cursor = await this.fetchMessagesByCursorWithKey(
sessionId,
{
sortSeq: base.message.sortSeq,
createTime: base.message.createTime,
localId: base.message.localId
},
Math.max(normalizedLimit * 2, normalizedLimit + 12),
true,
begin,
end
)
if (!cursor.success) {
return { messages: [], hasMore: false }
}
const filtered = (cursor.messages || []).filter((message) => {
if (senderId && senderId > 0) {
const hashed = this.hashSenderId(String(message.senderUsername || ''))
if (hashed !== senderId) return false
}
return this.messageMatchesKeywords(message, keywords)
})
const sliced = filtered.slice(0, normalizedLimit)
return {
messages: sliced.map((message) => this.toAiMessage(sessionId, message)),
hasMore: cursor.hasMore === true || filtered.length > normalizedLimit
}
}
async getMessageContextForAI(
sessionId: string,
messageIds: number | number[],
contextSize = 20
): Promise<AiMessageResult[]> {
const ids = Array.isArray(messageIds) ? messageIds : [messageIds]
const uniqueIds = Array.from(new Set(ids.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)))
if (uniqueIds.length === 0) return []
const size = Math.max(0, Math.min(120, Number(contextSize || 20)))
const merged = new Map<number, AiMessageResult>()
for (const id of uniqueIds) {
const target = await this.getMessageById(sessionId, id)
if (target.success && target.message) {
merged.set(id, this.toAiMessage(sessionId, target.message))
}
if (size <= 0) continue
const [before, after] = await Promise.all([
this.getMessagesBeforeForAI(sessionId, id, size),
this.getMessagesAfterForAI(sessionId, id, size)
])
for (const item of before.messages) merged.set(item.id, item)
for (const item of after.messages) merged.set(item.id, item)
}
return Array.from(merged.values()).sort((a, b) => {
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return a.id - b.id
})
}
async getSearchMessageContextForAI(
sessionId: string,
messageIds: number[],
contextBefore = 2,
contextAfter = 2
): Promise<AiMessageResult[]> {
const uniqueIds = Array.from(new Set((messageIds || []).map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)))
if (uniqueIds.length === 0) return []
const beforeLimit = Math.max(0, Math.min(30, Number(contextBefore || 2)))
const afterLimit = Math.max(0, Math.min(30, Number(contextAfter || 2)))
const merged = new Map<number, AiMessageResult>()
for (const id of uniqueIds) {
const target = await this.getMessageById(sessionId, id)
if (target.success && target.message) {
merged.set(id, this.toAiMessage(sessionId, target.message))
}
const [before, after] = await Promise.all([
beforeLimit > 0 ? this.getMessagesBeforeForAI(sessionId, id, beforeLimit) : Promise.resolve({ messages: [], hasMore: false }),
afterLimit > 0 ? this.getMessagesAfterForAI(sessionId, id, afterLimit) : Promise.resolve({ messages: [], hasMore: false })
])
for (const item of before.messages) merged.set(item.id, item)
for (const item of after.messages) merged.set(item.id, item)
}
return Array.from(merged.values()).sort((a, b) => {
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return a.id - b.id
})
}
async getConversationBetweenForAI(
sessionId: string,
memberId1: number,
memberId2: number,
filter?: AiTimeFilter,
limit = 100
): Promise<{ messages: AiMessageResult[]; total: number; member1Name: string; member2Name: string }> {
const normalizedLimit = Math.max(1, Math.min(500, Number(limit || 100)))
const { begin, end } = this.normalizeAiFilter(filter)
const sample = await this.getMessages(sessionId, 0, Math.max(600, normalizedLimit * 8), begin, end, false)
if (!sample.success || !Array.isArray(sample.messages) || sample.messages.length === 0) {
return { messages: [], total: 0, member1Name: '', member2Name: '' }
}
const idSet = new Set<number>([Number(memberId1), Number(memberId2)].filter((id) => Number.isFinite(id) && id > 0))
const filtered = sample.messages.filter((message) => {
const senderId = this.hashSenderId(String(message.senderUsername || ''))
return idSet.has(senderId) && String(message.parsedContent || message.rawContent || '').trim().length > 0
})
const picked = filtered.slice(-normalizedLimit)
const names = Array.from(new Set(picked.map((message) => String(message.senderUsername || '').trim()).filter(Boolean)))
return {
messages: picked.map((message) => this.toAiMessage(sessionId, message)),
total: filtered.length,
member1Name: names[0] || '',
member2Name: names[1] || names[0] || ''
}
}
async searchSessionsForAI(
_sessionId: string,
keywords?: string[],
timeFilter?: AiTimeFilter,
limit = 20,
previewCount = 5
): Promise<AiSessionSearchResult[]> {
const normalizedLimit = Math.max(1, Math.min(60, Number(limit || 20)))
const normalizedPreview = Math.max(1, Math.min(20, Number(previewCount || 5)))
const { begin, end } = this.normalizeAiFilter(timeFilter)
const tokenList = Array.from(new Set((keywords || []).map((keyword) => String(keyword || '').trim()).filter(Boolean)))
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) return []
const sessionMap = new Map<string, ChatSession>()
for (const session of sessionsResult.sessions) {
const sid = String(session.username || '').trim()
if (!sid) continue
sessionMap.set(sid, session)
}
const rows: Array<{ sessionId: string; hitCount: number }> = []
if (tokenList.length > 0) {
const native = await wcdbService.aiQuerySessionCandidates({
keyword: tokenList.join(' '),
limit: normalizedLimit * 4,
beginTimestamp: begin,
endTimestamp: end
})
if (native.success && Array.isArray(native.rows)) {
for (const row of native.rows as Record<string, any>[]) {
const sid = String(row.session_id || row._session_id || row.sessionId || '').trim()
if (!sid) continue
rows.push({
sessionId: sid,
hitCount: this.toSafeInt(row.hit_count ?? row.count ?? row.message_count, 0)
})
}
}
}
const candidateIds = rows.length > 0
? Array.from(new Set(rows.map((item) => item.sessionId)))
: sessionsResult.sessions
.filter((session) => {
if (begin > 0 && Number(session.lastTimestamp || session.sortTimestamp || 0) < begin) return false
if (end > 0 && Number(session.lastTimestamp || session.sortTimestamp || 0) > end) return false
return true
})
.slice(0, normalizedLimit * 2)
.map((session) => String(session.username || '').trim())
.filter(Boolean)
const output: AiSessionSearchResult[] = []
for (const sid of candidateIds.slice(0, normalizedLimit)) {
const latest = await this.getLatestMessages(sid, normalizedPreview)
const messages = Array.isArray(latest.messages) ? latest.messages : []
const mapped = messages.map((message) => this.toAiMessage(sid, message)).slice(-normalizedPreview)
const hitRow = rows.find((item) => item.sessionId === sid)
const session = sessionMap.get(sid)
const tsList = mapped.map((item) => item.timestamp).filter((value) => Number.isFinite(value) && value > 0)
const startTs = tsList.length > 0 ? Math.min(...tsList) : 0
const endTs = tsList.length > 0 ? Math.max(...tsList) : Number(session?.lastTimestamp || session?.sortTimestamp || 0)
output.push({
id: sid,
startTs,
endTs,
messageCount: hitRow?.hitCount || mapped.length,
isComplete: mapped.length <= normalizedPreview,
previewMessages: mapped
})
}
return output
}
async getSessionMessagesForAI(
_sessionId: string,
chatSessionId: string | number,
limit = 500
): Promise<{
sessionId: string
startTs: number
endTs: number
messageCount: number
returnedCount: number
participants: string[]
messages: AiMessageResult[]
} | null> {
const sid = String(chatSessionId || '').trim()
if (!sid) return null
const normalizedLimit = Math.max(1, Math.min(1000, Number(limit || 500)))
const latest = await this.getLatestMessages(sid, normalizedLimit)
if (!latest.success || !Array.isArray(latest.messages)) return null
const mapped = latest.messages.map((message) => this.toAiMessage(sid, message))
const tsList = mapped.map((item) => item.timestamp).filter((value) => Number.isFinite(value) && value > 0)
const count = await this.getMessageCount(sid)
return {
sessionId: sid,
startTs: tsList.length > 0 ? Math.min(...tsList) : 0,
endTs: tsList.length > 0 ? Math.max(...tsList) : 0,
messageCount: count.success ? Number(count.count || mapped.length) : mapped.length,
returnedCount: mapped.length,
participants: Array.from(new Set(mapped.map((item) => item.senderName).filter(Boolean))),
messages: mapped
}
}
async getSessionSummariesForAI(
_sessionId: string,
options?: {
sessionIds?: string[]
limit?: number
previewCount?: number
}
): Promise<Array<{
sessionId: string
sessionName: string
messageCount: number
latestTs: number
previewMessages: AiMessageResult[]
}>> {
const normalizedLimit = Math.max(1, Math.min(60, Number(options?.limit || 20)))
const previewCount = Math.max(1, Math.min(20, Number(options?.previewCount || 3)))
const sessionsResult = await this.getSessions()
if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) return []
const explicitIds = Array.isArray(options?.sessionIds)
? options?.sessionIds.map((value) => String(value || '').trim()).filter(Boolean)
: []
const candidates = explicitIds.length > 0
? sessionsResult.sessions.filter((session) => explicitIds.includes(String(session.username || '').trim()))
: sessionsResult.sessions.slice(0, normalizedLimit)
const summaries: Array<{
sessionId: string
sessionName: string
messageCount: number
latestTs: number
previewMessages: AiMessageResult[]
}> = []
for (const session of candidates.slice(0, normalizedLimit)) {
const sid = String(session.username || '').trim()
if (!sid) continue
const [countResult, latestResult] = await Promise.all([
this.getMessageCount(sid),
this.getLatestMessages(sid, previewCount)
])
const previewMessages = Array.isArray(latestResult.messages)
? latestResult.messages.map((message) => this.toAiMessage(sid, message)).slice(-previewCount)
: []
summaries.push({
sessionId: sid,
sessionName: String(session.displayName || sid),
messageCount: countResult.success ? Number(countResult.count || previewMessages.length) : previewMessages.length,
latestTs: Number(session.lastTimestamp || session.sortTimestamp || 0),
previewMessages
})
}
return summaries
}
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
try { try {
const nativeResult = await wcdbService.getMessageById(sessionId, localId) const nativeResult = await wcdbService.getMessageById(sessionId, localId)

View File

@@ -74,6 +74,16 @@ interface ConfigSchema {
aiModelApiBaseUrl: string aiModelApiBaseUrl: string
aiModelApiKey: string aiModelApiKey: string
aiModelApiModel: string aiModelApiModel: string
aiAgentMaxMessagesPerRequest: number
aiAgentMaxHistoryRounds: number
aiAgentEnableAutoSkill: boolean
aiAgentSearchContextBefore: number
aiAgentSearchContextAfter: number
aiAgentPreprocessClean: boolean
aiAgentPreprocessMerge: boolean
aiAgentPreprocessDenoise: boolean
aiAgentPreprocessDesensitize: boolean
aiAgentPreprocessAnonymize: boolean
aiInsightEnabled: boolean aiInsightEnabled: boolean
aiInsightApiBaseUrl: string aiInsightApiBaseUrl: string
aiInsightApiKey: string aiInsightApiKey: string
@@ -184,6 +194,16 @@ export class ConfigService {
aiModelApiBaseUrl: '', aiModelApiBaseUrl: '',
aiModelApiKey: '', aiModelApiKey: '',
aiModelApiModel: 'gpt-4o-mini', aiModelApiModel: 'gpt-4o-mini',
aiAgentMaxMessagesPerRequest: 120,
aiAgentMaxHistoryRounds: 12,
aiAgentEnableAutoSkill: true,
aiAgentSearchContextBefore: 3,
aiAgentSearchContextAfter: 3,
aiAgentPreprocessClean: true,
aiAgentPreprocessMerge: true,
aiAgentPreprocessDenoise: true,
aiAgentPreprocessDesensitize: false,
aiAgentPreprocessAnonymize: false,
aiInsightEnabled: false, aiInsightEnabled: false,
aiInsightApiBaseUrl: '', aiInsightApiBaseUrl: '',
aiInsightApiKey: '', aiInsightApiKey: '',

View File

@@ -62,6 +62,8 @@ export class WcdbCore {
private wcdbGetMessageDates: any = null private wcdbGetMessageDates: any = null
private wcdbOpenMessageCursor: any = null private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null private wcdbOpenMessageCursorLite: any = null
private wcdbOpenMessageCursorWithKey: any = null
private wcdbOpenMessageCursorLiteWithKey: any = null
private wcdbFetchMessageBatch: any = null private wcdbFetchMessageBatch: any = null
private wcdbCloseMessageCursor: any = null private wcdbCloseMessageCursor: any = null
private wcdbGetLogs: any = null private wcdbGetLogs: any = null
@@ -85,6 +87,19 @@ export class WcdbCore {
private wcdbScanMediaStream: any = null private wcdbScanMediaStream: any = null
private wcdbGetHeadImageBuffers: any = null private wcdbGetHeadImageBuffers: any = null
private wcdbSearchMessages: any = null private wcdbSearchMessages: any = null
private wcdbAiQuerySessionCandidates: any = null
private wcdbAiQueryTimeline: any = null
private wcdbAiQueryTopicStats: any = null
private wcdbAiQuerySourceRefs: any = null
private wcdbAiGetRecentMessages: any = null
private wcdbAiGetMessagesBefore: any = null
private wcdbAiGetMessagesAfter: any = null
private wcdbAiGetMessageContext: any = null
private wcdbAiGetSearchMessageContext: any = null
private wcdbAiGetConversationBetween: any = null
private wcdbAiSearchSessions: any = null
private wcdbAiGetSessionMessages: any = null
private wcdbAiGetSessionSummaries: any = null
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null private wcdbGetSnsAnnualStats: any = null
private wcdbGetSnsUsernames: any = null private wcdbGetSnsUsernames: any = null
@@ -943,6 +958,15 @@ export class WcdbCore {
// wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) // wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
// wcdb_status wcdb_open_message_cursor_with_key(...)
try {
this.wcdbOpenMessageCursorWithKey = this.lib.func(
'int32 wcdb_open_message_cursor_with_key(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, int32 keyValid, int64 keySortSeq, int64 keyCreateTime, int64 keyLocalId, _Out_ int64* outCursor)'
)
} catch {
this.wcdbOpenMessageCursorWithKey = null
}
// wcdb_status wcdb_open_message_cursor_lite(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor) // wcdb_status wcdb_open_message_cursor_lite(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
try { try {
this.wcdbOpenMessageCursorLite = this.lib.func('int32 wcdb_open_message_cursor_lite(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)') this.wcdbOpenMessageCursorLite = this.lib.func('int32 wcdb_open_message_cursor_lite(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
@@ -950,6 +974,15 @@ export class WcdbCore {
this.wcdbOpenMessageCursorLite = null this.wcdbOpenMessageCursorLite = null
} }
// wcdb_status wcdb_open_message_cursor_lite_with_key(...)
try {
this.wcdbOpenMessageCursorLiteWithKey = this.lib.func(
'int32 wcdb_open_message_cursor_lite_with_key(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, int32 keyValid, int64 keySortSeq, int64 keyCreateTime, int64 keyLocalId, _Out_ int64* outCursor)'
)
} catch {
this.wcdbOpenMessageCursorLiteWithKey = null
}
// wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more) // wcdb_status wcdb_fetch_message_batch(wcdb_handle handle, wcdb_cursor cursor, char** out_json, int32_t* out_has_more)
this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)') this.wcdbFetchMessageBatch = this.lib.func('int32 wcdb_fetch_message_batch(int64 handle, int64 cursor, _Out_ void** outJson, _Out_ int32* outHasMore)')
@@ -1060,6 +1093,71 @@ export class WcdbCore {
} catch { } catch {
this.wcdbSearchMessages = null this.wcdbSearchMessages = null
} }
try {
this.wcdbAiQuerySessionCandidates = this.lib.func('int32 wcdb_ai_query_session_candidates(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQuerySessionCandidates = null
}
try {
this.wcdbAiQueryTimeline = this.lib.func('int32 wcdb_ai_query_timeline(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQueryTimeline = null
}
try {
this.wcdbAiQueryTopicStats = this.lib.func('int32 wcdb_ai_query_topic_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQueryTopicStats = null
}
try {
this.wcdbAiQuerySourceRefs = this.lib.func('int32 wcdb_ai_query_source_refs(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQuerySourceRefs = null
}
try {
this.wcdbAiGetRecentMessages = this.lib.func('int32 wcdb_ai_get_recent_messages(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetRecentMessages = null
}
try {
this.wcdbAiGetMessagesBefore = this.lib.func('int32 wcdb_ai_get_messages_before(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetMessagesBefore = null
}
try {
this.wcdbAiGetMessagesAfter = this.lib.func('int32 wcdb_ai_get_messages_after(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetMessagesAfter = null
}
try {
this.wcdbAiGetMessageContext = this.lib.func('int32 wcdb_ai_get_message_context(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetMessageContext = null
}
try {
this.wcdbAiGetSearchMessageContext = this.lib.func('int32 wcdb_ai_get_search_message_context(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetSearchMessageContext = null
}
try {
this.wcdbAiGetConversationBetween = this.lib.func('int32 wcdb_ai_get_conversation_between(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetConversationBetween = null
}
try {
this.wcdbAiSearchSessions = this.lib.func('int32 wcdb_ai_search_sessions(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiSearchSessions = null
}
try {
this.wcdbAiGetSessionMessages = this.lib.func('int32 wcdb_ai_get_session_messages(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetSessionMessages = null
}
try {
this.wcdbAiGetSessionSummaries = this.lib.func('int32 wcdb_ai_get_session_summaries(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiGetSessionSummaries = null
}
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
try { try {
@@ -3256,6 +3354,80 @@ export class WcdbCore {
} }
} }
async openMessageCursorWithKey(
sessionId: string,
batchSize: number,
ascending: boolean,
beginTimestamp: number,
endTimestamp: number,
key?: { sortSeq?: number; createTime?: number; localId?: number }
): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
const keySortSeq = Number.isFinite(Number(key?.sortSeq)) ? Math.floor(Number(key?.sortSeq)) : 0
const keyCreateTime = Number.isFinite(Number(key?.createTime)) ? Math.floor(Number(key?.createTime)) : 0
const keyLocalId = Number.isFinite(Number(key?.localId)) ? Math.floor(Number(key?.localId)) : 0
const keyValid = keySortSeq > 0 || keyCreateTime > 0 || keyLocalId > 0
if (!keyValid || !this.wcdbOpenMessageCursorWithKey) {
return this.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
}
try {
const outCursor = [0]
let result = this.wcdbOpenMessageCursorWithKey(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
1,
keySortSeq,
keyCreateTime,
keyLocalId,
outCursor
)
if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) {
this.writeLog('openMessageCursorWithKey: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
outCursor[0] = 0
result = this.wcdbOpenMessageCursorWithKey(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
1,
keySortSeq,
keyCreateTime,
keyLocalId,
outCursor
)
this.writeLog(`openMessageCursorWithKey retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true)
}
}
if (result !== 0 || outCursor[0] <= 0) {
if (result !== -3) {
await this.printLogs(true)
this.writeLog(
`openMessageCursorWithKey failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
}
return { success: false, error: `创建游标失败: ${result}` }
}
return { success: true, cursor: outCursor[0] }
} catch (e) {
await this.printLogs(true)
this.writeLog(`openMessageCursorWithKey exception: ${String(e)}`, true)
return { success: false, error: '创建游标异常,请查看日志' }
}
}
async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { async openMessageCursorLite(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -3318,6 +3490,83 @@ export class WcdbCore {
} }
} }
async openMessageCursorLiteWithKey(
sessionId: string,
batchSize: number,
ascending: boolean,
beginTimestamp: number,
endTimestamp: number,
key?: { sortSeq?: number; createTime?: number; localId?: number }
): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
const keySortSeq = Number.isFinite(Number(key?.sortSeq)) ? Math.floor(Number(key?.sortSeq)) : 0
const keyCreateTime = Number.isFinite(Number(key?.createTime)) ? Math.floor(Number(key?.createTime)) : 0
const keyLocalId = Number.isFinite(Number(key?.localId)) ? Math.floor(Number(key?.localId)) : 0
const keyValid = keySortSeq > 0 || keyCreateTime > 0 || keyLocalId > 0
if (!keyValid) {
return this.openMessageCursorLite(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
}
if (!this.wcdbOpenMessageCursorLiteWithKey) {
return this.openMessageCursorWithKey(sessionId, batchSize, ascending, beginTimestamp, endTimestamp, key)
}
try {
const outCursor = [0]
let result = this.wcdbOpenMessageCursorLiteWithKey(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
1,
keySortSeq,
keyCreateTime,
keyLocalId,
outCursor
)
if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) {
this.writeLog('openMessageCursorLiteWithKey: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
outCursor[0] = 0
result = this.wcdbOpenMessageCursorLiteWithKey(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
1,
keySortSeq,
keyCreateTime,
keyLocalId,
outCursor
)
this.writeLog(`openMessageCursorLiteWithKey retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true)
}
}
if (result !== 0 || outCursor[0] <= 0) {
if (result !== -3) {
await this.printLogs(true)
this.writeLog(
`openMessageCursorLiteWithKey failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
}
return { success: false, error: `创建游标失败: ${result}` }
}
return { success: true, cursor: outCursor[0] }
} catch (e) {
await this.printLogs(true)
this.writeLog(`openMessageCursorLiteWithKey exception: ${String(e)}`, true)
return { success: false, error: '创建游标异常,请查看日志' }
}
}
async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> { async fetchMessageBatch(cursor: number): Promise<{ success: boolean; rows?: any[]; hasMore?: boolean; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -3370,6 +3619,204 @@ export class WcdbCore {
} }
} }
private normalizeSqlIdentifier(name: string): string {
return `"${String(name || '').replace(/"/g, '""')}"`
}
private stripSqlComments(sql: string): string {
return String(sql || '')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/--[^\n\r]*/g, ' ')
.trim()
}
private isSqlLabReadOnly(sql: string): boolean {
const normalized = this.stripSqlComments(sql).trim()
if (!normalized) return false
if (normalized.includes('\u0000')) return false
const hasMultipleStatements = /;[\s\r\n]*\S/.test(normalized)
if (hasMultipleStatements) return false
const lower = normalized.toLowerCase()
if (/(insert|update|delete|drop|alter|create|attach|detach|replace|truncate|reindex|vacuum|analyze|begin|commit|rollback|savepoint|release)\b/.test(lower)) {
return false
}
if (/pragma\s+.*(writable_schema|journal_mode|locking_mode|foreign_keys)\s*=/.test(lower)) {
return false
}
return /^(select|with|pragma|explain)\b/.test(lower)
}
private async sqlLabListTablesForSource(
kind: 'message' | 'contact' | 'biz',
path: string | null,
maxTables: number = 60,
maxColumns: number = 120
): Promise<Array<{ name: string; columns: string[] }>> {
const tableRows = await this.execQuery(
kind,
path,
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT ${Math.max(1, maxTables)}`
)
if (!tableRows.success || !Array.isArray(tableRows.rows)) return []
const tables: Array<{ name: string; columns: string[] }> = []
for (const row of tableRows.rows) {
const tableName = String((row as any)?.name || '').trim()
if (!tableName) continue
const pragma = await this.execQuery(kind, path, `PRAGMA table_info(${this.normalizeSqlIdentifier(tableName)})`)
const columns = pragma.success && Array.isArray(pragma.rows)
? pragma.rows
.map((item: any) => String(item?.name || '').trim())
.filter(Boolean)
.slice(0, maxColumns)
: []
tables.push({ name: tableName, columns })
}
return tables
}
async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const sessionId = String(payload?.sessionId || '').trim()
const sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}> = []
if (sessionId) {
const tableStats = await this.getMessageTableStats(sessionId)
const tableEntries = tableStats.success && Array.isArray(tableStats.tables) ? tableStats.tables : []
const dbPathSet = new Set<string>()
for (const entry of tableEntries) {
const dbPath = String((entry as any)?.db_path || '').trim()
if (!dbPath) continue
dbPathSet.add(dbPath)
}
for (const dbPath of Array.from(dbPathSet).slice(0, 8)) {
sources.push({
kind: 'message',
path: dbPath,
label: dbPath.split(/[\\/]/).pop() || dbPath,
tables: await this.sqlLabListTablesForSource('message', dbPath)
})
}
} else {
const messageDbs = await this.listMessageDbs()
const paths = messageDbs.success && Array.isArray(messageDbs.data) ? messageDbs.data : []
for (const dbPath of paths.slice(0, 8)) {
sources.push({
kind: 'message',
path: dbPath,
label: dbPath.split(/[\\/]/).pop() || dbPath,
tables: await this.sqlLabListTablesForSource('message', dbPath)
})
}
}
sources.push({
kind: 'contact',
path: null,
label: 'contact',
tables: await this.sqlLabListTablesForSource('contact', null)
})
sources.push({
kind: 'biz',
path: null,
label: 'biz',
tables: await this.sqlLabListTablesForSource('biz', null)
})
const schemaText = sources
.map((source) => {
const tableLines = source.tables
.map((table) => `- ${table.name} (${table.columns.join(', ')})`)
.join('\n')
return `[${source.kind}] ${source.label}\n${tableLines}`
})
.join('\n\n')
return {
success: true,
schema: {
generatedAt: Date.now(),
sources
},
schemaText
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async sqlLabExecuteReadonly(payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}): Promise<{
success: boolean
rows?: any[]
columns?: string[]
total?: number
error?: string
}> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const sql = String(payload?.sql || '').trim()
if (!this.isSqlLabReadOnly(sql)) {
return { success: false, error: '仅允许只读 SQLSELECT/WITH/PRAGMA/EXPLAIN' }
}
const kind = payload?.kind === 'contact' || payload?.kind === 'biz' ? payload.kind : 'message'
const path = kind === 'message'
? (payload?.path == null ? null : String(payload.path))
: null
const limit = Math.max(1, Math.min(1000, Number(payload?.limit || 200)))
const sqlNoTail = sql.replace(/;+\s*$/, '')
const lower = sqlNoTail.toLowerCase()
const executable = /^(select|with)\b/.test(lower)
? `SELECT * FROM (${sqlNoTail}) LIMIT ${limit}`
: sqlNoTail
const result = await this.execQuery(kind, path, executable)
if (!result.success) {
return { success: false, error: result.error || '执行 SQL 失败' }
}
const rows = Array.isArray(result.rows) ? result.rows : []
return {
success: true,
rows,
columns: rows[0] && typeof rows[0] === 'object' ? Object.keys(rows[0] as Record<string, unknown>) : [],
total: rows.length
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -3979,6 +4426,347 @@ export class WcdbCore {
} }
} }
async aiQuerySessionCandidates(options: {
keyword: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiQuerySessionCandidates) return { success: false, error: '当前数据服务版本不支持 AI 候选会话查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiQuerySessionCandidates(this.handle, JSON.stringify({
keyword: options.keyword || '',
limit: options.limit || 12,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 候选会话查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 候选会话结果失败' }
const rows = JSON.parse(jsonStr)
return { success: true, rows: Array.isArray(rows) ? rows : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiQueryTimeline(options: {
sessionId?: string
keyword: string
limit?: number
offset?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiQueryTimeline) return { success: false, error: '当前数据服务版本不支持 AI 时间轴查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiQueryTimeline(this.handle, JSON.stringify({
session_id: options.sessionId || '',
keyword: options.keyword || '',
limit: options.limit || 120,
offset: options.offset || 0,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 时间轴查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 时间轴结果失败' }
const rows = this.parseMessageJson(jsonStr)
return { success: true, rows }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiQueryTopicStats(options: {
sessionIds: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiQueryTopicStats) return { success: false, error: '当前数据服务版本不支持 AI 主题统计' }
try {
const outPtr = [null as any]
const result = this.wcdbAiQueryTopicStats(this.handle, JSON.stringify({
session_ids_json: JSON.stringify(options.sessionIds || []),
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 主题统计失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 主题统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiQuerySourceRefs(options: {
sessionIds: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiQuerySourceRefs) return { success: false, error: '当前数据服务版本不支持 AI 来源引用查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiQuerySourceRefs(this.handle, JSON.stringify({
session_ids_json: JSON.stringify(options.sessionIds || []),
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 来源引用查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 来源引用查询失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetRecentMessages(options: {
sessionId: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetRecentMessages) return { success: false, error: '当前数据服务版本不支持 AI 最近消息查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetRecentMessages(this.handle, JSON.stringify({
session_id: options.sessionId || '',
limit: options.limit || 120,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 最近消息查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 最近消息查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetMessagesBefore(options: {
sessionId: string
beforeId?: number
beforeLocalId?: number
beforeCreateTime?: number
beforeSortSeq?: number
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetMessagesBefore) return { success: false, error: '当前数据服务版本不支持 AI 前向消息查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetMessagesBefore(this.handle, JSON.stringify({
session_id: options.sessionId || '',
before_id: options.beforeId || 0,
before_local_id: options.beforeLocalId || options.beforeId || 0,
before_create_time: options.beforeCreateTime || 0,
before_sort_seq: options.beforeSortSeq || 0,
limit: options.limit || 120,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 前向消息查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 前向消息查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetMessagesAfter(options: {
sessionId: string
afterId?: number
afterLocalId?: number
afterCreateTime?: number
afterSortSeq?: number
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetMessagesAfter) return { success: false, error: '当前数据服务版本不支持 AI 后向消息查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetMessagesAfter(this.handle, JSON.stringify({
session_id: options.sessionId || '',
after_id: options.afterId || 0,
after_local_id: options.afterLocalId || options.afterId || 0,
after_create_time: options.afterCreateTime || 0,
after_sort_seq: options.afterSortSeq || 0,
limit: options.limit || 120,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 后向消息查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 后向消息查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetMessageContext(options: {
sessionId: string
messageIds: number[]
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetMessageContext) return { success: false, error: '当前数据服务版本不支持 AI 消息上下文查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetMessageContext(this.handle, JSON.stringify({
session_id: options.sessionId || '',
message_ids: Array.isArray(options.messageIds) ? options.messageIds : []
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 消息上下文查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 消息上下文查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetSearchMessageContext(options: {
sessionId: string
messageIds: number[]
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetSearchMessageContext) return { success: false, error: '当前数据服务版本不支持 AI 搜索上下文查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetSearchMessageContext(this.handle, JSON.stringify({
session_id: options.sessionId || '',
message_ids: Array.isArray(options.messageIds) ? options.messageIds : []
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 搜索上下文查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 搜索上下文查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetConversationBetween(options: {
sessionId: string
memberId1?: number
memberId2?: number
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetConversationBetween) return { success: false, error: '当前数据服务版本不支持 AI 双人对话查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetConversationBetween(this.handle, JSON.stringify({
session_id: options.sessionId || '',
member_id1: options.memberId1 || 0,
member_id2: options.memberId2 || 0,
limit: options.limit || 120,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 双人对话查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 双人对话查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiSearchSessions(options: {
keyword?: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiSearchSessions) return { success: false, error: '当前数据服务版本不支持 AI 会话搜索' }
try {
const outPtr = [null as any]
const result = this.wcdbAiSearchSessions(this.handle, JSON.stringify({
keyword: options.keyword || '',
limit: options.limit || 20,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 会话搜索失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 会话搜索失败' }
const rows = JSON.parse(jsonStr)
return { success: true, rows: Array.isArray(rows) ? rows : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetSessionMessages(options: {
sessionId: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetSessionMessages) return { success: false, error: '当前数据服务版本不支持 AI 会话消息查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetSessionMessages(this.handle, JSON.stringify({
session_id: options.sessionId || '',
limit: options.limit || 500,
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 会话消息查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 会话消息查询失败' }
return { success: true, rows: this.parseMessageJson(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async aiGetSessionSummaries(options: {
sessionIds?: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbAiGetSessionSummaries) return { success: false, error: '当前数据服务版本不支持 AI 会话摘要查询' }
try {
const outPtr = [null as any]
const result = this.wcdbAiGetSessionSummaries(this.handle, JSON.stringify({
session_ids_json: JSON.stringify(options.sessionIds || []),
begin_timestamp: options.beginTimestamp || 0,
end_timestamp: options.endTimestamp || 0
}), outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `AI 会话摘要查询失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析 AI 会话摘要查询失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' }

View File

@@ -468,6 +468,24 @@ export class WcdbService {
return this.callWorker('openMessageCursor', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp }) return this.callWorker('openMessageCursor', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp })
} }
async openMessageCursorWithKey(
sessionId: string,
batchSize: number,
ascending: boolean,
beginTimestamp: number,
endTimestamp: number,
key?: { sortSeq?: number; createTime?: number; localId?: number }
): Promise<{ success: boolean; cursor?: number; error?: string }> {
return this.callWorker('openMessageCursorWithKey', {
sessionId,
batchSize,
ascending,
beginTimestamp,
endTimestamp,
key
})
}
/** /**
* 打开轻量级消息游标 * 打开轻量级消息游标
*/ */
@@ -475,6 +493,24 @@ export class WcdbService {
return this.callWorker('openMessageCursorLite', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp }) return this.callWorker('openMessageCursorLite', { sessionId, batchSize, ascending, beginTimestamp, endTimestamp })
} }
async openMessageCursorLiteWithKey(
sessionId: string,
batchSize: number,
ascending: boolean,
beginTimestamp: number,
endTimestamp: number,
key?: { sortSeq?: number; createTime?: number; localId?: number }
): Promise<{ success: boolean; cursor?: number; error?: string }> {
return this.callWorker('openMessageCursorLiteWithKey', {
sessionId,
batchSize,
ascending,
beginTimestamp,
endTimestamp,
key
})
}
/** /**
* 获取下一批消息 * 获取下一批消息
*/ */
@@ -489,6 +525,44 @@ export class WcdbService {
return this.callWorker('closeMessageCursor', { cursor }) return this.callWorker('closeMessageCursor', { cursor })
} }
/**
* SQL Lab: 获取多数据源 Schema 摘要
*/
async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}> {
return this.callWorker('sqlLabGetSchema', payload || {})
}
/**
* SQL Lab: 执行只读 SQL
*/
async sqlLabExecuteReadonly(payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}): Promise<{
success: boolean
rows?: any[]
columns?: string[]
total?: number
error?: string
}> {
return this.callWorker('sqlLabExecuteReadonly', payload)
}
/** /**
* 执行 SQL 查询仅主进程内部使用fallback/diagnostic/低频兼容) * 执行 SQL 查询仅主进程内部使用fallback/diagnostic/低频兼容)
*/ */
@@ -542,6 +616,128 @@ export class WcdbService {
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp }) return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
} }
async aiQuerySessionCandidates(options: {
keyword: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiQuerySessionCandidates', { options })
}
async aiQueryTimeline(options: {
sessionId?: string
keyword: string
limit?: number
offset?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiQueryTimeline', { options })
}
async aiQueryTopicStats(options: {
sessionIds: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('aiQueryTopicStats', { options })
}
async aiQuerySourceRefs(options: {
sessionIds: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('aiQuerySourceRefs', { options })
}
async aiGetRecentMessages(options: {
sessionId: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetRecentMessages', { options })
}
async aiGetMessagesBefore(options: {
sessionId: string
beforeId?: number
beforeLocalId?: number
beforeCreateTime?: number
beforeSortSeq?: number
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetMessagesBefore', { options })
}
async aiGetMessagesAfter(options: {
sessionId: string
afterId?: number
afterLocalId?: number
afterCreateTime?: number
afterSortSeq?: number
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetMessagesAfter', { options })
}
async aiGetMessageContext(options: {
sessionId: string
messageIds: number[]
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetMessageContext', { options })
}
async aiGetSearchMessageContext(options: {
sessionId: string
messageIds: number[]
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetSearchMessageContext', { options })
}
async aiGetConversationBetween(options: {
sessionId: string
memberId1?: number
memberId2?: number
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetConversationBetween', { options })
}
async aiSearchSessions(options: {
keyword?: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiSearchSessions', { options })
}
async aiGetSessionMessages(options: {
sessionId: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiGetSessionMessages', { options })
}
async aiGetSessionSummaries(options: {
sessionIds?: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('aiGetSessionSummaries', { options })
}
/** /**
* 获取语音数据 * 获取语音数据
*/ */

View File

@@ -164,15 +164,41 @@ if (parentPort) {
case 'openMessageCursor': case 'openMessageCursor':
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break break
case 'openMessageCursorWithKey':
result = await core.openMessageCursorWithKey(
payload.sessionId,
payload.batchSize,
payload.ascending,
payload.beginTimestamp,
payload.endTimestamp,
payload.key
)
break
case 'openMessageCursorLite': case 'openMessageCursorLite':
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break break
case 'openMessageCursorLiteWithKey':
result = await core.openMessageCursorLiteWithKey(
payload.sessionId,
payload.batchSize,
payload.ascending,
payload.beginTimestamp,
payload.endTimestamp,
payload.key
)
break
case 'fetchMessageBatch': case 'fetchMessageBatch':
result = await core.fetchMessageBatch(payload.cursor) result = await core.fetchMessageBatch(payload.cursor)
break break
case 'closeMessageCursor': case 'closeMessageCursor':
result = await core.closeMessageCursor(payload.cursor) result = await core.closeMessageCursor(payload.cursor)
break break
case 'sqlLabGetSchema':
result = await core.sqlLabGetSchema(payload)
break
case 'sqlLabExecuteReadonly':
result = await core.sqlLabExecuteReadonly(payload)
break
case 'execQuery': case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params) result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
break break
@@ -197,6 +223,45 @@ if (parentPort) {
case 'searchMessages': case 'searchMessages':
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp) result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
break break
case 'aiQuerySessionCandidates':
result = await core.aiQuerySessionCandidates(payload.options || {})
break
case 'aiQueryTimeline':
result = await core.aiQueryTimeline(payload.options || {})
break
case 'aiQueryTopicStats':
result = await core.aiQueryTopicStats(payload.options || {})
break
case 'aiQuerySourceRefs':
result = await core.aiQuerySourceRefs(payload.options || {})
break
case 'aiGetRecentMessages':
result = await core.aiGetRecentMessages(payload.options || {})
break
case 'aiGetMessagesBefore':
result = await core.aiGetMessagesBefore(payload.options || {})
break
case 'aiGetMessagesAfter':
result = await core.aiGetMessagesAfter(payload.options || {})
break
case 'aiGetMessageContext':
result = await core.aiGetMessageContext(payload.options || {})
break
case 'aiGetSearchMessageContext':
result = await core.aiGetSearchMessageContext(payload.options || {})
break
case 'aiGetConversationBetween':
result = await core.aiGetConversationBetween(payload.options || {})
break
case 'aiSearchSessions':
result = await core.aiSearchSessions(payload.options || {})
break
case 'aiGetSessionMessages':
result = await core.aiGetSessionMessages(payload.options || {})
break
case 'aiGetSessionSummaries':
result = await core.aiGetSessionSummaries(payload.options || {})
break
case 'getVoiceData': case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId) result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) { if (!result.success) {

View File

@@ -6,6 +6,7 @@ import RouteGuard from './components/RouteGuard'
import WelcomePage from './pages/WelcomePage' import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
import AiAnalysisPage from './pages/AiAnalysisPage'
import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage' import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
@@ -679,6 +680,7 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="/ai-analysis" element={<AiAnalysisPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} /> <Route path="/analytics" element={<ChatAnalyticsHubPage />} />
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} /> <Route path="/analytics/private" element={<AnalyticsWelcomePage />} />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints } from 'lucide-react' import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints, Sparkles } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -409,6 +409,16 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* AI分析 */}
<NavLink
to="/ai-analysis"
className={`nav-item ${isActive('/ai-analysis') ? 'active' : ''}`}
title={collapsed ? 'AI分析' : undefined}
>
<span className="nav-icon"><Sparkles size={20} /></span>
<span className="nav-label">AI分析</span>
</NavLink>
{/* 朋友圈 */} {/* 朋友圈 */}
<NavLink <NavLink
to="/sns" to="/sns"

View File

@@ -0,0 +1,787 @@
.ai-analysis-chatlab {
--ai-border: color-mix(in srgb, var(--border-color) 78%, #94a3b8 22%);
--ai-surface: color-mix(in srgb, var(--card-bg) 90%, #ffffff 10%);
--ai-surface-soft: color-mix(in srgb, var(--card-bg) 82%, #dbeafe 18%);
--ai-accent: #0f766e;
--ai-danger: #dc2626;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
background:
radial-gradient(1200px 360px at 8% -18%, color-mix(in srgb, #22c55e 18%, transparent), transparent 70%),
radial-gradient(980px 320px at 98% -12%, color-mix(in srgb, #0284c7 16%, transparent), transparent 70%),
var(--bg-primary);
}
.ai-topbar {
border: 1px solid var(--ai-border);
border-radius: 14px;
background: var(--ai-surface);
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.title-group {
display: flex;
align-items: center;
gap: 8px;
h1 {
margin: 0;
font-size: 15px;
}
span {
color: var(--text-secondary);
font-size: 12px;
}
}
.top-actions {
display: flex;
gap: 6px;
button {
border: 1px solid var(--ai-border);
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
border-radius: 10px;
padding: 6px 10px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
&.active {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--ai-accent) 50%, transparent);
background: color-mix(in srgb, var(--ai-accent) 12%, transparent);
}
}
}
}
.chat-shell {
min-height: 0;
display: grid;
gap: 12px;
grid-template-columns: 300px minmax(0, 1fr);
&.with-data {
grid-template-columns: 300px minmax(0, 1fr) 320px;
}
}
.conversation-sidebar,
.chat-main-panel,
.data-panel,
.schema-panel,
.sql-main {
border: 1px solid var(--ai-border);
border-radius: 14px;
background: var(--ai-surface);
min-height: 0;
}
.sidebar-head,
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--ai-border);
h3 {
margin: 0;
font-size: 13px;
}
button {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--ai-border);
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
cursor: pointer;
}
}
.conversation-list {
height: calc(100% - 49px);
overflow: auto;
padding: 10px;
display: grid;
gap: 8px;
}
.conversation-item {
border: 1px solid transparent;
border-radius: 11px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
color: var(--text-primary);
text-align: left;
padding: 10px;
cursor: pointer;
.main {
display: grid;
gap: 4px;
strong {
font-size: 13px;
font-weight: 600;
}
small {
font-size: 11px;
color: var(--text-tertiary);
}
}
.ops {
margin-top: 8px;
display: inline-flex;
gap: 8px;
color: var(--text-secondary);
font-size: 12px;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
&.active {
border-color: color-mix(in srgb, var(--ai-accent) 50%, transparent);
background: color-mix(in srgb, var(--ai-accent) 12%, transparent);
}
}
.chat-main-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
}
.chat-toolbar {
border-bottom: 1px solid var(--ai-border);
padding: 10px 12px;
display: grid;
gap: 8px;
.controls-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
label {
font-size: 12px;
color: var(--text-secondary);
}
select,
input {
border: 1px solid var(--ai-border);
border-radius: 8px;
background: var(--ai-surface-soft);
color: var(--text-primary);
padding: 6px 8px;
font-size: 12px;
min-width: 114px;
}
}
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
button {
border: 1px solid var(--ai-border);
border-radius: 999px;
padding: 4px 10px;
background: color-mix(in srgb, var(--ai-accent) 10%, transparent);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
}
.messages-wrap {
position: relative;
min-height: 0;
overflow: auto;
padding: 12px;
display: grid;
gap: 10px;
}
.message-card {
border: 1px solid var(--ai-border);
border-radius: 12px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
padding: 10px;
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
span,
time {
font-size: 12px;
color: var(--text-secondary);
}
}
.message-body {
margin-top: 8px;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
&.blocks {
display: grid;
gap: 8px;
}
}
&.user {
background: color-mix(in srgb, var(--ai-accent) 14%, transparent);
}
.tool-trace {
margin-top: 8px;
border-top: 1px dashed var(--ai-border);
padding-top: 8px;
font-size: 12px;
color: var(--text-secondary);
ul {
margin: 6px 0 0;
padding-left: 18px;
display: grid;
gap: 4px;
}
}
.think-block {
border-left: 2px solid color-mix(in srgb, var(--ai-accent) 38%, transparent);
padding-left: 10px;
summary {
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
gap: 6px;
align-items: center;
}
pre {
margin-top: 6px;
white-space: pre-wrap;
color: var(--text-tertiary);
font-size: 12px;
}
}
.tool-block {
border: 1px dashed var(--ai-border);
border-radius: 10px;
padding: 8px;
.line {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
strong {
color: var(--text-primary);
}
span {
color: var(--text-secondary);
}
}
pre {
margin-top: 6px;
white-space: pre-wrap;
font-size: 12px;
color: var(--text-tertiary);
}
&.done {
border-color: color-mix(in srgb, #16a34a 55%, transparent);
}
&.error {
border-color: color-mix(in srgb, #dc2626 55%, transparent);
}
}
.typing-cursor {
width: 8px;
display: inline-block;
animation: blink-cursor 1s step-start infinite;
}
}
@keyframes blink-cursor {
50% {
opacity: 0;
}
}
.scroll-bottom {
position: sticky;
bottom: 10px;
margin-left: auto;
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--ai-border);
background: var(--ai-surface);
color: var(--text-secondary);
cursor: pointer;
}
.status-row {
border-top: 1px solid var(--ai-border);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.left,
.right {
display: inline-flex;
align-items: center;
gap: 8px;
}
span {
font-size: 12px;
color: var(--text-secondary);
}
}
.input-panel {
border-top: 1px solid var(--ai-border);
padding: 10px 12px;
display: grid;
gap: 8px;
textarea {
width: 100%;
min-height: 88px;
border: 1px solid var(--ai-border);
border-radius: 10px;
background: var(--ai-surface-soft);
color: var(--text-primary);
padding: 10px;
resize: vertical;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
}
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
button {
border: 1px solid var(--ai-border);
border-radius: 999px;
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
}
}
.input-actions,
.status-row .right,
.top-actions,
.controls-row {
button {
border: 1px solid var(--ai-border);
border-radius: 8px;
padding: 6px 10px;
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&.primary {
background: color-mix(in srgb, var(--ai-accent) 18%, transparent);
border-color: color-mix(in srgb, var(--ai-accent) 52%, transparent);
color: var(--text-primary);
}
&.danger {
border-color: color-mix(in srgb, var(--ai-danger) 48%, transparent);
color: color-mix(in srgb, var(--ai-danger) 78%, var(--text-primary) 22%);
background: color-mix(in srgb, var(--ai-danger) 10%, transparent);
}
}
}
.data-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
> header {
padding: 10px 12px;
border-bottom: 1px solid var(--ai-border);
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 13px;
}
span {
font-size: 12px;
color: var(--text-secondary);
}
}
section {
padding: 10px 12px;
h4 {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-secondary);
}
}
.keywords {
border-bottom: 1px solid var(--ai-border);
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
span {
border-radius: 999px;
border: 1px solid var(--ai-border);
padding: 3px 8px;
font-size: 12px;
color: var(--text-secondary);
}
small {
color: var(--text-tertiary);
font-size: 12px;
}
}
}
.sources {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
.source-list {
min-height: 0;
overflow: auto;
display: grid;
gap: 8px;
article {
border: 1px solid var(--ai-border);
border-radius: 10px;
padding: 8px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
header {
display: flex;
justify-content: space-between;
gap: 8px;
strong,
time {
font-size: 11px;
color: var(--text-secondary);
}
}
p {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-primary);
line-height: 1.55;
}
}
}
}
}
.sql-shell {
min-height: 0;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
}
.schema-list {
padding: 10px;
overflow: auto;
max-height: calc(100% - 49px);
}
.schema-source {
border: 1px solid var(--ai-border);
border-radius: 10px;
padding: 8px;
margin-bottom: 8px;
h4 {
margin: 0 0 8px;
font-size: 12px;
}
ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
li {
display: grid;
gap: 2px;
strong {
font-size: 12px;
}
small {
font-size: 11px;
color: var(--text-tertiary);
}
}
}
}
.sql-main {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
gap: 10px;
padding: 12px;
}
.sql-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
select,
button {
border: 1px solid var(--ai-border);
border-radius: 8px;
background: var(--ai-surface-soft);
color: var(--text-primary);
padding: 6px 10px;
font-size: 12px;
}
}
.sql-prompt,
.sql-generated,
.tool-args {
width: 100%;
border: 1px solid var(--ai-border);
border-radius: 10px;
background: var(--ai-surface-soft);
color: var(--text-primary);
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.sql-prompt {
min-height: 90px;
}
.sql-generated {
min-height: 130px;
}
.error,
.global-error {
border: 1px solid color-mix(in srgb, #dc2626 50%, transparent);
background: color-mix(in srgb, #dc2626 11%, transparent);
color: color-mix(in srgb, #dc2626 80%, var(--text-primary) 20%);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
}
.sql-table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid var(--ai-border);
border-radius: 10px;
.sql-table {
width: 100%;
border-collapse: collapse;
th,
td {
border-bottom: 1px solid color-mix(in srgb, var(--ai-border) 70%, transparent);
padding: 7px 8px;
font-size: 12px;
text-align: left;
vertical-align: top;
white-space: pre-wrap;
word-break: break-word;
}
th {
position: sticky;
top: 0;
background: color-mix(in srgb, var(--ai-surface) 94%, #f8fafc 6%);
cursor: pointer;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.pager {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
button {
border: 1px solid var(--ai-border);
border-radius: 8px;
padding: 4px 8px;
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
}
span {
font-size: 12px;
color: var(--text-secondary);
}
}
}
.sql-history {
border-top: 1px solid var(--ai-border);
padding-top: 10px;
h4 {
margin: 0 0 8px;
font-size: 12px;
}
.history-list {
display: grid;
gap: 6px;
max-height: 120px;
overflow: auto;
button {
text-align: left;
border: 1px solid var(--ai-border);
border-radius: 8px;
padding: 6px 8px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.empty {
color: var(--text-tertiary);
font-size: 12px;
padding: 10px;
}
.spin {
animation: wf-spin 1s linear infinite;
}
@keyframes wf-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 1380px) {
.chat-shell,
.chat-shell.with-data {
grid-template-columns: 260px minmax(0, 1fr);
}
.data-panel {
display: none;
}
}
@media (max-width: 1024px) {
.ai-analysis-chatlab {
padding: 10px;
}
.chat-shell,
.chat-shell.with-data,
.sql-shell {
grid-template-columns: 1fr;
}
.conversation-sidebar {
max-height: 260px;
}
}

View File

@@ -0,0 +1,936 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Bot,
Braces,
CircleStop,
Database,
Download,
Loader2,
PanelLeftClose,
PanelLeftOpen,
Play,
RefreshCw,
Send,
Sparkles,
Trash2,
ChevronDown,
ChevronUp
} from 'lucide-react'
import type {
AiConversation,
AiMessageRecord,
AssistantSummary,
SkillSummary,
SqlResultPayload,
SqlSchemaPayload,
ToolCatalogEntry
} from '../types/aiAnalysis'
import { useAiRuntimeStore } from '../stores/aiRuntimeStore'
import './AiAnalysisPage.scss'
type MainTab = 'chat' | 'sql'
type ScopeMode = 'global' | 'contact' | 'session'
function formatDateTime(ts: number): string {
if (!ts) return '--'
const d = new Date(ts)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const hh = `${d.getHours()}`.padStart(2, '0')
const mm = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
function normalizeText(value: unknown, fallback = ''): string {
const text = String(value ?? '').trim()
return text || fallback
}
function extractSqlTarget(
schema: SqlSchemaPayload | null,
key: string
): { kind: 'message' | 'contact' | 'biz'; path: string | null } | null {
if (!schema) return null
for (const source of schema.sources) {
const sourceKey = `${source.kind}:${source.path || ''}`
if (sourceKey === key) return { kind: source.kind, path: source.path }
}
return null
}
function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
const esc = (value: unknown) => {
const text = String(value ?? '')
if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`
return text
}
const header = columns.map((column) => esc(column)).join(',')
const body = rows
.map((row) => columns.map((column) => esc(row[column])).join(','))
.join('\n')
return `${header}\n${body}`
}
function AiAnalysisPage() {
const aiApi = window.electronAPI.aiApi
const agentApi = window.electronAPI.agentApi
const assistantApi = window.electronAPI.assistantApi
const skillApi = window.electronAPI.skillApi
const [activeTab, setActiveTab] = useState<MainTab>('chat')
const [showDataPanel, setShowDataPanel] = useState(true)
const [scopeMode, setScopeMode] = useState<ScopeMode>('global')
const [scopeTarget, setScopeTarget] = useState('')
const [conversations, setConversations] = useState<AiConversation[]>([])
const [currentConversationId, setCurrentConversationId] = useState('')
const [messages, setMessages] = useState<AiMessageRecord[]>([])
const [assistants, setAssistants] = useState<AssistantSummary[]>([])
const [selectedAssistantId, setSelectedAssistantId] = useState('general_cn')
const [skills, setSkills] = useState<SkillSummary[]>([])
const [selectedSkillId, setSelectedSkillId] = useState('')
const [contacts, setContacts] = useState<Array<{ username: string; displayName: string }>>([])
const [input, setInput] = useState('')
const [loadingConversations, setLoadingConversations] = useState(false)
const [loadingMessages, setLoadingMessages] = useState(false)
const [errorText, setErrorText] = useState('')
const [sqlPrompt, setSqlPrompt] = useState('')
const [sqlGenerated, setSqlGenerated] = useState('')
const [sqlGenerating, setSqlGenerating] = useState(false)
const [sqlSchema, setSqlSchema] = useState<SqlSchemaPayload | null>(null)
const [sqlSchemaText, setSqlSchemaText] = useState('')
const [sqlTargetKey, setSqlTargetKey] = useState('message:')
const [sqlResult, setSqlResult] = useState<SqlResultPayload | null>(null)
const [sqlError, setSqlError] = useState('')
const [sqlHistory, setSqlHistory] = useState<string[]>([])
const [sqlSortBy, setSqlSortBy] = useState('')
const [sqlSortOrder, setSqlSortOrder] = useState<'asc' | 'desc'>('asc')
const [sqlPage, setSqlPage] = useState(1)
const [sqlPageSize] = useState(50)
const messageContainerRef = useRef<HTMLDivElement | null>(null)
const sqlGeneratedRef = useRef('')
const [showScrollBottom, setShowScrollBottom] = useState(false)
const runtimeState = useAiRuntimeStore((state) => (
currentConversationId ? state.states[currentConversationId] : undefined
))
const activeRequestId = useAiRuntimeStore((state) => state.activeRequestId)
const startRun = useAiRuntimeStore((state) => state.startRun)
const appendChunk = useAiRuntimeStore((state) => state.appendChunk)
const completeRun = useAiRuntimeStore((state) => state.completeRun)
const selectedAssistant = useMemo(
() => assistants.find((assistant) => assistant.id === selectedAssistantId) || null,
[assistants, selectedAssistantId]
)
const showThinkBlocks = useMemo(() => {
try {
const query = new URLSearchParams(window.location.search)
if (query.get('debugThink') === '1') return true
return window.localStorage.getItem('wf_ai_debug_think') === '1'
} catch {
return false
}
}, [])
const slashSuggestions = useMemo(() => {
const text = normalizeText(input)
if (!text.startsWith('/')) return []
const key = text.slice(1).toLowerCase()
return skills
.filter((skill) => !key || skill.id.includes(key) || skill.name.toLowerCase().includes(key))
.slice(0, 8)
}, [input, skills])
const mentionSuggestions = useMemo(() => {
const match = input.match(/@([^\s@]*)$/)
if (!match) return []
const keyword = match[1].toLowerCase()
return contacts
.filter((contact) =>
!keyword ||
contact.displayName.toLowerCase().includes(keyword) ||
contact.username.toLowerCase().includes(keyword)
)
.slice(0, 8)
}, [contacts, input])
const sqlTargetOptions = useMemo(() => {
if (!sqlSchema) return []
return sqlSchema.sources.map((source) => ({
key: `${source.kind}:${source.path || ''}`,
label: `[${source.kind}] ${source.label}`
}))
}, [sqlSchema])
const sqlSortedRows = useMemo(() => {
const rows = sqlResult?.rows || []
if (!sqlSortBy) return rows
const copied = [...rows]
copied.sort((a, b) => {
const left = String(a[sqlSortBy] ?? '')
const right = String(b[sqlSortBy] ?? '')
if (left === right) return 0
return sqlSortOrder === 'asc' ? (left > right ? 1 : -1) : (left > right ? -1 : 1)
})
return copied
}, [sqlResult, sqlSortBy, sqlSortOrder])
const sqlPagedRows = useMemo(() => {
const start = (sqlPage - 1) * sqlPageSize
return sqlSortedRows.slice(start, start + sqlPageSize)
}, [sqlPage, sqlPageSize, sqlSortedRows])
const loadConversations = useCallback(async () => {
setLoadingConversations(true)
try {
const res = await aiApi.listConversations({ page: 1, pageSize: 200 })
if (!res.success) {
setErrorText(res.error || '加载会话失败')
return
}
const list = res.conversations || []
setConversations(list)
if (!currentConversationId && list.length > 0) {
setCurrentConversationId(list[0].conversationId)
}
} finally {
setLoadingConversations(false)
}
}, [aiApi, currentConversationId])
const loadMessages = useCallback(async (conversationId: string) => {
if (!conversationId) return
setLoadingMessages(true)
try {
const res = await aiApi.listMessages({ conversationId, limit: 1200 })
if (!res.success) {
setErrorText(res.error || '加载消息失败')
return
}
setMessages((res.messages || []).filter((message) => normalizeText(message.role) !== 'tool'))
} finally {
setLoadingMessages(false)
}
}, [aiApi])
const loadAssistantsAndSkills = useCallback(async () => {
try {
const [assistantList, skillList] = await Promise.all([
assistantApi.getAll(),
skillApi.getAll()
])
setAssistants(assistantList || [])
setSkills(skillList || [])
if (
assistantList &&
assistantList.length > 0 &&
!assistantList.some((item) => item.id === selectedAssistantId)
) {
setSelectedAssistantId(assistantList[0].id)
}
} catch (error) {
setErrorText(String((error as Error)?.message || error))
}
}, [assistantApi, skillApi, selectedAssistantId])
const loadContacts = useCallback(async () => {
try {
const res = await window.electronAPI.chat.getContacts({ lite: true })
if (!res.success || !res.contacts) return
const list = res.contacts
.map((contact) => ({
username: normalizeText(contact.username),
displayName: normalizeText(
contact.displayName ||
contact.remark ||
contact.nickname ||
contact.username
)
}))
.filter((contact) => contact.username && contact.displayName)
.slice(0, 300)
setContacts(list)
} catch {
// ignore
}
}, [])
const loadSchema = useCallback(async () => {
const res = await window.electronAPI.chat.getSchema({})
if (!res.success || !res.schema) {
setSqlError(res.error || 'Schema 加载失败')
return
}
setSqlSchema(res.schema)
setSqlSchemaText(res.schemaText || '')
if (res.schema.sources.length > 0) {
setSqlTargetKey(`${res.schema.sources[0].kind}:${res.schema.sources[0].path || ''}`)
}
}, [])
useEffect(() => {
void loadConversations()
void loadAssistantsAndSkills()
void loadContacts()
}, [loadConversations, loadAssistantsAndSkills, loadContacts])
useEffect(() => {
if (!currentConversationId) return
void loadMessages(currentConversationId)
}, [currentConversationId, loadMessages])
useEffect(() => {
if (activeTab === 'sql' && !sqlSchema) {
void loadSchema()
}
}, [activeTab, sqlSchema, loadSchema])
useEffect(() => {
const panel = messageContainerRef.current
if (!panel) return
const onScroll = () => {
const distance = panel.scrollHeight - panel.scrollTop - panel.clientHeight
setShowScrollBottom(distance > 64)
}
panel.addEventListener('scroll', onScroll)
onScroll()
return () => panel.removeEventListener('scroll', onScroll)
}, [messageContainerRef.current])
useEffect(() => {
const panel = messageContainerRef.current
if (!panel) return
panel.scrollTo({ top: panel.scrollHeight, behavior: 'smooth' })
}, [messages, runtimeState?.blocks.length, runtimeState?.draft])
const ensureConversation = useCallback(async (): Promise<string> => {
if (currentConversationId) return currentConversationId
const created = await aiApi.createConversation({ title: '新的 AI 对话' })
if (!created.success || !created.conversationId) {
throw new Error(created.error || '创建会话失败')
}
setCurrentConversationId(created.conversationId)
await loadConversations()
return created.conversationId
}, [aiApi, currentConversationId, loadConversations])
const handleCreateConversation = async () => {
const created = await aiApi.createConversation({ title: '新的 AI 对话' })
if (!created.success || !created.conversationId) {
setErrorText(created.error || '创建会话失败')
return
}
setCurrentConversationId(created.conversationId)
setMessages([])
setErrorText('')
await loadConversations()
}
const handleRenameConversation = async (conversationId: string) => {
const current = conversations.find((item) => item.conversationId === conversationId)
const nextTitle = window.prompt('请输入新的会话标题', current?.title || '新的 AI 对话')
if (!nextTitle) return
const result = await aiApi.renameConversation({ conversationId, title: nextTitle })
if (!result.success) {
setErrorText(result.error || '重命名失败')
return
}
await loadConversations()
}
const handleDeleteConversation = async (conversationId: string) => {
const ok = window.confirm('确认删除该会话吗?')
if (!ok) return
const result = await aiApi.deleteConversation(conversationId)
if (!result.success) {
setErrorText(result.error || '删除失败')
return
}
if (currentConversationId === conversationId) {
setCurrentConversationId('')
setMessages([])
}
await loadConversations()
}
const handleSend = async () => {
const text = normalizeText(input)
if (!text) return
setErrorText('')
const conversationId = await ensureConversation()
setMessages((prev) => ([
...prev,
{
messageId: `temp-${Date.now()}`,
conversationId,
role: 'user',
content: text,
intentType: '',
components: [],
toolTrace: [],
createdAt: Date.now()
}
]))
setInput('')
const run = agentApi.runStream({
mode: 'chat',
conversationId,
userInput: text,
assistantId: selectedAssistantId,
activeSkillId: selectedSkillId || undefined,
chatScope: scopeMode === 'session' ? 'private' : 'private'
}, (chunk) => {
appendChunk(conversationId, chunk)
})
startRun(conversationId, run.requestId)
const result = await run.promise
completeRun(conversationId, result.result || { error: result.error, canceled: false })
if (!result.success && !result.result?.canceled) {
setErrorText(result.error || '执行失败')
}
await loadMessages(conversationId)
await loadConversations()
}
const handleStop = async () => {
if (!currentConversationId) return
const requestId = runtimeState?.requestId || activeRequestId
if (!requestId) return
setErrorText('')
await agentApi.abort(requestId)
completeRun(currentConversationId, { canceled: true })
}
const handleExportConversation = async () => {
if (!currentConversationId) return
const result = await aiApi.exportConversation({ conversationId: currentConversationId })
if (!result.success || !result.markdown) {
setErrorText(result.error || '导出失败')
return
}
await navigator.clipboard.writeText(result.markdown)
window.alert('会话 Markdown 已复制到剪贴板')
}
const handleGenerateSql = async () => {
const prompt = normalizeText(sqlPrompt)
if (!prompt) return
setSqlGenerating(true)
setSqlGenerated('')
sqlGeneratedRef.current = ''
setSqlError('')
const target = extractSqlTarget(sqlSchema, sqlTargetKey)
const run = agentApi.runStream({
mode: 'sql',
userInput: prompt,
sqlContext: {
schemaText: sqlSchemaText,
targetHint: target ? `${target.kind}:${target.path || ''}` : ''
}
}, (chunk) => {
if (chunk.type === 'content') {
setSqlGenerated((prev) => {
const next = `${prev}${chunk.content || ''}`
sqlGeneratedRef.current = next
return next
})
}
})
const result = await run.promise
setSqlGenerating(false)
if (!result.success) {
setSqlError(result.error || 'SQL 生成失败')
return
}
if (normalizeText(sqlGeneratedRef.current)) {
setSqlHistory((prev) => [sqlGeneratedRef.current.trim(), ...prev].slice(0, 30))
}
}
const handleExecuteSql = async () => {
const sql = normalizeText(sqlGenerated)
if (!sql) return
const target = extractSqlTarget(sqlSchema, sqlTargetKey)
if (!target) {
setSqlError('请选择 SQL 数据源')
return
}
const result = await window.electronAPI.chat.executeSQL({
kind: target.kind,
path: target.path,
sql,
limit: 500
})
if (!result.success || !result.rows || !result.columns) {
setSqlError(result.error || '执行失败')
return
}
setSqlError('')
setSqlResult({
rows: result.rows,
columns: result.columns,
total: result.total || result.rows.length
})
setSqlHistory((prev) => [sql, ...prev].slice(0, 30))
setSqlPage(1)
}
const handleExportSqlRows = () => {
if (!sqlResult || sqlResult.rows.length === 0) return
const csv = toCsv(sqlResult.rows, sqlResult.columns)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `sql-result-${Date.now()}.csv`
link.click()
URL.revokeObjectURL(url)
}
return (
<div className="ai-analysis-chatlab">
<header className="ai-topbar">
<div className="title-group">
<Sparkles size={18} />
<h1>AI Analysis</h1>
<span>ChatLab </span>
</div>
<div className="top-actions">
<button type="button" className={activeTab === 'chat' ? 'active' : ''} onClick={() => setActiveTab('chat')}>
<Bot size={14} />
AI Chat
</button>
<button type="button" className={activeTab === 'sql' ? 'active' : ''} onClick={() => setActiveTab('sql')}>
<Database size={14} />
SQL Lab
</button>
</div>
</header>
{activeTab === 'chat' && (
<div className={`chat-shell ${showDataPanel ? 'with-data' : ''}`}>
<aside className="conversation-sidebar">
<div className="sidebar-head">
<h3></h3>
<button type="button" onClick={() => void handleCreateConversation()} title="新建会话">+</button>
</div>
{loadingConversations ? (
<div className="empty"><Loader2 className="spin" size={14} /> ...</div>
) : (
<div className="conversation-list">
{conversations.map((conversation) => (
<button
type="button"
key={conversation.conversationId}
className={`conversation-item ${currentConversationId === conversation.conversationId ? 'active' : ''}`}
onClick={() => setCurrentConversationId(conversation.conversationId)}
>
<div className="main">
<strong>{conversation.title || '新的 AI 对话'}</strong>
<small>{formatDateTime(conversation.updatedAt)}</small>
</div>
<div className="ops" onClick={(event) => event.stopPropagation()}>
<span onClick={() => void handleRenameConversation(conversation.conversationId)}></span>
<span onClick={() => void handleDeleteConversation(conversation.conversationId)}>
<Trash2 size={12} />
</span>
</div>
</button>
))}
{conversations.length === 0 && <div className="empty"></div>}
</div>
)}
</aside>
<section className="chat-main-panel">
<div className="chat-toolbar">
<div className="controls-row">
<label></label>
<select value={selectedAssistantId} onChange={(event) => setSelectedAssistantId(event.target.value)}>
{assistants.map((assistant) => (
<option key={assistant.id} value={assistant.id}>{assistant.name}</option>
))}
</select>
<label></label>
<select value={selectedSkillId} onChange={(event) => setSelectedSkillId(event.target.value)}>
<option value=""></option>
{skills.map((skill) => (
<option key={skill.id} value={skill.id}>{skill.name}</option>
))}
</select>
<label></label>
<select value={scopeMode} onChange={(event) => setScopeMode(event.target.value as ScopeMode)}>
<option value="global"></option>
<option value="contact"></option>
<option value="session"></option>
</select>
{scopeMode !== 'global' && (
<input
type="text"
value={scopeTarget}
onChange={(event) => setScopeTarget(event.target.value)}
placeholder={scopeMode === 'contact' ? '联系人昵称/账号' : '会话ID'}
/>
)}
<button
type="button"
className="ghost"
onClick={() => setShowDataPanel((prev) => !prev)}
title={showDataPanel ? '隐藏数据面板' : '显示数据面板'}
>
{showDataPanel ? <PanelLeftClose size={14} /> : <PanelLeftOpen size={14} />}
</button>
</div>
{selectedAssistant?.presetQuestions?.length ? (
<div className="preset-row">
{selectedAssistant.presetQuestions.slice(0, 8).map((question) => (
<button key={question} type="button" onClick={() => setInput(question)}>{question}</button>
))}
</div>
) : null}
</div>
<div className="messages-wrap" ref={messageContainerRef}>
{loadingMessages ? (
<div className="empty"><Loader2 className="spin" size={14} /> ...</div>
) : (
<>
{messages.map((message) => (
<article key={message.messageId} className={`message-card ${message.role === 'user' ? 'user' : 'assistant'}`}>
<header>
<span>{message.role === 'user' ? '你' : '助手'}</span>
<time>{formatDateTime(message.createdAt)}</time>
</header>
<div className="message-body">{message.content || '(空)'}</div>
{message.role === 'assistant' && Array.isArray(message.toolTrace) && message.toolTrace.length > 0 ? (
<details className="tool-trace">
<summary>{message.toolTrace.length}</summary>
<ul>
{message.toolTrace.map((trace, index) => (
<li key={`${message.messageId}-trace-${index}`}>
{String(trace?.toolName || 'unknown')} · {String(trace?.status || 'unknown')} · {Number(trace?.durationMs || 0)}ms
</li>
))}
</ul>
</details>
) : null}
</article>
))}
{runtimeState?.running ? (
<article className="message-card assistant streaming">
<header>
<span></span>
<time>{runtimeState?.status?.phase || 'thinking'}</time>
</header>
<div className="message-body blocks">
{(runtimeState?.blocks || []).map((block, index) => {
if (block.type === 'text') {
return <div key={`text-${index}`} className="text-block">{block.text}</div>
}
if (block.type === 'think') {
if (!showThinkBlocks) return null
return (
<details key={`think-${index}`} className="think-block">
<summary>
{block.durationMs ? <small>{Math.max(0, block.durationMs)}ms</small> : null}
</summary>
<pre>{block.text}</pre>
</details>
)
}
return (
<div key={`tool-${index}`} className={`tool-block ${block.tool.status}`}>
<div className="line">
<strong>{block.tool.name}</strong>
<span>{block.tool.status}</span>
</div>
{block.tool.params ? (
<pre>{JSON.stringify(block.tool.params, null, 2)}</pre>
) : null}
{block.tool.result ? (
<pre>{JSON.stringify(block.tool.result, null, 2)}</pre>
) : null}
</div>
)
})}
{runtimeState?.running ? (
<span className="typing-cursor">|</span>
) : null}
</div>
</article>
) : null}
</>
)}
{showScrollBottom ? (
<button
type="button"
className="scroll-bottom"
onClick={() => messageContainerRef.current?.scrollTo({ top: messageContainerRef.current.scrollHeight, behavior: 'smooth' })}
>
<ChevronDown size={14} />
</button>
) : null}
</div>
<div className="status-row">
<div className="left">
<span>{runtimeState?.status?.phase || 'idle'}</span>
{typeof runtimeState?.usage?.totalTokens === 'number' ? (
<span>Tokens: {runtimeState?.usage?.totalTokens}</span>
) : null}
</div>
<div className="right">
<button type="button" className="ghost" onClick={() => void loadConversations()}>
<RefreshCw size={13} />
</button>
<button type="button" className="ghost" onClick={() => void handleExportConversation()}>
<Download size={13} />
</button>
<button
type="button"
className="danger"
disabled={!runtimeState?.running}
onClick={() => void handleStop()}
>
<CircleStop size={13} />
</button>
</div>
</div>
<div className="input-panel">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="输入问题,支持 /技能 和 @成员Ctrl/Cmd + Enter 发送"
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
void handleSend()
}
}}
/>
{slashSuggestions.length > 0 && (
<div className="suggestions">
{slashSuggestions.map((skill) => (
<button key={skill.id} type="button" onClick={() => { setSelectedSkillId(skill.id); setInput('') }}>
/{skill.id} · {skill.name}
</button>
))}
</div>
)}
{mentionSuggestions.length > 0 && (
<div className="suggestions">
{mentionSuggestions.map((contact) => (
<button
key={contact.username}
type="button"
onClick={() => {
setInput((prev) => prev.replace(/@([^\s@]*)$/, `@${contact.displayName} `))
}}
>
@{contact.displayName}
</button>
))}
</div>
)}
<div className="input-actions">
<button
type="button"
className="primary"
onClick={() => void handleSend()}
disabled={runtimeState?.running}
>
{runtimeState?.running ? <Loader2 className="spin" size={14} /> : <Send size={14} />}
</button>
</div>
</div>
</section>
{showDataPanel ? (
<aside className="data-panel">
<header>
<h3></h3>
<span>{runtimeState?.sourceMessages?.length || 0} </span>
</header>
<section className="keywords">
<h4></h4>
<div className="chips">
{(runtimeState?.currentKeywords || []).length ? (
runtimeState?.currentKeywords.map((keyword) => (
<span key={keyword}>{keyword}</span>
))
) : (
<small></small>
)}
</div>
</section>
<section className="sources">
<h4></h4>
<div className="source-list">
{(runtimeState?.sourceMessages || []).map((message) => (
<article key={`${message.sessionId}-${message.localId}-${message.timestamp}`}>
<header>
<strong>{message.senderName || '未知成员'}</strong>
<time>{formatDateTime((message.timestamp || 0) * 1000)}</time>
</header>
<p>{message.content}</p>
</article>
))}
{(runtimeState?.sourceMessages || []).length === 0 ? (
<div className="empty"></div>
) : null}
</div>
</section>
</aside>
) : null}
</div>
)}
{activeTab === 'sql' && (
<div className="sql-shell">
<aside className="schema-panel">
<div className="panel-head">
<h3>Schema</h3>
<button type="button" onClick={() => void loadSchema()}><RefreshCw size={13} /></button>
</div>
<div className="schema-list">
{sqlSchema?.sources.map((source) => (
<div key={`${source.kind}:${source.path || ''}`} className="schema-source">
<h4>[{source.kind}] {source.label}</h4>
<ul>
{source.tables.slice(0, 24).map((table) => (
<li key={table.name}>
<strong>{table.name}</strong>
<small>{table.columns.slice(0, 10).join(', ')}</small>
</li>
))}
</ul>
</div>
))}
</div>
</aside>
<section className="sql-main">
<div className="sql-bar">
<select value={sqlTargetKey} onChange={(event) => setSqlTargetKey(event.target.value)}>
{sqlTargetOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select>
<button type="button" onClick={() => void handleGenerateSql()} disabled={sqlGenerating}>
{sqlGenerating ? <Loader2 className="spin" size={14} /> : <Braces size={14} />}
SQL
</button>
<button type="button" onClick={() => void handleExecuteSql()}>
<Play size={14} />
SQL
</button>
<button type="button" onClick={handleExportSqlRows} disabled={!sqlResult?.rows?.length}>
<Download size={14} />
</button>
</div>
<textarea
className="sql-prompt"
value={sqlPrompt}
onChange={(event) => setSqlPrompt(event.target.value)}
placeholder="输入需求例如统计过去7天最活跃的10个联系人"
/>
<textarea
className="sql-generated"
value={sqlGenerated}
onChange={(event) => {
setSqlGenerated(event.target.value)
sqlGeneratedRef.current = event.target.value
}}
placeholder="生成的 SQL 将显示在这里"
/>
{sqlError ? <div className="error">{sqlError}</div> : null}
<div className="sql-table-wrap">
{sqlResult?.rows?.length ? (
<>
<table className="sql-table">
<thead>
<tr>
{sqlResult.columns.map((column) => (
<th
key={column}
onClick={() => {
if (sqlSortBy === column) {
setSqlSortOrder((prev) => prev === 'asc' ? 'desc' : 'asc')
} else {
setSqlSortBy(column)
setSqlSortOrder('asc')
}
}}
>
{column}
{sqlSortBy === column ? (sqlSortOrder === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />) : null}
</th>
))}
</tr>
</thead>
<tbody>
{sqlPagedRows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{sqlResult.columns.map((column) => (
<td key={`${rowIndex}-${column}`}>{String(row[column] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
<div className="pager">
<span> {sqlResult.total} </span>
<button type="button" onClick={() => setSqlPage((prev) => Math.max(1, prev - 1))}></button>
<span>{sqlPage}</span>
<button type="button" onClick={() => setSqlPage((prev) => prev + 1)}></button>
</div>
</>
) : (
<div className="empty"></div>
)}
</div>
<div className="sql-history">
<h4> SQL</h4>
<div className="history-list">
{sqlHistory.map((sql, index) => (
<button key={`sql-${index}`} type="button" onClick={() => setSqlGenerated(sql)}>
{sql.slice(0, 160)}
</button>
))}
</div>
</div>
</section>
</div>
)}
{errorText ? <div className="global-error">{errorText}</div> : null}
</div>
)
}
export default AiAnalysisPage

View File

@@ -240,6 +240,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('') const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('')
const [aiModelApiKey, setAiModelApiKey] = useState('') const [aiModelApiKey, setAiModelApiKey] = useState('')
const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini') const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini')
const [aiAgentMaxMessagesPerRequest, setAiAgentMaxMessagesPerRequest] = useState(120)
const [aiAgentMaxHistoryRounds, setAiAgentMaxHistoryRounds] = useState(12)
const [aiAgentEnableAutoSkill, setAiAgentEnableAutoSkill] = useState(true)
const [aiAgentSearchContextBefore, setAiAgentSearchContextBefore] = useState(3)
const [aiAgentSearchContextAfter, setAiAgentSearchContextAfter] = useState(3)
const [aiAgentPreprocessClean, setAiAgentPreprocessClean] = useState(true)
const [aiAgentPreprocessMerge, setAiAgentPreprocessMerge] = useState(true)
const [aiAgentPreprocessDenoise, setAiAgentPreprocessDenoise] = useState(true)
const [aiAgentPreprocessDesensitize, setAiAgentPreprocessDesensitize] = useState(false)
const [aiAgentPreprocessAnonymize, setAiAgentPreprocessAnonymize] = useState(false)
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3) const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false) const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [isTestingInsight, setIsTestingInsight] = useState(false) const [isTestingInsight, setIsTestingInsight] = useState(false)
@@ -479,6 +489,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl() const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl()
const savedAiModelApiKey = await configService.getAiModelApiKey() const savedAiModelApiKey = await configService.getAiModelApiKey()
const savedAiModelApiModel = await configService.getAiModelApiModel() const savedAiModelApiModel = await configService.getAiModelApiModel()
const savedAiAgentMaxMessagesPerRequest = await configService.getAiAgentMaxMessagesPerRequest()
const savedAiAgentMaxHistoryRounds = await configService.getAiAgentMaxHistoryRounds()
const savedAiAgentEnableAutoSkill = await configService.getAiAgentEnableAutoSkill()
const savedAiAgentSearchContextBefore = await configService.getAiAgentSearchContextBefore()
const savedAiAgentSearchContextAfter = await configService.getAiAgentSearchContextAfter()
const savedAiAgentPreprocessClean = await configService.getAiAgentPreprocessClean()
const savedAiAgentPreprocessMerge = await configService.getAiAgentPreprocessMerge()
const savedAiAgentPreprocessDenoise = await configService.getAiAgentPreprocessDenoise()
const savedAiAgentPreprocessDesensitize = await configService.getAiAgentPreprocessDesensitize()
const savedAiAgentPreprocessAnonymize = await configService.getAiAgentPreprocessAnonymize()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
@@ -496,6 +516,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiModelApiBaseUrl(savedAiModelApiBaseUrl) setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
setAiModelApiKey(savedAiModelApiKey) setAiModelApiKey(savedAiModelApiKey)
setAiModelApiModel(savedAiModelApiModel) setAiModelApiModel(savedAiModelApiModel)
setAiAgentMaxMessagesPerRequest(savedAiAgentMaxMessagesPerRequest)
setAiAgentMaxHistoryRounds(savedAiAgentMaxHistoryRounds)
setAiAgentEnableAutoSkill(savedAiAgentEnableAutoSkill)
setAiAgentSearchContextBefore(savedAiAgentSearchContextBefore)
setAiAgentSearchContextAfter(savedAiAgentSearchContextAfter)
setAiAgentPreprocessClean(savedAiAgentPreprocessClean)
setAiAgentPreprocessMerge(savedAiAgentPreprocessMerge)
setAiAgentPreprocessDenoise(savedAiAgentPreprocessDenoise)
setAiAgentPreprocessDesensitize(savedAiAgentPreprocessDesensitize)
setAiAgentPreprocessAnonymize(savedAiAgentPreprocessAnonymize)
setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext) setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
@@ -2614,6 +2644,113 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
/> />
</div> </div>
<div className="divider" />
<div className="form-group">
<label>Agent </label>
<span className="form-hint">
AI
</span>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 8 }}>
<div>
<span style={{ fontSize: 12, opacity: 0.8 }}></span>
<input
type="number"
className="field-input"
value={aiAgentMaxMessagesPerRequest}
min={20}
max={500}
onChange={(e) => {
const val = Math.max(20, Math.min(500, parseInt(e.target.value, 10) || 120))
setAiAgentMaxMessagesPerRequest(val)
scheduleConfigSave('aiAgentMaxMessagesPerRequest', () => configService.setAiAgentMaxMessagesPerRequest(val))
}}
style={{ width: 130, marginTop: 6 }}
/>
</div>
<div>
<span style={{ fontSize: 12, opacity: 0.8 }}></span>
<input
type="number"
className="field-input"
value={aiAgentMaxHistoryRounds}
min={4}
max={60}
onChange={(e) => {
const val = Math.max(4, Math.min(60, parseInt(e.target.value, 10) || 12))
setAiAgentMaxHistoryRounds(val)
scheduleConfigSave('aiAgentMaxHistoryRounds', () => configService.setAiAgentMaxHistoryRounds(val))
}}
style={{ width: 130, marginTop: 6 }}
/>
</div>
<div>
<span style={{ fontSize: 12, opacity: 0.8 }}></span>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<input
type="number"
className="field-input"
value={aiAgentSearchContextBefore}
min={0}
max={20}
onChange={(e) => {
const val = Math.max(0, Math.min(20, parseInt(e.target.value, 10) || 3))
setAiAgentSearchContextBefore(val)
scheduleConfigSave('aiAgentSearchContextBefore', () => configService.setAiAgentSearchContextBefore(val))
}}
style={{ width: 90 }}
/>
<input
type="number"
className="field-input"
value={aiAgentSearchContextAfter}
min={0}
max={20}
onChange={(e) => {
const val = Math.max(0, Math.min(20, parseInt(e.target.value, 10) || 3))
setAiAgentSearchContextAfter(val)
scheduleConfigSave('aiAgentSearchContextAfter', () => configService.setAiAgentSearchContextAfter(val))
}}
style={{ width: 90 }}
/>
</div>
</div>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
Agent <code>activate_skill</code>////
</span>
<div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{[
['自动技能 Auto Skill', aiAgentEnableAutoSkill, setAiAgentEnableAutoSkill, () => configService.setAiAgentEnableAutoSkill(!aiAgentEnableAutoSkill), 'aiAgentEnableAutoSkill'],
['清洗', aiAgentPreprocessClean, setAiAgentPreprocessClean, () => configService.setAiAgentPreprocessClean(!aiAgentPreprocessClean), 'aiAgentPreprocessClean'],
['合并', aiAgentPreprocessMerge, setAiAgentPreprocessMerge, () => configService.setAiAgentPreprocessMerge(!aiAgentPreprocessMerge), 'aiAgentPreprocessMerge'],
['去噪', aiAgentPreprocessDenoise, setAiAgentPreprocessDenoise, () => configService.setAiAgentPreprocessDenoise(!aiAgentPreprocessDenoise), 'aiAgentPreprocessDenoise'],
['脱敏', aiAgentPreprocessDesensitize, setAiAgentPreprocessDesensitize, () => configService.setAiAgentPreprocessDesensitize(!aiAgentPreprocessDesensitize), 'aiAgentPreprocessDesensitize'],
['匿名', aiAgentPreprocessAnonymize, setAiAgentPreprocessAnonymize, () => configService.setAiAgentPreprocessAnonymize(!aiAgentPreprocessAnonymize), 'aiAgentPreprocessAnonymize']
].map(([label, value, setter, saveFn, key]) => (
<div key={key as string} className="log-toggle-line">
<span className="log-status">{label as string}</span>
<label className="switch">
<input
type="checkbox"
checked={value as boolean}
onChange={() => {
const next = !(value as boolean)
;(setter as (value: boolean) => void)(next)
scheduleConfigSave(key as string, saveFn as () => Promise<void>)
}}
/>
<span className="switch-slider" />
</label>
</div>
))}
</div>
</div>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"> <span className="form-hint">

View File

@@ -86,6 +86,16 @@ export const CONFIG_KEYS = {
AI_MODEL_API_BASE_URL: 'aiModelApiBaseUrl', AI_MODEL_API_BASE_URL: 'aiModelApiBaseUrl',
AI_MODEL_API_KEY: 'aiModelApiKey', AI_MODEL_API_KEY: 'aiModelApiKey',
AI_MODEL_API_MODEL: 'aiModelApiModel', AI_MODEL_API_MODEL: 'aiModelApiModel',
AI_AGENT_MAX_MESSAGES_PER_REQUEST: 'aiAgentMaxMessagesPerRequest',
AI_AGENT_MAX_HISTORY_ROUNDS: 'aiAgentMaxHistoryRounds',
AI_AGENT_ENABLE_AUTO_SKILL: 'aiAgentEnableAutoSkill',
AI_AGENT_SEARCH_CONTEXT_BEFORE: 'aiAgentSearchContextBefore',
AI_AGENT_SEARCH_CONTEXT_AFTER: 'aiAgentSearchContextAfter',
AI_AGENT_PREPROCESS_CLEAN: 'aiAgentPreprocessClean',
AI_AGENT_PREPROCESS_MERGE: 'aiAgentPreprocessMerge',
AI_AGENT_PREPROCESS_DENOISE: 'aiAgentPreprocessDenoise',
AI_AGENT_PREPROCESS_DESENSITIZE: 'aiAgentPreprocessDesensitize',
AI_AGENT_PREPROCESS_ANONYMIZE: 'aiAgentPreprocessAnonymize',
AI_INSIGHT_ENABLED: 'aiInsightEnabled', AI_INSIGHT_ENABLED: 'aiInsightEnabled',
AI_INSIGHT_API_BASE_URL: 'aiInsightApiBaseUrl', AI_INSIGHT_API_BASE_URL: 'aiInsightApiBaseUrl',
AI_INSIGHT_API_KEY: 'aiInsightApiKey', AI_INSIGHT_API_KEY: 'aiInsightApiKey',
@@ -1626,6 +1636,100 @@ export async function setAiModelApiModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_MODEL, model) await config.set(CONFIG_KEYS.AI_MODEL_API_MODEL, model)
} }
export async function getAiAgentMaxMessagesPerRequest(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_MAX_MESSAGES_PER_REQUEST)
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 120
}
export async function setAiAgentMaxMessagesPerRequest(value: number): Promise<void> {
const normalized = Number.isFinite(value) ? Math.max(20, Math.min(500, Math.floor(value))) : 120
await config.set(CONFIG_KEYS.AI_AGENT_MAX_MESSAGES_PER_REQUEST, normalized)
}
export async function getAiAgentMaxHistoryRounds(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_MAX_HISTORY_ROUNDS)
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 12
}
export async function setAiAgentMaxHistoryRounds(value: number): Promise<void> {
const normalized = Number.isFinite(value) ? Math.max(4, Math.min(60, Math.floor(value))) : 12
await config.set(CONFIG_KEYS.AI_AGENT_MAX_HISTORY_ROUNDS, normalized)
}
export async function getAiAgentEnableAutoSkill(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_ENABLE_AUTO_SKILL)
return value !== false
}
export async function setAiAgentEnableAutoSkill(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_AGENT_ENABLE_AUTO_SKILL, enabled)
}
export async function getAiAgentSearchContextBefore(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_SEARCH_CONTEXT_BEFORE)
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 3
}
export async function setAiAgentSearchContextBefore(value: number): Promise<void> {
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(20, Math.floor(value))) : 3
await config.set(CONFIG_KEYS.AI_AGENT_SEARCH_CONTEXT_BEFORE, normalized)
}
export async function getAiAgentSearchContextAfter(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_SEARCH_CONTEXT_AFTER)
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 3
}
export async function setAiAgentSearchContextAfter(value: number): Promise<void> {
const normalized = Number.isFinite(value) ? Math.max(0, Math.min(20, Math.floor(value))) : 3
await config.set(CONFIG_KEYS.AI_AGENT_SEARCH_CONTEXT_AFTER, normalized)
}
export async function getAiAgentPreprocessClean(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_PREPROCESS_CLEAN)
return value !== false
}
export async function setAiAgentPreprocessClean(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_AGENT_PREPROCESS_CLEAN, enabled)
}
export async function getAiAgentPreprocessMerge(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_PREPROCESS_MERGE)
return value !== false
}
export async function setAiAgentPreprocessMerge(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_AGENT_PREPROCESS_MERGE, enabled)
}
export async function getAiAgentPreprocessDenoise(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_PREPROCESS_DENOISE)
return value !== false
}
export async function setAiAgentPreprocessDenoise(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_AGENT_PREPROCESS_DENOISE, enabled)
}
export async function getAiAgentPreprocessDesensitize(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_PREPROCESS_DESENSITIZE)
return value === true
}
export async function setAiAgentPreprocessDesensitize(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_AGENT_PREPROCESS_DESENSITIZE, enabled)
}
export async function getAiAgentPreprocessAnonymize(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_AGENT_PREPROCESS_ANONYMIZE)
return value === true
}
export async function setAiAgentPreprocessAnonymize(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_AGENT_PREPROCESS_ANONYMIZE, enabled)
}
export async function getAiInsightEnabled(): Promise<boolean> { export async function getAiInsightEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED) const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED)
return value === true return value === true

View File

@@ -0,0 +1,319 @@
import { create } from 'zustand'
import type { AgentRuntimeStatus, AgentStreamChunk, TokenUsage } from '../types/electron'
export type RuntimeContentBlock =
| { type: 'text'; text: string }
| { type: 'think'; tag: string; text: string; durationMs?: number }
| {
type: 'tool'
tool: {
id: string
name: string
status: 'running' | 'done' | 'error'
params?: Record<string, unknown>
result?: unknown
durationMs?: number
}
}
export interface RuntimeSourceMessage {
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
}
interface ConversationRuntimeState {
requestId: string
runId: string
running: boolean
draft: string
chunks: AgentStreamChunk[]
blocks: RuntimeContentBlock[]
sourceMessages: RuntimeSourceMessage[]
currentKeywords: string[]
usage?: TokenUsage
status?: AgentRuntimeStatus
error?: string
updatedAt: number
}
interface AiRuntimeStoreState {
activeRequestId: string
states: Record<string, ConversationRuntimeState>
startRun: (conversationId: string, requestId: string) => void
appendChunk: (conversationId: string, chunk: AgentStreamChunk) => void
completeRun: (
conversationId: string,
payload?: { runId?: string; conversationId?: string; error?: string; canceled?: boolean }
) => void
clearConversation: (conversationId: string) => void
}
function nextConversationState(previous?: ConversationRuntimeState): ConversationRuntimeState {
return previous || {
requestId: '',
runId: '',
running: false,
draft: '',
chunks: [],
blocks: [],
sourceMessages: [],
currentKeywords: [],
usage: undefined,
status: undefined,
error: '',
updatedAt: Date.now()
}
}
function normalizeText(value: unknown): string {
return String(value ?? '').trim()
}
function toRuntimeSourceMessage(row: any): RuntimeSourceMessage | null {
if (!row || typeof row !== 'object') return null
const id = Number(row.id ?? row.localId ?? row.local_id ?? 0)
const timestamp = Number(row.timestamp ?? row.createTime ?? row.create_time ?? 0)
const senderName = normalizeText(row.senderName ?? row.sender ?? row.sender_username)
const content = normalizeText(row.content ?? row.snippet ?? row.message_content)
const sessionId = normalizeText(row.sessionId ?? row._session_id ?? row.session_id)
if (id <= 0 || !content) return null
return {
id,
localId: Number(row.localId ?? row.local_id ?? id),
sessionId,
senderName: senderName || '未知成员',
senderPlatformId: normalizeText(row.senderPlatformId ?? row.sender_platform_id ?? row.sender_username),
senderUsername: normalizeText(row.senderUsername ?? row.sender_username),
content,
timestamp,
type: Number(row.type ?? row.localType ?? row.local_type ?? 0)
}
}
function extractRuntimeSourceMessages(payload: unknown): RuntimeSourceMessage[] {
if (!payload || typeof payload !== 'object') return []
const bag = payload as Record<string, unknown>
const candidates: unknown[] = []
if (Array.isArray(bag.rawMessages)) candidates.push(...bag.rawMessages)
if (Array.isArray(bag.messages)) candidates.push(...bag.messages)
if (Array.isArray(bag.rows)) candidates.push(...bag.rows)
const nestedResult = bag.result
if (nestedResult && typeof nestedResult === 'object') {
const nested = nestedResult as Record<string, unknown>
if (Array.isArray(nested.rawMessages)) candidates.push(...nested.rawMessages)
if (Array.isArray(nested.messages)) candidates.push(...nested.messages)
if (Array.isArray(nested.rows)) candidates.push(...nested.rows)
if (Array.isArray(nested.items)) candidates.push(...nested.items)
}
const output: RuntimeSourceMessage[] = []
const dedup = new Set<string>()
for (const row of candidates) {
const normalized = toRuntimeSourceMessage(row)
if (!normalized) continue
const key = `${normalized.sessionId}:${normalized.localId}:${normalized.timestamp}`
if (dedup.has(key)) continue
dedup.add(key)
output.push(normalized)
if (output.length >= 120) break
}
return output
}
function upsertToolBlock(blocks: RuntimeContentBlock[], chunk: AgentStreamChunk): RuntimeContentBlock[] {
const toolName = normalizeText(chunk.toolName)
if (!toolName) return blocks
if (chunk.type === 'tool_start') {
return [
...blocks,
{
type: 'tool',
tool: {
id: `${toolName}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: toolName,
status: 'running',
params: chunk.toolParams
}
}
]
}
if (chunk.type !== 'tool_result') return blocks
const next = [...blocks]
for (let i = next.length - 1; i >= 0; i -= 1) {
const block = next[i]
if (block.type !== 'tool') continue
if (block.tool.name !== toolName) continue
if (block.tool.status !== 'running') continue
next[i] = {
type: 'tool',
tool: {
...block.tool,
status: chunk.error ? 'error' : 'done',
result: chunk.toolResult,
durationMs: Number((chunk.toolResult as any)?.durationMs || 0) || undefined
}
}
return next
}
next.push({
type: 'tool',
tool: {
id: `${toolName}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: toolName,
status: chunk.error ? 'error' : 'done',
params: chunk.toolParams,
result: chunk.toolResult,
durationMs: Number((chunk.toolResult as any)?.durationMs || 0) || undefined
}
})
return next
}
export const useAiRuntimeStore = create<AiRuntimeStoreState>((set) => ({
activeRequestId: '',
states: {},
startRun: (conversationId, requestId) => set((state) => {
const prev = nextConversationState(state.states[conversationId])
return {
activeRequestId: requestId,
states: {
...state.states,
[conversationId]: {
...prev,
requestId,
runId: '',
running: true,
draft: '',
chunks: [],
blocks: [],
error: '',
sourceMessages: [],
currentKeywords: [],
usage: undefined,
status: {
phase: 'thinking',
updatedAt: Date.now()
},
updatedAt: Date.now()
}
}
}
}),
appendChunk: (conversationId, chunk) => set((state) => {
const prev = nextConversationState(state.states[conversationId])
const nextBlocks = [...prev.blocks]
let nextDraft = prev.draft
const nextKeywords = [...prev.currentKeywords]
if (chunk.type === 'content') {
const text = normalizeText(chunk.content)
if (text) {
nextDraft = `${prev.draft}${text}`
const last = nextBlocks[nextBlocks.length - 1]
if (last && last.type === 'text') {
last.text = `${last.text}${text}`
} else {
nextBlocks.push({ type: 'text', text })
}
}
}
if (chunk.type === 'think') {
const text = normalizeText(chunk.content)
if (text) {
nextBlocks.push({
type: 'think',
tag: normalizeText(chunk.thinkTag) || 'thinking',
text,
durationMs: chunk.thinkDurationMs
})
}
}
const mergedBlocks = upsertToolBlock(nextBlocks, chunk)
const extractedSource = chunk.type === 'tool_result'
? extractRuntimeSourceMessages(chunk.toolResult)
: []
const sourceDedup = new Map<string, RuntimeSourceMessage>()
for (const item of [...prev.sourceMessages, ...extractedSource]) {
const key = `${item.sessionId}:${item.localId}:${item.timestamp}`
sourceDedup.set(key, item)
}
if (chunk.toolParams) {
const keywordRaw = chunk.toolParams.keyword ?? chunk.toolParams.keywords
if (Array.isArray(keywordRaw)) {
for (const item of keywordRaw) {
const keyword = normalizeText(item)
if (keyword && !nextKeywords.includes(keyword)) nextKeywords.push(keyword)
}
} else {
const keyword = normalizeText(keywordRaw)
if (keyword && !nextKeywords.includes(keyword)) nextKeywords.push(keyword)
}
}
return {
states: {
...state.states,
[conversationId]: {
...prev,
runId: normalizeText(chunk.runId) || prev.runId,
draft: nextDraft,
blocks: mergedBlocks,
chunks: [...prev.chunks, chunk].slice(-500),
sourceMessages: Array.from(sourceDedup.values()).slice(-120),
currentKeywords: nextKeywords.slice(-12),
usage: chunk.usage || prev.usage,
status: chunk.status || prev.status,
error: chunk.error || prev.error,
running: chunk.type === 'done' || chunk.type === 'error' || chunk.isFinished ? false : prev.running,
updatedAt: Date.now()
}
}
}
}),
completeRun: (conversationId, payload) => set((state) => {
const prev = state.states[conversationId]
if (!prev) return state
const failed = normalizeText(payload?.error)
const canceled = payload?.canceled === true || failed === '任务已取消' || failed === '任务已停止'
return {
activeRequestId: '',
states: {
...state.states,
[conversationId]: {
...prev,
runId: normalizeText(payload?.runId) || prev.runId,
running: false,
error: canceled ? '' : (failed || prev.error),
status: canceled
? { phase: 'aborted', updatedAt: Date.now(), totalUsage: prev.usage }
: failed
? { phase: 'error', updatedAt: Date.now(), totalUsage: prev.usage }
: { phase: 'completed', updatedAt: Date.now(), totalUsage: prev.usage },
updatedAt: Date.now()
}
}
}
}),
clearConversation: (conversationId) => set((state) => {
const next = { ...state.states }
delete next[conversationId]
return { states: next }
})
}))

88
src/types/aiAnalysis.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { AgentStreamChunk } from './electron'
export type AiToolCategory = 'core' | 'analysis'
export interface AiConversation {
conversationId: string
title: string
createdAt: number
updatedAt: number
lastMessageAt: number
}
export interface AiMessageRecord {
messageId: string
conversationId: string
role: 'user' | 'assistant' | 'system' | 'tool' | string
content: string
intentType: string
components: any[]
toolTrace: any[]
usage?: {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
error?: string
parentMessageId?: string
createdAt: number
}
export interface ToolCatalogEntry {
name: string
category: AiToolCategory
description: string
parameters: Record<string, unknown>
}
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: Array<'group' | 'private'>
supportedLocales?: string[]
}
export interface SkillSummary {
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
builtinId?: string
}
export interface SqlSchemaTable {
name: string
columns: string[]
}
export interface SqlSchemaSource {
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: SqlSchemaTable[]
}
export interface SqlSchemaPayload {
generatedAt: number
sources: SqlSchemaSource[]
}
export interface SqlResultPayload {
rows: Record<string, unknown>[]
columns: string[]
total: number
}
export interface ConversationRuntimeView {
draft: string
chunks: AgentStreamChunk[]
running: boolean
updatedAt: number
}

View File

@@ -7,6 +7,37 @@ export interface SessionChatWindowOpenOptions {
initialContactType?: ContactInfo['type'] initialContactType?: ContactInfo['type']
} }
export interface TokenUsage {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
export interface AgentRuntimeStatus {
phase: 'idle' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'error' | 'aborted'
round?: number
currentTool?: string
toolsUsed?: number
updatedAt: number
totalUsage?: TokenUsage
}
export interface AgentStreamChunk {
runId: string
conversationId?: string
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error'
content?: string
thinkTag?: string
thinkDurationMs?: number
toolName?: string
toolParams?: Record<string, unknown>
toolResult?: unknown
error?: string
isFinished?: boolean
usage?: TokenUsage
status?: AgentRuntimeStatus
}
export interface ElectronAPI { export interface ElectronAPI {
window: { window: {
minimize: () => void minimize: () => void
@@ -482,6 +513,32 @@ export interface ElectronAPI {
filePath?: string filePath?: string
error?: string error?: string
}> }>
getSchema: (payload?: { sessionId?: string }) => Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}>
executeSQL: (payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}) => Promise<{
success: boolean
rows?: Record<string, unknown>[]
columns?: string[]
total?: number
error?: string
}>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
} }
biz: { biz: {
@@ -1093,6 +1150,372 @@ export interface ElectronAPI {
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => Promise<{ success: boolean; message: string; insight?: string }> }) => Promise<{ success: boolean; message: string; insight?: string }>
} }
aiApi: {
listConversations: (payload?: { page?: number; pageSize?: number }) => Promise<{
success: boolean
conversations?: Array<{
conversationId: string
title: string
createdAt: number
updatedAt: number
lastMessageAt: number
}>
error?: string
}>
createConversation: (payload?: { title?: string }) => Promise<{
success: boolean
conversationId?: string
error?: string
}>
renameConversation: (payload: { conversationId: string; title: string }) => Promise<{ success: boolean; error?: string }>
deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>
listMessages: (payload: { conversationId: string; limit?: number }) => Promise<{
success: boolean
messages?: Array<{
messageId: string
conversationId: string
role: 'user' | 'assistant' | 'system' | 'tool' | string
content: string
intentType: string
components: any[]
toolTrace: any[]
usage: Record<string, unknown>
error: string
parentMessageId: string
createdAt: number
}>
error?: string
}>
exportConversation: (payload: { conversationId: string }) => Promise<{
success: boolean
conversation?: { conversationId: string; title: string; updatedAt: number }
markdown?: string
error?: string
}>
getMessageContext: (sessionId: string, messageIds: number | number[], contextSize?: number) => Promise<Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>>
getSearchMessageContext: (sessionId: string, messageIds: number[], contextBefore?: number, contextAfter?: number) => Promise<Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>>
getRecentMessages: (sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => Promise<{
messages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
total: number
}>
getAllRecentMessages: (sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => Promise<{
messages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
total: number
}>
getConversationBetween: (
sessionId: string,
memberId1: number,
memberId2: number,
filter?: { startTs?: number; endTs?: number },
limit?: number
) => Promise<{
messages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
total: number
member1Name: string
member2Name: string
}>
getMessagesBefore: (
sessionId: string,
beforeId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => Promise<{
messages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
hasMore: boolean
}>
getMessagesAfter: (
sessionId: string,
afterId: number,
limit?: number,
filter?: { startTs?: number; endTs?: number },
senderId?: number,
keywords?: string[]
) => Promise<{
messages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
hasMore: boolean
}>
searchSessions: (
sessionId: string,
keywords?: string[],
timeFilter?: { startTs?: number; endTs?: number },
limit?: number,
previewCount?: number
) => Promise<Array<{
id: string
startTs: number
endTs: number
messageCount: number
isComplete: boolean
previewMessages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
}>>
getSessionMessages: (sessionId: string, chatSessionId: string | number, limit?: number) => Promise<{
sessionId: string
startTs: number
endTs: number
messageCount: number
returnedCount: number
participants: string[]
messages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
} | null>
getSessionSummaries: (
sessionId: string,
options?: { sessionIds?: string[]; limit?: number; previewCount?: number }
) => Promise<Array<{
sessionId: string
sessionName: string
messageCount: number
latestTs: number
previewMessages: Array<{
id: number
localId: number
sessionId: string
senderName: string
senderPlatformId: string
senderUsername: string
content: string
timestamp: number
type: number
isSend: number | null
replyToMessageId: string | null
replyToContent: string | null
replyToSenderName: string | null
}>
}>>
getToolCatalog: () => Promise<Array<{
name: string
category: 'core' | 'analysis'
description: string
parameters: Record<string, unknown>
}>>
executeTool: (payload: { name: string; args?: Record<string, any> }) => Promise<{
success: boolean
result?: unknown
error?: string
}>
cancelToolTest: (payload?: { taskId?: string }) => Promise<{ success: boolean }>
}
agentApi: {
runStream: (payload: {
mode?: 'chat' | 'sql'
conversationId?: string
userInput: string
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
sqlContext?: { schemaText?: string; targetHint?: string }
}, onChunk?: (payload: AgentStreamChunk) => void) => {
requestId: string
promise: Promise<{
success: boolean
result?: { success: boolean; runId?: string; conversationId?: string; error?: string; canceled?: boolean }
error?: string
}>
}
abort: (payload: string | { requestId?: string; runId?: string; conversationId?: string }) => Promise<{ success: boolean }>
}
assistantApi: {
getAll: () => Promise<Array<{
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: Array<'group' | 'private'>
supportedLocales?: string[]
}>>
getConfig: (id: string) => Promise<{
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: Array<'group' | 'private'>
supportedLocales?: string[]
} | null>
create: (payload: any) => Promise<{ success: boolean; id?: string; error?: string }>
update: (payload: { id: string; updates: any }) => Promise<{ success: boolean; error?: string }>
delete: (id: string) => Promise<{ success: boolean; error?: string }>
reset: (id: string) => Promise<{ success: boolean; error?: string }>
getBuiltinCatalog: () => Promise<Array<{
id: string
name: string
systemPrompt: string
applicableChatTypes?: Array<'group' | 'private'>
supportedLocales?: string[]
imported: boolean
}>>
getBuiltinToolCatalog: () => Promise<Array<{ name: string; category: 'core' | 'analysis' }>>
importFromMd: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }>
}
skillApi: {
getAll: () => Promise<Array<{
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
builtinId?: string
}>>
getConfig: (id: string) => Promise<{
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
prompt: string
builtinId?: string
} | null>
create: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }>
update: (payload: { id: string; rawMd: string }) => Promise<{ success: boolean; error?: string }>
delete: (id: string) => Promise<{ success: boolean; error?: string }>
getBuiltinCatalog: () => Promise<Array<{
id: string
name: string
description: string
tags: string[]
chatScope: 'all' | 'group' | 'private'
tools: string[]
imported: boolean
}>>
importFromMd: (rawMd: string) => Promise<{ success: boolean; id?: string; error?: string }>
}
llmApi: {
getConfig: () => Promise<{ success: boolean; config: { apiBaseUrl: string; apiKey: string; model: string } }>
setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => Promise<{ success: boolean }>
listModels: () => Promise<{ success: boolean; models: Array<{ id: string; label: string }> }>
}
} }
export interface ExportOptions { export interface ExportOptions {
@@ -1149,6 +1572,11 @@ export interface WxidInfo {
declare global { declare global {
interface Window { interface Window {
electronAPI: ElectronAPI electronAPI: ElectronAPI
aiApi: ElectronAPI['aiApi']
agentApi: ElectronAPI['agentApi']
assistantApi: ElectronAPI['assistantApi']
skillApi: ElectronAPI['skillApi']
llmApi: ElectronAPI['llmApi']
} }
// Electron 类型声明 // Electron 类型声明