mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 15:08:36 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9470bcb9a | ||
|
|
599fd1af26 |
311
electron/main.ts
311
electron/main.ts
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
454
electron/services/aiAgentService.ts
Normal file
454
electron/services/aiAgentService.ts
Normal 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()
|
||||||
3155
electron/services/aiAnalysisService.ts
Normal file
3155
electron/services/aiAnalysisService.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
electron/services/aiAnalysisSkills/base.md
Normal file
30
electron/services/aiAnalysisSkills/base.md
Normal 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。
|
||||||
|
- 未转写成功的语音不可作为事实依据。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
你会收到 conversation_summary(历史压缩摘要)。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
- 默认把摘要作为历史背景,不逐字复述。
|
||||||
|
- 若摘要与最近消息冲突,以最近消息为准。
|
||||||
|
- 若用户追问很久之前的细节,优先重新调用工具检索,不依赖旧记忆。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
工具:get_message_context
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 需要核对少量关键消息原文,避免全量展开。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 只传必要 items(sessionId + localId),每次少量(<=20)。
|
||||||
|
- 默认 minimal;需要上下文再用 standard/full。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
工具:search_sessions
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 用户未明确具体会话,但给了关键词/关系词(如“老婆”“买车”)。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 首次调用 detailLevel=minimal。
|
||||||
|
- 默认 limit 8~12,避免拉太多候选。
|
||||||
|
- 当候选歧义较大时再升级 detailLevel=standard/full。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
工具:get_recent_messages
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 已确定候选会话,需要“先看一点”理解上下文。
|
||||||
|
|
||||||
|
Agent策略:
|
||||||
|
- 每个候选会话先抽样 6~20 条,按时间顺序阅读。
|
||||||
|
- 不要只读一个会话就结束;优先覆盖多会话后再总结。
|
||||||
|
- 如果出现明显分歧场景(工作/家庭/感情)需主动向用户确认分析目标。
|
||||||
8
electron/services/aiAnalysisSkills/tool_source_refs.md
Normal file
8
electron/services/aiAnalysisSkills/tool_source_refs.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
工具:get_session_summaries
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 输出总结或分析后,用于来源说明与可解释卡片。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 默认 minimal 即可,输出 range/session_count/message_count/db_refs。
|
||||||
|
- 只有排错或审计时再请求 full。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
工具:get_time_stats
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 用户提到“今天凌晨/昨晚/某个时间段”的聊天分析。
|
||||||
|
|
||||||
|
Agent策略:
|
||||||
|
- 第一步必须先扫时间窗活跃会话,不要直接下结论。
|
||||||
|
- 拿到活跃会话后,再调用 get_recent_messages 逐个会话抽样阅读。
|
||||||
|
- 若用户目标仍不清晰,先追问 1 个关键澄清问题再继续。
|
||||||
9
electron/services/aiAnalysisSkills/tool_timeline.md
Normal file
9
electron/services/aiAnalysisSkills/tool_timeline.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
工具:search_messages
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 回忆事件经过、梳理时间线、提取关键节点。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 默认 detailLevel=minimal。
|
||||||
|
- 先小批次 limit(40~120),不够再分页 offset。
|
||||||
|
- 需要引用原文证据时,可搭配 get_message_context。
|
||||||
9
electron/services/aiAnalysisSkills/tool_top_contacts.md
Normal file
9
electron/services/aiAnalysisSkills/tool_top_contacts.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
工具:get_member_stats
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 用户问“谁联系最密切”“谁聊得最多”“最常联系的是谁”。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 该问题优先调用本工具,而不是先跑时间轴。
|
||||||
|
- 默认 detailLevel=minimal,limit 5~10。
|
||||||
|
- 需要区分群聊时再设置 includeGroups=true。
|
||||||
8
electron/services/aiAnalysisSkills/tool_topic_stats.md
Normal file
8
electron/services/aiAnalysisSkills/tool_topic_stats.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
工具:get_chat_overview
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 用户问“多少、占比、趋势、对比”。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 仅在统计问题时调用,避免无谓聚合。
|
||||||
|
- 默认 detailLevel=minimal;有统计追问再升到 standard/full。
|
||||||
8
electron/services/aiAnalysisSkills/tool_voice_list.md
Normal file
8
electron/services/aiAnalysisSkills/tool_voice_list.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
工具:ai_list_voice_messages
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 用户提到“语音里说了什么”。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 第一步先拿 ID 清单,默认 detailLevel=minimal(仅 IDs)。
|
||||||
|
- 如用户需要挑选依据,再用 standard/full 查看更多元数据。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
工具:ai_transcribe_voice_messages
|
||||||
|
|
||||||
|
何时用:
|
||||||
|
- 已明确拿到语音 ID,且用户需要读取语音内容。
|
||||||
|
|
||||||
|
调用建议:
|
||||||
|
- 必须显式传 ids 或 items。
|
||||||
|
- 单次控制在小批次(建议 <=5),失败可重试。
|
||||||
|
- 转写成功后再参与总结;失败项单独标注,不混入结论。
|
||||||
444
electron/services/aiAssistantService.ts
Normal file
444
electron/services/aiAssistantService.ts
Normal 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()
|
||||||
395
electron/services/aiSkillService.ts
Normal file
395
electron/services/aiSkillService.ts
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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: '仅允许只读 SQL(SELECT/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: '当前数据服务版本不支持获取朋友圈' }
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取语音数据
|
* 获取语音数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Binary file not shown.
@@ -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 />} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
787
src/pages/AiAnalysisPage.scss
Normal file
787
src/pages/AiAnalysisPage.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
936
src/pages/AiAnalysisPage.tsx
Normal file
936
src/pages/AiAnalysisPage.tsx
Normal 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
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
319
src/stores/aiRuntimeStore.ts
Normal file
319
src/stores/aiRuntimeStore.ts
Normal 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
88
src/types/aiAnalysis.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
428
src/types/electron.d.ts
vendored
428
src/types/electron.d.ts
vendored
@@ -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 类型声明
|
||||||
|
|||||||
Reference in New Issue
Block a user