mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 07:25:50 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9470bcb9a | ||
|
|
599fd1af26 |
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -104,11 +104,6 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure linux key helper is executable
|
||||
shell: bash
|
||||
run: |
|
||||
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -316,22 +311,3 @@ jobs:
|
||||
EOF
|
||||
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
|
||||
deploy-aur:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-linux]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||
with:
|
||||
pkgname: weflow
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
commit_email: h3cof6@gmail.com
|
||||
ssh_keyscan_types: ed25519
|
||||
|
||||
311
electron/main.ts
311
electron/main.ts
@@ -31,6 +31,10 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
|
||||
import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
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'
|
||||
|
||||
// 配置自动更新
|
||||
@@ -1598,6 +1602,14 @@ const runLegacySnsCacheMigration = async (
|
||||
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 处理器
|
||||
function registerIpcHandlers() {
|
||||
registerNotificationHandlers()
|
||||
@@ -1651,6 +1663,305 @@ function registerIpcHandlers() {
|
||||
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 () => {
|
||||
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
|
||||
const result = setSystemLaunchAtStartup(false)
|
||||
|
||||
@@ -276,6 +276,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
format: 'csv' | 'json',
|
||||
filePath: string
|
||||
) => 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) => {
|
||||
ipcRenderer.on('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 }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => 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()
|
||||
@@ -13,7 +13,6 @@ export interface BizAccount {
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
unread_count?: number
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
@@ -105,24 +104,19 @@ export class BizService {
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
const bizUnreadCount: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
const sessionsRes = await chatService.getSessions()
|
||||
const sessionsRes = await wcdbService.getSessions()
|
||||
if (sessionsRes.success && sessionsRes.sessions) {
|
||||
for (const session of sessionsRes.sessions) {
|
||||
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||
// 适配日志中发现的字段,注意转为整型数字
|
||||
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const time = parseInt(timeStr.toString(), 10)
|
||||
|
||||
if (usernames.includes(uname) && time > 0) {
|
||||
bizLatestTime[uname] = time
|
||||
}
|
||||
if (usernames.includes(uname)) {
|
||||
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
|
||||
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -158,8 +152,7 @@ export class BizService {
|
||||
avatar: info?.avatarUrl || '',
|
||||
type: 0,
|
||||
last_time: lastTime,
|
||||
formatted_last_time: formatBizTime(lastTime),
|
||||
unread_count: bizUnreadCount[uname] || 0
|
||||
formatted_last_time: formatBizTime(lastTime)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -174,6 +174,36 @@ interface GetContactsOptions {
|
||||
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 {
|
||||
totalMessages: number
|
||||
voiceMessages: number
|
||||
@@ -232,16 +262,6 @@ interface SessionDetailExtra {
|
||||
|
||||
type SessionDetail = SessionDetailFast & SessionDetailExtra
|
||||
|
||||
interface SyntheticUnreadState {
|
||||
readTimestamp: number
|
||||
scannedTimestamp: number
|
||||
latestTimestamp: number
|
||||
unreadCount: number
|
||||
summaryTimestamp?: number
|
||||
summary?: string
|
||||
lastMsgType?: number
|
||||
}
|
||||
|
||||
interface MyFootprintSummary {
|
||||
private_inbound_people: number
|
||||
private_replied_people: number
|
||||
@@ -388,7 +408,6 @@ class ChatService {
|
||||
private readonly messageDbCountSnapshotCacheTtlMs = 8000
|
||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||
private sessionMessageCountHintCache = new Map<string, number>()
|
||||
private syntheticUnreadState = new Map<string, SyntheticUnreadState>()
|
||||
private sessionMessageCountBatchCache: {
|
||||
dbSignature: string
|
||||
sessionIdsKey: string
|
||||
@@ -876,10 +895,6 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.addMissingOfficialSessions(sessions, myWxid)
|
||||
await this.applySyntheticUnreadCounts(sessions)
|
||||
sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0))
|
||||
|
||||
// 不等待联系人信息加载,直接返回基础会话列表
|
||||
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
||||
return { success: true, sessions }
|
||||
@@ -889,242 +904,6 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
|
||||
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
|
||||
try {
|
||||
const contactResult = await wcdbService.getContactsCompact()
|
||||
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return
|
||||
|
||||
for (const row of contactResult.contacts as Record<string, any>[]) {
|
||||
const username = String(row.username || '').trim()
|
||||
if (!username.startsWith('gh_') || existing.has(username)) continue
|
||||
|
||||
sessions.push({
|
||||
username,
|
||||
type: 0,
|
||||
unreadCount: 0,
|
||||
summary: '查看公众号历史消息',
|
||||
sortTimestamp: 0,
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
displayName: row.remark || row.nick_name || row.alias || username,
|
||||
avatarUrl: undefined,
|
||||
selfWxid: myWxid
|
||||
})
|
||||
existing.add(username)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ChatService] 补充公众号会话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldUseSyntheticUnread(sessionId: string): boolean {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
return normalized.startsWith('gh_')
|
||||
}
|
||||
|
||||
private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> {
|
||||
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) {
|
||||
return { total: 0, latestTimestamp: 0 }
|
||||
}
|
||||
|
||||
let total = 0
|
||||
let latestTimestamp = 0
|
||||
for (const row of tableStatsResult.tables as Record<string, any>[]) {
|
||||
const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0)
|
||||
if (Number.isFinite(count) && count > 0) {
|
||||
total += Math.floor(count)
|
||||
}
|
||||
|
||||
const latest = Number(
|
||||
row.last_timestamp ??
|
||||
row.lastTimestamp ??
|
||||
row.last_time ??
|
||||
row.lastTime ??
|
||||
row.max_create_time ??
|
||||
row.maxCreateTime ??
|
||||
0
|
||||
)
|
||||
if (Number.isFinite(latest) && latest > latestTimestamp) {
|
||||
latestTimestamp = Math.floor(latest)
|
||||
}
|
||||
}
|
||||
|
||||
return { total, latestTimestamp }
|
||||
}
|
||||
|
||||
private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise<void> {
|
||||
const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username))
|
||||
if (candidates.length === 0) return
|
||||
|
||||
for (const session of candidates) {
|
||||
try {
|
||||
const snapshot = await this.getSessionMessageStatsSnapshot(session.username)
|
||||
const latestTimestamp = Math.max(
|
||||
Number(session.lastTimestamp || 0),
|
||||
Number(session.sortTimestamp || 0),
|
||||
snapshot.latestTimestamp
|
||||
)
|
||||
if (latestTimestamp > 0) {
|
||||
session.lastTimestamp = latestTimestamp
|
||||
session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp)
|
||||
}
|
||||
if (snapshot.total > 0) {
|
||||
session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total)
|
||||
this.sessionMessageCountHintCache.set(session.username, session.messageCountHint)
|
||||
}
|
||||
|
||||
let state = this.syntheticUnreadState.get(session.username)
|
||||
if (!state) {
|
||||
const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp)
|
||||
state = {
|
||||
readTimestamp: latestTimestamp,
|
||||
scannedTimestamp: latestTimestamp,
|
||||
latestTimestamp,
|
||||
unreadCount: initialUnread.count
|
||||
}
|
||||
if (initialUnread.latestMessage) {
|
||||
state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage)
|
||||
state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp)
|
||||
state.lastMsgType = Number(initialUnread.latestMessage.localType || 0)
|
||||
}
|
||||
this.syntheticUnreadState.set(session.username, state)
|
||||
}
|
||||
|
||||
let latestMessageForSummary: Message | undefined
|
||||
if (latestTimestamp > state.scannedTimestamp) {
|
||||
const newMessagesResult = await this.getNewMessages(
|
||||
session.username,
|
||||
Math.max(0, state.scannedTimestamp),
|
||||
1000
|
||||
)
|
||||
if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) {
|
||||
let nextUnread = state.unreadCount
|
||||
let nextScannedTimestamp = state.scannedTimestamp
|
||||
for (const message of newMessagesResult.messages) {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue
|
||||
if (message.isSend === 1) continue
|
||||
nextUnread += 1
|
||||
latestMessageForSummary = message
|
||||
if (createTime > nextScannedTimestamp) {
|
||||
nextScannedTimestamp = Math.floor(createTime)
|
||||
}
|
||||
}
|
||||
state.unreadCount = nextUnread
|
||||
state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp)
|
||||
} else {
|
||||
state.scannedTimestamp = latestTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp)
|
||||
if (latestMessageForSummary) {
|
||||
const summary = this.getSessionSummaryFromMessage(latestMessageForSummary)
|
||||
if (summary) {
|
||||
state.summary = summary
|
||||
state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp)
|
||||
state.lastMsgType = Number(latestMessageForSummary.localType || 0)
|
||||
}
|
||||
}
|
||||
if (state.summary) {
|
||||
session.summary = state.summary
|
||||
session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0)
|
||||
}
|
||||
session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount)
|
||||
} catch (error) {
|
||||
console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionSummaryFromMessage(message: Message): string {
|
||||
const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim()
|
||||
let summary = ''
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
summary = message.parsedContent || message.rawContent || ''
|
||||
break
|
||||
case 3:
|
||||
summary = '[图片]'
|
||||
break
|
||||
case 34:
|
||||
summary = '[语音]'
|
||||
break
|
||||
case 43:
|
||||
summary = '[视频]'
|
||||
break
|
||||
case 47:
|
||||
summary = '[表情]'
|
||||
break
|
||||
case 42:
|
||||
summary = message.cardNickname || '[名片]'
|
||||
break
|
||||
case 48:
|
||||
summary = '[位置]'
|
||||
break
|
||||
case 49:
|
||||
summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]'
|
||||
break
|
||||
default:
|
||||
summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0))
|
||||
break
|
||||
}
|
||||
return cleanOfficialPrefix(this.cleanString(summary))
|
||||
}
|
||||
|
||||
private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{
|
||||
count: number
|
||||
latestMessage?: Message
|
||||
}> {
|
||||
const normalizedLatest = Number(latestTimestamp || 0)
|
||||
if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 }
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) {
|
||||
return { count: 0 }
|
||||
}
|
||||
|
||||
const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20)
|
||||
if (!result.success || !Array.isArray(result.messages)) return { count: 0 }
|
||||
const unreadMessages = result.messages.filter((message) => {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
return Number.isFinite(createTime) &&
|
||||
createTime >= normalizedLatest &&
|
||||
message.isSend !== 1
|
||||
})
|
||||
return {
|
||||
count: unreadMessages.length,
|
||||
latestMessage: unreadMessages[unreadMessages.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!this.shouldUseSyntheticUnread(normalized)) return
|
||||
|
||||
let latestTimestamp = 0
|
||||
const state = this.syntheticUnreadState.get(normalized)
|
||||
if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp)
|
||||
for (const message of messages) {
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (Number.isFinite(createTime) && createTime > latestTimestamp) {
|
||||
latestTimestamp = Math.floor(createTime)
|
||||
}
|
||||
}
|
||||
|
||||
this.syntheticUnreadState.set(normalized, {
|
||||
readTimestamp: latestTimestamp,
|
||||
scannedTimestamp: latestTimestamp,
|
||||
latestTimestamp,
|
||||
unreadCount: 0,
|
||||
summary: state?.summary,
|
||||
summaryTimestamp: state?.summaryTimestamp,
|
||||
lastMsgType: state?.lastMsgType
|
||||
})
|
||||
}
|
||||
|
||||
async getSessionStatuses(usernames: string[]): Promise<{
|
||||
success: boolean
|
||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
@@ -2065,9 +1844,6 @@ class ChatService {
|
||||
releaseMessageCursorMutex?.()
|
||||
|
||||
this.messageCacheService.set(sessionId, filtered)
|
||||
if (offset === 0 && startTime === 0 && endTime === 0) {
|
||||
this.markSyntheticUnreadRead(sessionId, filtered)
|
||||
}
|
||||
console.log(
|
||||
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
|
||||
)
|
||||
@@ -4670,8 +4446,6 @@ class ChatService {
|
||||
case '57':
|
||||
// 引用消息,title 就是回复的内容
|
||||
return title
|
||||
case '53':
|
||||
return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}`
|
||||
case '2000':
|
||||
return `[转账] ${title}`
|
||||
case '2001':
|
||||
@@ -4701,8 +4475,6 @@ class ChatService {
|
||||
return '[链接]'
|
||||
case '87':
|
||||
return '[群公告]'
|
||||
case '53':
|
||||
return '[接龙]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
@@ -5302,8 +5074,6 @@ class ChatService {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
result.quotedContent = quoteInfo.content
|
||||
result.quotedSender = quoteInfo.sender
|
||||
} else if (xmlType === '53') {
|
||||
result.appMsgKind = 'solitaire'
|
||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||
result.appMsgKind = 'official-link'
|
||||
} else if (url) {
|
||||
@@ -8734,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 }> {
|
||||
try {
|
||||
const nativeResult = await wcdbService.getMessageById(sessionId, localId)
|
||||
|
||||
@@ -61,8 +61,6 @@ interface ConfigSchema {
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
messagePushFilterList: string[]
|
||||
httpApiEnabled: boolean
|
||||
httpApiPort: number
|
||||
httpApiHost: string
|
||||
@@ -76,6 +74,16 @@ interface ConfigSchema {
|
||||
aiModelApiBaseUrl: string
|
||||
aiModelApiKey: 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
|
||||
aiInsightApiBaseUrl: string
|
||||
aiInsightApiKey: string
|
||||
@@ -179,8 +187,6 @@ export class ConfigService {
|
||||
httpApiPort: 5031,
|
||||
httpApiHost: '127.0.0.1',
|
||||
messagePushEnabled: false,
|
||||
messagePushFilterMode: 'all',
|
||||
messagePushFilterList: [],
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: [],
|
||||
@@ -188,6 +194,16 @@ export class ConfigService {
|
||||
aiModelApiBaseUrl: '',
|
||||
aiModelApiKey: '',
|
||||
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,
|
||||
aiInsightApiBaseUrl: '',
|
||||
aiInsightApiKey: '',
|
||||
|
||||
@@ -2119,7 +2119,6 @@ class ExportService {
|
||||
}
|
||||
return title || '[引用消息]'
|
||||
}
|
||||
if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]'
|
||||
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||
|
||||
// 有 title 就返回 title
|
||||
@@ -3221,8 +3220,6 @@ class ExportService {
|
||||
appMsgKind = 'announcement'
|
||||
} else if (xmlType === '57' || hasReferMsg || localType === 244813135921) {
|
||||
appMsgKind = 'quote'
|
||||
} else if (xmlType === '53') {
|
||||
appMsgKind = 'solitaire'
|
||||
} else if (xmlType === '5' || xmlType === '49') {
|
||||
appMsgKind = 'link'
|
||||
} else if (looksLikeAppMsg) {
|
||||
|
||||
@@ -98,12 +98,7 @@ export class KeyServiceLinux {
|
||||
'xwechat',
|
||||
'/opt/wechat/wechat',
|
||||
'/usr/bin/wechat',
|
||||
'/usr/local/bin/wechat',
|
||||
'/usr/bin/wechat',
|
||||
'/opt/apps/com.tencent.wechat/files/wechat',
|
||||
'/usr/bin/wechat-bin',
|
||||
'/usr/local/bin/wechat-bin',
|
||||
'com.tencent.wechat'
|
||||
'/opt/apps/com.tencent.wechat/files/wechat'
|
||||
]
|
||||
|
||||
for (const binName of wechatBins) {
|
||||
@@ -157,7 +152,7 @@ export class KeyServiceLinux {
|
||||
}
|
||||
|
||||
if (!pid) {
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
@@ -555,19 +555,7 @@ export class KeyServiceMac {
|
||||
if (code === 'HOOK_TARGET_ONLY') {
|
||||
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
const normalizedDetail = (detail || '').trim()
|
||||
if (!normalizedDetail) {
|
||||
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
||||
}
|
||||
if (normalizedDetail.includes('Sink pattern not found')) {
|
||||
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
|
||||
}
|
||||
if (normalizedDetail.includes('No suitable module found')) {
|
||||
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
|
||||
}
|
||||
return `内存扫描失败:${normalizedDetail}`
|
||||
}
|
||||
if (code === 'SCAN_FAILED') return '内存扫描失败'
|
||||
return '未知错误'
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ interface SessionBaseline {
|
||||
interface MessagePushPayload {
|
||||
event: 'message.new'
|
||||
sessionId: string
|
||||
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||
messageKey: string
|
||||
avatarUrl?: string
|
||||
sourceName: string
|
||||
@@ -21,8 +20,6 @@ interface MessagePushPayload {
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
'messagePushEnabled',
|
||||
'messagePushFilterMode',
|
||||
'messagePushFilterList',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
@@ -41,7 +38,6 @@ class MessagePushService {
|
||||
private rerunRequested = false
|
||||
private started = false
|
||||
private baselineReady = false
|
||||
private messageTableScanRequested = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
@@ -64,15 +60,12 @@ class MessagePushService {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const tableName = String(payload?.table || '').trim()
|
||||
if (this.isSessionTableChange(tableName)) {
|
||||
this.scheduleSync()
|
||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||
if (tableName && tableName !== 'session') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||
}
|
||||
this.scheduleSync()
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
@@ -98,7 +91,6 @@ class MessagePushService {
|
||||
this.recentMessageKeys.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
this.baselineReady = false
|
||||
this.messageTableScanRequested = false
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
@@ -129,11 +121,7 @@ class MessagePushService {
|
||||
this.baselineReady = true
|
||||
}
|
||||
|
||||
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||
if (options.scanMessageBackedSessions) {
|
||||
this.messageTableScanRequested = true
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
@@ -153,8 +141,6 @@ class MessagePushService {
|
||||
this.processing = true
|
||||
try {
|
||||
if (!this.isPushEnabled()) return
|
||||
const scanMessageBackedSessions = this.messageTableScanRequested
|
||||
this.messageTableScanRequested = false
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
@@ -177,47 +163,27 @@ class MessagePushService {
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
this.setBaseline(sessions)
|
||||
|
||||
const candidates = sessions.filter((session) => {
|
||||
const previous = previousBaseline.get(session.username)
|
||||
if (this.shouldInspectSession(previous, session)) {
|
||||
return true
|
||||
}
|
||||
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
|
||||
})
|
||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||
for (const session of candidates) {
|
||||
await this.pushSessionMessages(
|
||||
session,
|
||||
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||
)
|
||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.rerunRequested) {
|
||||
this.rerunRequested = false
|
||||
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
|
||||
this.scheduleSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setBaseline(sessions: ChatSession[]): void {
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
const nextBaseline = new Map<string, SessionBaseline>()
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
this.sessionBaseline.clear()
|
||||
for (const session of sessions) {
|
||||
const username = String(session.username || '').trim()
|
||||
if (!username) continue
|
||||
const previous = previousBaseline.get(username)
|
||||
const sessionTimestamp = Number(session.lastTimestamp || 0)
|
||||
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
|
||||
nextBaseline.set(username, {
|
||||
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
|
||||
this.sessionBaseline.set(session.username, {
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
unreadCount: Number(session.unreadCount || 0)
|
||||
})
|
||||
}
|
||||
for (const [username, baseline] of nextBaseline.entries()) {
|
||||
this.sessionBaseline.set(username, baseline)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
@@ -238,30 +204,16 @@ class MessagePushService {
|
||||
return unreadCount > 0 && lastTimestamp > 0
|
||||
}
|
||||
|
||||
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
|
||||
}
|
||||
|
||||
private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
const sessionId = String(session.username || '').trim()
|
||||
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||
if (lastTimestamp <= previous.lastTimestamp) {
|
||||
return false
|
||||
}
|
||||
|
||||
const summary = String(session.summary || '').trim()
|
||||
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionType = this.getSessionType(sessionId, session)
|
||||
if (sessionType === 'private') {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
|
||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||
return unreadCount > previous.unreadCount
|
||||
}
|
||||
|
||||
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||
return
|
||||
@@ -272,7 +224,7 @@ class MessagePushService {
|
||||
if (!messageKey) continue
|
||||
if (message.isSend === 1) continue
|
||||
|
||||
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
|
||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -282,11 +234,9 @@ class MessagePushService {
|
||||
|
||||
const payload = await this.buildPayload(session, message)
|
||||
if (!payload) continue
|
||||
if (!this.shouldPushPayload(payload)) continue
|
||||
|
||||
httpService.broadcastMessagePush(payload)
|
||||
this.rememberMessageKey(messageKey)
|
||||
this.bumpSessionBaseline(session.username, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +246,6 @@ class MessagePushService {
|
||||
if (!sessionId || !messageKey) return null
|
||||
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const sessionType = this.getSessionType(sessionId, session)
|
||||
const content = this.getMessageDisplayContent(message)
|
||||
|
||||
if (isGroup) {
|
||||
@@ -306,7 +255,6 @@ class MessagePushService {
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
groupName,
|
||||
@@ -319,7 +267,6 @@ class MessagePushService {
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
@@ -327,84 +274,10 @@ class MessagePushService {
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
return 'group'
|
||||
}
|
||||
if (sessionId.startsWith('gh_') || session.type === 'official') {
|
||||
return 'official'
|
||||
}
|
||||
if (session.type === 'friend') {
|
||||
return 'private'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
private shouldPushPayload(payload: MessagePushPayload): boolean {
|
||||
const sessionId = String(payload.sessionId || '').trim()
|
||||
const filterMode = this.getMessagePushFilterMode()
|
||||
if (filterMode === 'all') {
|
||||
return true
|
||||
}
|
||||
|
||||
const filterList = this.getMessagePushFilterList()
|
||||
const listed = filterList.has(sessionId)
|
||||
if (filterMode === 'whitelist') {
|
||||
return listed
|
||||
}
|
||||
return !listed
|
||||
}
|
||||
|
||||
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
|
||||
const value = this.configService.get('messagePushFilterMode')
|
||||
if (value === 'whitelist' || value === 'blacklist') return value
|
||||
return 'all'
|
||||
}
|
||||
|
||||
private getMessagePushFilterList(): Set<string> {
|
||||
const value = this.configService.get('messagePushFilterList')
|
||||
if (!Array.isArray(value)) return new Set()
|
||||
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
|
||||
}
|
||||
|
||||
private isSessionTableChange(tableName: string): boolean {
|
||||
return String(tableName || '').trim().toLowerCase() === 'session'
|
||||
}
|
||||
|
||||
private isMessageTableChange(tableName: string): boolean {
|
||||
const normalized = String(tableName || '').trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return normalized === 'message' ||
|
||||
normalized === 'msg' ||
|
||||
normalized.startsWith('message_') ||
|
||||
normalized.startsWith('msg_') ||
|
||||
normalized.includes('message')
|
||||
}
|
||||
|
||||
private bumpSessionBaseline(sessionId: string, message: Message): void {
|
||||
const key = String(sessionId || '').trim()
|
||||
if (!key) return
|
||||
|
||||
const createTime = Number(message.createTime || 0)
|
||||
if (!Number.isFinite(createTime) || createTime <= 0) return
|
||||
|
||||
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
|
||||
if (createTime > current.lastTimestamp) {
|
||||
this.sessionBaseline.set(key, {
|
||||
...current,
|
||||
lastTimestamp: createTime
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageDisplayContent(message: Message): string | null {
|
||||
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||
if (!value) return value
|
||||
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||
}
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
return cleanOfficialPrefix(message.rawContent || null)
|
||||
return message.rawContent || null
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
@@ -414,13 +287,13 @@ class MessagePushService {
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||
return message.cardNickname || '[名片]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||
return message.linkTitle || message.fileName || '[消息]'
|
||||
default:
|
||||
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||
return message.parsedContent || message.rawContent || null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ export class WcdbCore {
|
||||
private wcdbGetMessageDates: any = null
|
||||
private wcdbOpenMessageCursor: any = null
|
||||
private wcdbOpenMessageCursorLite: any = null
|
||||
private wcdbOpenMessageCursorWithKey: any = null
|
||||
private wcdbOpenMessageCursorLiteWithKey: any = null
|
||||
private wcdbFetchMessageBatch: any = null
|
||||
private wcdbCloseMessageCursor: any = null
|
||||
private wcdbGetLogs: any = null
|
||||
@@ -85,6 +87,19 @@ export class WcdbCore {
|
||||
private wcdbScanMediaStream: any = null
|
||||
private wcdbGetHeadImageBuffers: 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 wcdbGetSnsAnnualStats: 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)
|
||||
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)
|
||||
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)')
|
||||
@@ -950,6 +974,15 @@ export class WcdbCore {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
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 }> {
|
||||
if (!this.ensureReady()) {
|
||||
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 }> {
|
||||
if (!this.ensureReady()) {
|
||||
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 }> {
|
||||
if (!this.ensureReady()) {
|
||||
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 }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' }
|
||||
|
||||
@@ -468,6 +468,24 @@ export class WcdbService {
|
||||
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 })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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/低频兼容)
|
||||
*/
|
||||
@@ -542,6 +616,128 @@ export class WcdbService {
|
||||
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':
|
||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'openMessageCursorWithKey':
|
||||
result = await core.openMessageCursorWithKey(
|
||||
payload.sessionId,
|
||||
payload.batchSize,
|
||||
payload.ascending,
|
||||
payload.beginTimestamp,
|
||||
payload.endTimestamp,
|
||||
payload.key
|
||||
)
|
||||
break
|
||||
case 'openMessageCursorLite':
|
||||
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'openMessageCursorLiteWithKey':
|
||||
result = await core.openMessageCursorLiteWithKey(
|
||||
payload.sessionId,
|
||||
payload.batchSize,
|
||||
payload.ascending,
|
||||
payload.beginTimestamp,
|
||||
payload.endTimestamp,
|
||||
payload.key
|
||||
)
|
||||
break
|
||||
case 'fetchMessageBatch':
|
||||
result = await core.fetchMessageBatch(payload.cursor)
|
||||
break
|
||||
case 'closeMessageCursor':
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'sqlLabGetSchema':
|
||||
result = await core.sqlLabGetSchema(payload)
|
||||
break
|
||||
case 'sqlLabExecuteReadonly':
|
||||
result = await core.sqlLabExecuteReadonly(payload)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||
break
|
||||
@@ -197,6 +223,45 @@ if (parentPort) {
|
||||
case 'searchMessages':
|
||||
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||
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':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
|
||||
0
resources/key/linux/x64/xkey_helper_linux
Executable file → Normal file
0
resources/key/linux/x64/xkey_helper_linux
Executable file → Normal file
Binary file not shown.
@@ -6,6 +6,7 @@ import RouteGuard from './components/RouteGuard'
|
||||
import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AiAnalysisPage from './pages/AiAnalysisPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||
@@ -679,6 +680,7 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/ai-analysis" element={<AiAnalysisPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
|
||||
@@ -192,149 +192,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.open .export-date-range-time-trigger {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-trigger {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-trigger-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.export-date-range-time-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 24;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.export-date-range-time-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-quick-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.export-date-range-time-quick-item,
|
||||
.export-date-range-time-option {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(var(--primary-rgb), 0.28);
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-time-quick-item {
|
||||
min-width: 52px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-date-range-time-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-date-range-time-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.export-date-range-time-column-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.export-date-range-time-column-list {
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.export-date-range-time-option {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import {
|
||||
EXPORT_DATE_RANGE_PRESETS,
|
||||
WEEKDAY_SHORT_LABELS,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createDateRangeByPreset,
|
||||
createDefaultDateRange,
|
||||
formatCalendarMonthTitle,
|
||||
formatDateInputValue,
|
||||
isSameDay,
|
||||
parseDateInputValue,
|
||||
startOfDay,
|
||||
@@ -36,10 +37,6 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||
panelMonth: Date
|
||||
}
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0'))
|
||||
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0'))
|
||||
const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59']
|
||||
|
||||
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
||||
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||
@@ -60,42 +57,16 @@ const clampSelectionToBounds = (
|
||||
const bounds = resolveBounds(minDate, maxDate)
|
||||
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (value.preset === 'custom' && !value.useAllTime) {
|
||||
const { start, end } = value.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
...value,
|
||||
dateRange: { start, end: start }
|
||||
}
|
||||
}
|
||||
return cloneExportDateRangeSelection(value)
|
||||
}
|
||||
|
||||
// For useAllTime, use bounds directly
|
||||
if (value.useAllTime) {
|
||||
return {
|
||||
preset: value.preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: bounds.minDate,
|
||||
end: bounds.maxDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For preset selections (not custom), clamp dates to bounds and use default times
|
||||
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
|
||||
|
||||
// Set default times: start at 00:00:00, end at 23:59:59
|
||||
nextStart.setHours(0, 0, 0, 0)
|
||||
nextEnd.setHours(23, 59, 59, 999)
|
||||
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
||||
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
||||
|
||||
return {
|
||||
preset: value.preset,
|
||||
useAllTime: false,
|
||||
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
||||
useAllTime: value.useAllTime,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
@@ -124,129 +95,62 @@ export function ExportDateRangeDialog({
|
||||
onClose,
|
||||
onConfirm
|
||||
}: ExportDateRangeDialogProps) {
|
||||
// Helper: Format date only (YYYY-MM-DD) for the date input field
|
||||
const formatDateOnly = (date: Date): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
// Helper: Format time only (HH:mm) for the time input field
|
||||
const formatTimeOnly = (date: Date): string => {
|
||||
const h = `${date.getHours()}`.padStart(2, '0')
|
||||
const m = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
}
|
||||
|
||||
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||
const [dateInput, setDateInput] = useState({
|
||||
start: formatDateOnly(value.dateRange.start),
|
||||
end: formatDateOnly(value.dateRange.end)
|
||||
start: formatDateInputValue(value.dateRange.start),
|
||||
end: formatDateInputValue(value.dateRange.end)
|
||||
})
|
||||
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||
|
||||
// Default times: start at 00:00, end at 23:59
|
||||
const [timeInput, setTimeInput] = useState({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
|
||||
const startTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||
const endTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||
setDraft(nextDraft)
|
||||
setActiveBoundary('start')
|
||||
setDateInput({
|
||||
start: formatDateOnly(nextDraft.dateRange.start),
|
||||
end: formatDateOnly(nextDraft.dateRange.end)
|
||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||
})
|
||||
// For preset-based selections (not custom), use default times 00:00 and 23:59
|
||||
// For custom selections, preserve the time from value.dateRange
|
||||
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
} else {
|
||||
setTimeInput({
|
||||
start: formatTimeOnly(nextDraft.dateRange.start),
|
||||
end: formatTimeOnly(nextDraft.dateRange.end)
|
||||
})
|
||||
}
|
||||
setOpenTimeDropdown(null)
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [maxDate, minDate, open, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDateInput({
|
||||
start: formatDateOnly(draft.dateRange.start),
|
||||
end: formatDateOnly(draft.dateRange.end)
|
||||
start: formatDateInputValue(draft.dateRange.start),
|
||||
end: formatDateInputValue(draft.dateRange.end)
|
||||
})
|
||||
// Don't sync timeInput here - it's controlled by the time picker
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!openTimeDropdown) return
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
const activeContainer = openTimeDropdown === 'start'
|
||||
? startTimeSelectRef.current
|
||||
: endTimeSelectRef.current
|
||||
if (!activeContainer?.contains(target)) {
|
||||
setOpenTimeDropdown(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpenTimeDropdown(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [openTimeDropdown])
|
||||
|
||||
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
||||
const clampStartDate = useCallback((targetDate: Date) => {
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
const start = startOfDay(targetDate)
|
||||
if (!bounds) return start
|
||||
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
||||
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
||||
return start
|
||||
}, [bounds])
|
||||
const clampEndDate = useCallback((targetDate: Date) => {
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
const end = endOfDay(targetDate)
|
||||
if (!bounds) return end
|
||||
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
||||
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
||||
return end
|
||||
}, [bounds])
|
||||
|
||||
const setRangeStart = useCallback((targetDate: Date) => {
|
||||
const start = clampStartDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: prev.dateRange.end
|
||||
end: nextEnd
|
||||
},
|
||||
panelMonth: toMonthStart(start)
|
||||
}
|
||||
@@ -257,13 +161,14 @@ export function ExportDateRangeDialog({
|
||||
const end = clampEndDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: end
|
||||
end: nextEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
@@ -275,11 +180,6 @@ export function ExportDateRangeDialog({
|
||||
const previewRange = bounds
|
||||
? { start: bounds.minDate, end: bounds.maxDate }
|
||||
: createDefaultDateRange()
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
setOpenTimeDropdown(null)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
@@ -296,11 +196,6 @@ export function ExportDateRangeDialog({
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByPreset(preset)
|
||||
}, minDate, maxDate).dateRange
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
setOpenTimeDropdown(null)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
@@ -311,149 +206,25 @@ export function ExportDateRangeDialog({
|
||||
setActiveBoundary('start')
|
||||
}, [bounds, maxDate, minDate])
|
||||
|
||||
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||
if (!matched) return null
|
||||
const hours = Number(matched[1])
|
||||
const minutes = Number(matched[2])
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||
return { hours, minutes }
|
||||
}
|
||||
|
||||
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
|
||||
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
|
||||
|
||||
const parsedTime = parseTimeValue(timeStr)
|
||||
if (!parsedTime) return
|
||||
|
||||
setDraft(prev => {
|
||||
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
|
||||
const newDate = new Date(dateObj)
|
||||
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
...prev.dateRange,
|
||||
[boundary]: newDate
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => {
|
||||
setActiveBoundary(boundary)
|
||||
setOpenTimeDropdown(prev => (prev === boundary ? null : boundary))
|
||||
}, [])
|
||||
|
||||
const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => {
|
||||
const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? {
|
||||
hours: boundary === 'start' ? 0 : 23,
|
||||
minutes: boundary === 'start' ? 0 : 59
|
||||
}
|
||||
const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours
|
||||
const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes
|
||||
updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`)
|
||||
}, [timeInput, updateBoundaryTime])
|
||||
|
||||
const renderTimeDropdown = (boundary: ActiveBoundary) => {
|
||||
const currentTime = timeInput[boundary]
|
||||
const parsedCurrent = parseTimeValue(currentTime) ?? {
|
||||
hours: boundary === 'start' ? 0 : 23,
|
||||
minutes: boundary === 'start' ? 0 : 59
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="export-date-range-time-dropdown-header">
|
||||
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
|
||||
<strong>{currentTime}</strong>
|
||||
</div>
|
||||
<div className="export-date-range-time-quick-list">
|
||||
{QUICK_TIME_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
|
||||
onClick={() => updateBoundaryTime(boundary, option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-time-columns">
|
||||
<div className="export-date-range-time-column">
|
||||
<span className="export-date-range-time-column-label">小时</span>
|
||||
<div className="export-date-range-time-column-list">
|
||||
{HOUR_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-hour-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
|
||||
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-time-column">
|
||||
<span className="export-date-range-time-column-label">分钟</span>
|
||||
<div className="export-date-range-time-column-list">
|
||||
{MINUTE_OPTIONS.map(option => (
|
||||
<button
|
||||
key={`${boundary}-minute-${option}`}
|
||||
type="button"
|
||||
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
|
||||
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if date input string contains time (YYYY-MM-DD HH:mm format)
|
||||
const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim())
|
||||
|
||||
const commitStartFromInput = useCallback(() => {
|
||||
const parsedDate = parseDateInputValue(dateInput.start)
|
||||
if (!parsedDate) {
|
||||
const parsed = parseDateInputValue(dateInput.start)
|
||||
if (!parsed) {
|
||||
setDateInputError(prev => ({ ...prev, start: true }))
|
||||
return
|
||||
}
|
||||
// Only apply time picker value if date input doesn't contain time
|
||||
if (!dateInputHasTime(dateInput.start)) {
|
||||
const parsedTime = parseTimeValue(timeInput.start)
|
||||
if (parsedTime) {
|
||||
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
}
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, start: false }))
|
||||
setRangeStart(parsedDate)
|
||||
}, [dateInput.start, timeInput.start, setRangeStart])
|
||||
setRangeStart(parsed)
|
||||
}, [dateInput.start, setRangeStart])
|
||||
|
||||
const commitEndFromInput = useCallback(() => {
|
||||
const parsedDate = parseDateInputValue(dateInput.end)
|
||||
if (!parsedDate) {
|
||||
const parsed = parseDateInputValue(dateInput.end)
|
||||
if (!parsed) {
|
||||
setDateInputError(prev => ({ ...prev, end: true }))
|
||||
return
|
||||
}
|
||||
// Only apply time picker value if date input doesn't contain time
|
||||
if (!dateInputHasTime(dateInput.end)) {
|
||||
const parsedTime = parseTimeValue(timeInput.end)
|
||||
if (parsedTime) {
|
||||
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||
}
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, end: false }))
|
||||
setRangeEnd(parsedDate)
|
||||
}, [dateInput.end, timeInput.end, setRangeEnd])
|
||||
setRangeEnd(parsed)
|
||||
}, [dateInput.end, setRangeEnd])
|
||||
|
||||
const shiftPanelMonth = useCallback((delta: number) => {
|
||||
setDraft(prev => ({
|
||||
@@ -463,50 +234,30 @@ export function ExportDateRangeDialog({
|
||||
}, [])
|
||||
|
||||
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
||||
// Use time from timeInput state (which is updated by the time picker)
|
||||
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
|
||||
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||
if (!matched) return { hours: 0, minutes: 0 }
|
||||
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
|
||||
}
|
||||
|
||||
if (activeBoundary === 'start') {
|
||||
const newStart = new Date(targetDate)
|
||||
const time = parseTime(timeInput.start)
|
||||
newStart.setHours(time.hours, time.minutes, 0, 0)
|
||||
setRangeStart(newStart)
|
||||
setRangeStart(targetDate)
|
||||
setActiveBoundary('end')
|
||||
setOpenTimeDropdown(null)
|
||||
return
|
||||
}
|
||||
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
|
||||
const newEnd = new Date(targetDate)
|
||||
const time = parseTime(timeInput.end)
|
||||
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
|
||||
if (pickedStart <= start) {
|
||||
newEnd.setHours(23, 59, 59, 999)
|
||||
setTimeInput(prev => ({ ...prev, end: '23:59' }))
|
||||
} else {
|
||||
newEnd.setHours(time.hours, time.minutes, 59, 999)
|
||||
}
|
||||
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: newEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}))
|
||||
setDraft(prev => {
|
||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
})
|
||||
setActiveBoundary('start')
|
||||
setOpenTimeDropdown(null)
|
||||
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
|
||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
||||
|
||||
const isRangeModeActive = !draft.useAllTime
|
||||
const modeText = isRangeModeActive
|
||||
@@ -613,23 +364,6 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
onBlur={commitStartFromInput}
|
||||
/>
|
||||
<div
|
||||
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
|
||||
ref={startTimeSelectRef}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-time-trigger"
|
||||
onClick={() => toggleTimeDropdown('start')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openTimeDropdown === 'start'}
|
||||
>
|
||||
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||
@@ -657,23 +391,6 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
onBlur={commitEndFromInput}
|
||||
/>
|
||||
<div
|
||||
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
|
||||
ref={endTimeSelectRef}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-time-trigger"
|
||||
onClick={() => toggleTimeDropdown('end')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openTimeDropdown === 'end'}
|
||||
>
|
||||
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -736,14 +453,7 @@ export function ExportDateRangeDialog({
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-dialog-btn primary"
|
||||
onClick={() => {
|
||||
// Validate: end time should not be earlier than start time
|
||||
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
|
||||
setDateInputError({ start: true, end: true })
|
||||
return
|
||||
}
|
||||
onConfirm(cloneExportDateRangeSelection(draft))
|
||||
}}
|
||||
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
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 { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
@@ -409,6 +409,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">聊天</span>
|
||||
</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
|
||||
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
|
||||
@@ -11,7 +11,6 @@
|
||||
}
|
||||
|
||||
.biz-account-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -47,24 +46,6 @@
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.biz-unread-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 52px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.biz-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useThemeStore } from '../stores/themeStore';
|
||||
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
||||
import './BizPage.scss';
|
||||
@@ -10,7 +10,6 @@ export interface BizAccount {
|
||||
type: string;
|
||||
last_time: number;
|
||||
formatted_last_time: string;
|
||||
unread_count?: number;
|
||||
}
|
||||
|
||||
export const BizAccountList: React.FC<{
|
||||
@@ -37,41 +36,24 @@ export const BizAccountList: React.FC<{
|
||||
initWxid().then(_r => { });
|
||||
}, []);
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
if (!myWxid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||
setAccounts(res || []);
|
||||
} catch (err) {
|
||||
console.error('获取服务号列表失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [myWxid]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts().then(_r => { });
|
||||
}, [fetchAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI.chat.onWcdbChange) return;
|
||||
const removeListener = window.electronAPI.chat.onWcdbChange((_event: any, data: { json?: string }) => {
|
||||
try {
|
||||
const payload = JSON.parse(data.json || '{}');
|
||||
const tableName = String(payload.table || '').toLowerCase();
|
||||
if (!tableName || tableName === 'session' || tableName.includes('message') || tableName.startsWith('msg_')) {
|
||||
fetchAccounts().then(_r => { });
|
||||
}
|
||||
} catch {
|
||||
fetchAccounts().then(_r => { });
|
||||
const fetch = async () => {
|
||||
if (!myWxid) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
return () => removeListener();
|
||||
}, [fetchAccounts]);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||
setAccounts(res || []);
|
||||
} catch (err) {
|
||||
console.error('获取服务号列表失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetch().then(_r => { } );
|
||||
}, [myWxid]);
|
||||
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -98,12 +80,7 @@ export const BizAccountList: React.FC<{
|
||||
{filtered.map(item => (
|
||||
<div
|
||||
key={item.username}
|
||||
onClick={() => {
|
||||
setAccounts(prev => prev.map(account =>
|
||||
account.username === item.username ? { ...account, unread_count: 0 } : account
|
||||
));
|
||||
onSelect({ ...item, unread_count: 0 });
|
||||
}}
|
||||
onClick={() => onSelect(item)}
|
||||
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
|
||||
>
|
||||
<img
|
||||
@@ -111,9 +88,6 @@ export const BizAccountList: React.FC<{
|
||||
className="biz-avatar"
|
||||
alt=""
|
||||
/>
|
||||
{(item.unread_count || 0) > 0 && (
|
||||
<span className="biz-unread-badge">{(item.unread_count || 0) > 99 ? '99+' : item.unread_count}</span>
|
||||
)}
|
||||
<div className="biz-info">
|
||||
<div className="biz-info-top">
|
||||
<span className="biz-name">{item.name || item.username}</span>
|
||||
|
||||
@@ -2064,7 +2064,6 @@
|
||||
.message-bubble .bubble-content:has(> .link-message),
|
||||
.message-bubble .bubble-content:has(> .card-message),
|
||||
.message-bubble .bubble-content:has(> .chat-record-message),
|
||||
.message-bubble .bubble-content:has(> .solitaire-message),
|
||||
.message-bubble .bubble-content:has(> .official-message),
|
||||
.message-bubble .bubble-content:has(> .channel-video-card),
|
||||
.message-bubble .bubble-content:has(> .location-message) {
|
||||
@@ -3605,140 +3604,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 接龙消息
|
||||
.solitaire-message {
|
||||
width: min(360px, 72vw);
|
||||
max-width: 360px;
|
||||
background: var(--card-inner-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.solitaire-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.solitaire-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.solitaire-heading {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.solitaire-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.solitaire-meta {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.solitaire-intro,
|
||||
.solitaire-entry-list {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.solitaire-intro {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.solitaire-intro-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.solitaire-entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.solitaire-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.solitaire-entry-index {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.solitaire-entry-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.solitaire-muted-line {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.solitaire-footer {
|
||||
padding: 8px 14px 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.solitaire-chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&.expanded .solitaire-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 通话消息
|
||||
.call-message {
|
||||
display: flex;
|
||||
|
||||
@@ -181,51 +181,6 @@ function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible =
|
||||
]
|
||||
}
|
||||
|
||||
interface SolitaireEntry {
|
||||
index: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface SolitaireContent {
|
||||
title: string
|
||||
introLines: string[]
|
||||
entries: SolitaireEntry[]
|
||||
}
|
||||
|
||||
function parseSolitaireContent(rawTitle: string): SolitaireContent {
|
||||
const lines = String(rawTitle || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const title = lines[0] || '接龙'
|
||||
const introLines: string[] = []
|
||||
const entries: SolitaireEntry[] = []
|
||||
let hasStartedEntries = false
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
const entryMatch = /^(\d+)[..、]\s*(.+)$/.exec(line)
|
||||
if (entryMatch) {
|
||||
hasStartedEntries = true
|
||||
entries.push({
|
||||
index: entryMatch[1],
|
||||
text: entryMatch[2].trim()
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasStartedEntries && entries.length > 0) {
|
||||
const previous = entries[entries.length - 1]
|
||||
previous.text = `${previous.text} ${line}`.trim()
|
||||
} else {
|
||||
introLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return { title, introLines, entries }
|
||||
}
|
||||
|
||||
function composeGlobalMsgSearchResults(
|
||||
seedMap: Map<string, GlobalMsgSearchResult[]>,
|
||||
authoritativeMap: Map<string, GlobalMsgSearchResult[]>
|
||||
@@ -1103,13 +1058,6 @@ const SessionItem = React.memo(function SessionItem({
|
||||
</div>
|
||||
<div className="session-bottom">
|
||||
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
|
||||
<div className="session-badges">
|
||||
{session.unreadCount > 0 && (
|
||||
<span className="unread-badge">
|
||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5101,37 +5049,24 @@ function ChatPage(props: ChatPageProps) {
|
||||
return []
|
||||
}
|
||||
|
||||
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
||||
|
||||
// 检查是否有折叠的群聊
|
||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||
const hasFoldedGroups = foldedGroups.length > 0
|
||||
|
||||
let visible = sessions.filter(s => {
|
||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||
if (s.username.startsWith('gh_')) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
||||
if (!latest) return current
|
||||
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||
return currentTime > latestTime ? current : latest
|
||||
}, null)
|
||||
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
||||
|
||||
const bizEntry: ChatSession = {
|
||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||
displayName: '公众号',
|
||||
summary: latestOfficial
|
||||
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
||||
: '查看公众号历史消息',
|
||||
summary: '查看公众号历史消息',
|
||||
type: 0,
|
||||
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
|
||||
lastMsgType: latestOfficial?.lastMsgType || 0,
|
||||
unreadCount: officialUnreadCount,
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
unreadCount: 0,
|
||||
isMuted: false,
|
||||
isFolded: false
|
||||
}
|
||||
@@ -7870,7 +7805,6 @@ function MessageBubble({
|
||||
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
||||
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
|
||||
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
|
||||
const [solitaireExpanded, setSolitaireExpanded] = useState(false)
|
||||
const senderProfileRequestSeqRef = useRef(0)
|
||||
const [emojiError, setEmojiError] = useState(false)
|
||||
const [emojiLoading, setEmojiLoading] = useState(false)
|
||||
@@ -9479,71 +9413,6 @@ function MessageBubble({
|
||||
)
|
||||
}
|
||||
|
||||
if (xmlType === '53' || message.appMsgKind === 'solitaire') {
|
||||
const solitaireText = message.linkTitle || q('appmsg > title') || q('title') || cleanedParsedContent || '接龙'
|
||||
const solitaire = parseSolitaireContent(solitaireText)
|
||||
const previewEntries = solitaireExpanded ? solitaire.entries : solitaire.entries.slice(0, 3)
|
||||
const hiddenEntryCount = Math.max(0, solitaire.entries.length - previewEntries.length)
|
||||
const introLines = solitaireExpanded ? solitaire.introLines : solitaire.introLines.slice(0, 4)
|
||||
const hasMoreIntro = !solitaireExpanded && solitaire.introLines.length > introLines.length
|
||||
const countText = solitaire.entries.length > 0 ? `${solitaire.entries.length} 人参与` : '接龙消息'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`solitaire-message${solitaireExpanded ? ' expanded' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={solitaireExpanded}
|
||||
onClick={isSelectionMode ? undefined : (e) => {
|
||||
e.stopPropagation()
|
||||
setSolitaireExpanded(value => !value)
|
||||
}}
|
||||
onKeyDown={isSelectionMode ? undefined : (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSolitaireExpanded(value => !value)
|
||||
}}
|
||||
title={solitaireExpanded ? '点击收起接龙' : '点击展开接龙'}
|
||||
>
|
||||
<div className="solitaire-header">
|
||||
<div className="solitaire-icon" aria-hidden="true">
|
||||
<Hash size={18} />
|
||||
</div>
|
||||
<div className="solitaire-heading">
|
||||
<div className="solitaire-title">{solitaire.title}</div>
|
||||
<div className="solitaire-meta">{countText}</div>
|
||||
</div>
|
||||
</div>
|
||||
{introLines.length > 0 && (
|
||||
<div className="solitaire-intro">
|
||||
{introLines.map((line, index) => (
|
||||
<div key={`${line}-${index}`} className="solitaire-intro-line">{line}</div>
|
||||
))}
|
||||
{hasMoreIntro && <div className="solitaire-muted-line">...</div>}
|
||||
</div>
|
||||
)}
|
||||
{previewEntries.length > 0 ? (
|
||||
<div className="solitaire-entry-list">
|
||||
{previewEntries.map(entry => (
|
||||
<div key={`${entry.index}-${entry.text}`} className="solitaire-entry">
|
||||
<span className="solitaire-entry-index">{entry.index}</span>
|
||||
<span className="solitaire-entry-text">{entry.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{hiddenEntryCount > 0 && (
|
||||
<div className="solitaire-muted-line">还有 {hiddenEntryCount} 条...</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="solitaire-footer">
|
||||
<span>{solitaireExpanded ? '收起接龙' : '展开接龙'}</span>
|
||||
<ChevronDown size={14} className="solitaire-chevron" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card'
|
||||
const desc = message.appMsgDesc || q('des')
|
||||
const url = message.linkUrl || q('url')
|
||||
|
||||
@@ -1105,42 +1105,21 @@ const clampExportSelectionToBounds = (
|
||||
): ExportDateRangeSelection => {
|
||||
if (!bounds) return cloneExportDateRangeSelection(selection)
|
||||
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (selection.preset === 'custom' && !selection.useAllTime) {
|
||||
const { start, end } = selection.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
...selection,
|
||||
dateRange: { start, end: start }
|
||||
}
|
||||
}
|
||||
return cloneExportDateRangeSelection(selection)
|
||||
}
|
||||
const boundedStart = startOfDay(bounds.minDate)
|
||||
const boundedEnd = endOfDay(bounds.maxDate)
|
||||
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
|
||||
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
|
||||
|
||||
// For useAllTime, use bounds directly
|
||||
if (selection.useAllTime) {
|
||||
return {
|
||||
preset: selection.preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: bounds.minDate,
|
||||
end: bounds.maxDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For preset selections (not custom), clamp dates to bounds and use default times
|
||||
const boundedStart = new Date(Math.min(Math.max(selection.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const boundedEnd = new Date(Math.min(Math.max(selection.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
// Use default times: start at 00:00, end at 23:59:59
|
||||
boundedStart.setHours(0, 0, 0, 0)
|
||||
boundedEnd.setHours(23, 59, 59, 999)
|
||||
return {
|
||||
preset: selection.preset,
|
||||
useAllTime: false,
|
||||
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
||||
useAllTime: selection.useAllTime,
|
||||
dateRange: {
|
||||
start: boundedStart,
|
||||
end: boundedEnd
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6887,7 +6866,6 @@ function ExportPage() {
|
||||
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
|
||||
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
|
||||
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
|
||||
const resolvedAvatarUrl = normalizeExportAvatarUrl(matchedSession?.avatarUrl || contact.avatarUrl)
|
||||
const rowClassName = [
|
||||
'contact-row',
|
||||
checked ? 'selected' : '',
|
||||
@@ -6911,7 +6889,7 @@ function ExportPage() {
|
||||
</div>
|
||||
<div className="contact-avatar">
|
||||
<Avatar
|
||||
src={resolvedAvatarUrl}
|
||||
src={normalizeExportAvatarUrl(contact.avatarUrl)}
|
||||
name={contact.displayName}
|
||||
size="100%"
|
||||
shape="rounded"
|
||||
|
||||
@@ -2349,24 +2349,6 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-panel-action {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary));
|
||||
}
|
||||
}
|
||||
|
||||
.filter-panel-list {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
@@ -2430,16 +2412,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item-type {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filter-item-action {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
@@ -2449,36 +2421,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.push-filter-type-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.push-filter-type-tab {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.filter-panel-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import type { ContactInfo } from '../types/models'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
@@ -72,25 +71,6 @@ interface WxidOption {
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
type SessionFilterType = configService.MessagePushSessionType
|
||||
type SessionFilterTypeValue = 'all' | SessionFilterType
|
||||
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
|
||||
|
||||
interface SessionFilterOption {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
type: SessionFilterType
|
||||
}
|
||||
|
||||
const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: string }> = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'private', label: '私聊' },
|
||||
{ value: 'group', label: '群聊' },
|
||||
{ value: 'official', label: '订阅号/服务号' },
|
||||
{ value: 'other', label: '其他/非好友' }
|
||||
]
|
||||
|
||||
interface SettingsPageProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -190,7 +170,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||
const [notificationTypeFilter, setNotificationTypeFilter] = useState<SessionFilterTypeValue>('all')
|
||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||
@@ -246,12 +225,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||
const [messagePushFilterMode, setMessagePushFilterMode] = useState<configService.MessagePushFilterMode>('all')
|
||||
const [messagePushFilterList, setMessagePushFilterList] = useState<string[]>([])
|
||||
const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false)
|
||||
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
|
||||
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<SessionFilterTypeValue>('all')
|
||||
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
|
||||
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||
@@ -267,6 +240,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('')
|
||||
const [aiModelApiKey, setAiModelApiKey] = useState('')
|
||||
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 [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
|
||||
const [isTestingInsight, setIsTestingInsight] = useState(false)
|
||||
@@ -383,16 +366,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setFilterModeDropdownOpen(false)
|
||||
setPositionDropdownOpen(false)
|
||||
setCloseBehaviorDropdownOpen(false)
|
||||
setMessagePushFilterDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
|
||||
|
||||
|
||||
const loadConfig = async () => {
|
||||
@@ -415,9 +397,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||
const savedMessagePushFilterMode = await configService.getMessagePushFilterMode()
|
||||
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
|
||||
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
||||
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||
@@ -468,11 +447,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setNotificationFilterMode(savedNotificationFilterMode)
|
||||
setNotificationFilterList(savedNotificationFilterList)
|
||||
setMessagePushEnabled(savedMessagePushEnabled)
|
||||
setMessagePushFilterMode(savedMessagePushFilterMode)
|
||||
setMessagePushFilterList(savedMessagePushFilterList)
|
||||
if (contactsResult.success && Array.isArray(contactsResult.contacts)) {
|
||||
setMessagePushContactOptions(contactsResult.contacts as ContactInfo[])
|
||||
}
|
||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||
@@ -515,6 +489,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl()
|
||||
const savedAiModelApiKey = await configService.getAiModelApiKey()
|
||||
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 savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
|
||||
@@ -532,6 +516,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
||||
setAiModelApiKey(savedAiModelApiKey)
|
||||
setAiModelApiModel(savedAiModelApiModel)
|
||||
setAiAgentMaxMessagesPerRequest(savedAiAgentMaxMessagesPerRequest)
|
||||
setAiAgentMaxHistoryRounds(savedAiAgentMaxHistoryRounds)
|
||||
setAiAgentEnableAutoSkill(savedAiAgentEnableAutoSkill)
|
||||
setAiAgentSearchContextBefore(savedAiAgentSearchContextBefore)
|
||||
setAiAgentSearchContextAfter(savedAiAgentSearchContextAfter)
|
||||
setAiAgentPreprocessClean(savedAiAgentPreprocessClean)
|
||||
setAiAgentPreprocessMerge(savedAiAgentPreprocessMerge)
|
||||
setAiAgentPreprocessDenoise(savedAiAgentPreprocessDenoise)
|
||||
setAiAgentPreprocessDesensitize(savedAiAgentPreprocessDesensitize)
|
||||
setAiAgentPreprocessAnonymize(savedAiAgentPreprocessAnonymize)
|
||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
|
||||
@@ -1222,13 +1216,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
||||
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
|
||||
} else {
|
||||
if (
|
||||
result.error?.includes('未找到微信安装路径') ||
|
||||
result.error?.includes('启动微信失败') ||
|
||||
result.error?.includes('未能自动启动微信') ||
|
||||
result.error?.includes('未找到微信进程') ||
|
||||
result.error?.includes('微信进程未运行')
|
||||
) {
|
||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||
setIsManualStartPrompt(true)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
@@ -1684,6 +1672,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
|
||||
const renderNotificationTab = () => {
|
||||
// 获取已过滤会话的信息
|
||||
const getSessionInfo = (username: string) => {
|
||||
const session = chatSessions.find(s => s.username === username)
|
||||
return {
|
||||
displayName: session?.displayName || username,
|
||||
avatarUrl: session?.avatarUrl || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 添加会话到过滤列表
|
||||
const handleAddToFilterList = async (username: string) => {
|
||||
if (notificationFilterList.includes(username)) return
|
||||
@@ -1701,6 +1698,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
showMessage('已从过滤列表移除', true)
|
||||
}
|
||||
|
||||
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
||||
const availableSessions = chatSessions.filter(s => {
|
||||
if (notificationFilterList.includes(s.username)) return false
|
||||
if (filterSearchKeyword) {
|
||||
const keyword = filterSearchKeyword.toLowerCase()
|
||||
const displayName = (s.displayName || '').toLowerCase()
|
||||
const username = s.username.toLowerCase()
|
||||
return displayName.includes(keyword) || username.includes(keyword)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -1792,7 +1801,17 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
|
||||
onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }}
|
||||
onClick={async () => {
|
||||
const val = option.value as 'all' | 'whitelist' | 'blacklist'
|
||||
setNotificationFilterMode(val)
|
||||
setFilterModeDropdownOpen(false)
|
||||
await configService.setNotificationFilterMode(val)
|
||||
showMessage(
|
||||
val === 'all' ? '已设为接收所有通知' :
|
||||
val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
|
||||
true
|
||||
)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
{notificationFilterMode === option.value && <Check size={14} />}
|
||||
@@ -1811,33 +1830,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
|
||||
</span>
|
||||
|
||||
<div className="push-filter-type-tabs">
|
||||
{sessionFilterTypeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`push-filter-type-tab ${notificationTypeFilter === option.value ? 'active' : ''}`}
|
||||
onClick={() => setNotificationTypeFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="notification-filter-container">
|
||||
{/* 可选会话列表 */}
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>可选会话</span>
|
||||
{notificationAvailableSessions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-panel-action"
|
||||
onClick={() => { void handleAddAllNotificationFilterSessions() }}
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
@@ -1849,8 +1846,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{notificationAvailableSessions.length > 0 ? (
|
||||
notificationAvailableSessions.map(session => (
|
||||
{availableSessions.length > 0 ? (
|
||||
availableSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className="filter-panel-item"
|
||||
@@ -1862,13 +1859,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{session.displayName || session.username}</span>
|
||||
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
|
||||
<span className="filter-item-action">+</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="filter-panel-empty">
|
||||
{filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1881,20 +1877,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{notificationFilterList.length > 0 && (
|
||||
<span className="filter-panel-count">{notificationFilterList.length}</span>
|
||||
)}
|
||||
{notificationFilterList.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-panel-action"
|
||||
onClick={() => { void handleRemoveAllNotificationFilterSessions() }}
|
||||
>
|
||||
全不选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{notificationFilterList.length > 0 ? (
|
||||
notificationFilterList.map(username => {
|
||||
const info = getSessionFilterOptionInfo(username)
|
||||
const info = getSessionInfo(username)
|
||||
return (
|
||||
<div
|
||||
key={username}
|
||||
@@ -1907,7 +1894,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{info.displayName}</span>
|
||||
<span className="filter-item-type">{getSessionFilterTypeLabel(info.type)}</span>
|
||||
<span className="filter-item-action">×</span>
|
||||
</div>
|
||||
)
|
||||
@@ -2152,9 +2138,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认</p>
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleManualConfirm}>
|
||||
我已看到登录窗口,继续检测
|
||||
我已启动微信,继续检测
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2561,163 +2547,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
|
||||
}
|
||||
|
||||
const getSessionFilterType = (session: { username: string; type?: ContactInfo['type'] | number }): SessionFilterType => {
|
||||
const username = String(session.username || '').trim()
|
||||
if (username.endsWith('@chatroom')) return 'group'
|
||||
if (username.startsWith('gh_') || session.type === 'official') return 'official'
|
||||
if (username.toLowerCase().includes('placeholder_foldgroup')) return 'other'
|
||||
if (session.type === 'former_friend' || session.type === 'other') return 'other'
|
||||
return 'private'
|
||||
}
|
||||
|
||||
const getSessionFilterTypeLabel = (type: SessionFilterType) => {
|
||||
switch (type) {
|
||||
case 'private': return '私聊'
|
||||
case 'group': return '群聊'
|
||||
case 'official': return '订阅号/服务号'
|
||||
default: return '其他/非好友'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetMessagePushFilterMode = async (mode: configService.MessagePushFilterMode) => {
|
||||
setMessagePushFilterMode(mode)
|
||||
setMessagePushFilterDropdownOpen(false)
|
||||
await configService.setMessagePushFilterMode(mode)
|
||||
showMessage(
|
||||
mode === 'all' ? '主动推送已设为接收所有会话' :
|
||||
mode === 'whitelist' ? '主动推送已设为仅推送白名单' : '主动推送已设为屏蔽黑名单',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddMessagePushFilterSession = async (username: string) => {
|
||||
if (messagePushFilterList.includes(username)) return
|
||||
const next = [...messagePushFilterList, username]
|
||||
setMessagePushFilterList(next)
|
||||
await configService.setMessagePushFilterList(next)
|
||||
showMessage('已添加到主动推送过滤列表', true)
|
||||
}
|
||||
|
||||
const handleRemoveMessagePushFilterSession = async (username: string) => {
|
||||
const next = messagePushFilterList.filter(item => item !== username)
|
||||
setMessagePushFilterList(next)
|
||||
await configService.setMessagePushFilterList(next)
|
||||
showMessage('已从主动推送过滤列表移除', true)
|
||||
}
|
||||
|
||||
const handleAddAllMessagePushFilterSessions = async () => {
|
||||
const usernames = messagePushAvailableSessions.map(session => session.username)
|
||||
if (usernames.length === 0) return
|
||||
const next = Array.from(new Set([...messagePushFilterList, ...usernames]))
|
||||
setMessagePushFilterList(next)
|
||||
await configService.setMessagePushFilterList(next)
|
||||
showMessage(`已添加 ${usernames.length} 个会话`, true)
|
||||
}
|
||||
|
||||
const handleRemoveAllMessagePushFilterSessions = async () => {
|
||||
if (messagePushFilterList.length === 0) return
|
||||
setMessagePushFilterList([])
|
||||
await configService.setMessagePushFilterList([])
|
||||
showMessage('已清空主动推送过滤列表', true)
|
||||
}
|
||||
|
||||
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
|
||||
|
||||
for (const session of chatSessions) {
|
||||
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
sessionFilterOptionMap.set(session.username, {
|
||||
username: session.username,
|
||||
displayName: session.displayName || session.username,
|
||||
avatarUrl: session.avatarUrl,
|
||||
type: getSessionFilterType(session)
|
||||
})
|
||||
}
|
||||
|
||||
for (const contact of messagePushContactOptions) {
|
||||
if (!contact.username) continue
|
||||
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
|
||||
const existing = sessionFilterOptionMap.get(contact.username)
|
||||
sessionFilterOptionMap.set(contact.username, {
|
||||
username: contact.username,
|
||||
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
|
||||
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
|
||||
type: getSessionFilterType(contact)
|
||||
})
|
||||
}
|
||||
|
||||
const sessionFilterOptions = Array.from(sessionFilterOptionMap.values())
|
||||
.sort((a, b) => {
|
||||
const aSession = chatSessions.find(session => session.username === a.username)
|
||||
const bSession = chatSessions.find(session => session.username === b.username)
|
||||
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
|
||||
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
|
||||
})
|
||||
|
||||
const getSessionFilterOptionInfo = (username: string) => {
|
||||
return sessionFilterOptionMap.get(username) || {
|
||||
username,
|
||||
displayName: username,
|
||||
avatarUrl: undefined,
|
||||
type: 'other' as SessionFilterType
|
||||
}
|
||||
}
|
||||
|
||||
const getAvailableSessionFilterOptions = (
|
||||
selectedList: string[],
|
||||
typeFilter: SessionFilterTypeValue,
|
||||
searchKeyword: string
|
||||
) => {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
return sessionFilterOptions.filter(session => {
|
||||
if (selectedList.includes(session.username)) return false
|
||||
if (typeFilter !== 'all' && session.type !== typeFilter) return false
|
||||
if (keyword) {
|
||||
return String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||
session.username.toLowerCase().includes(keyword)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const notificationAvailableSessions = getAvailableSessionFilterOptions(
|
||||
notificationFilterList,
|
||||
notificationTypeFilter,
|
||||
filterSearchKeyword
|
||||
)
|
||||
|
||||
const messagePushAvailableSessions = getAvailableSessionFilterOptions(
|
||||
messagePushFilterList,
|
||||
messagePushTypeFilter,
|
||||
messagePushFilterSearchKeyword
|
||||
)
|
||||
|
||||
const handleAddAllNotificationFilterSessions = async () => {
|
||||
const usernames = notificationAvailableSessions.map(session => session.username)
|
||||
if (usernames.length === 0) return
|
||||
const next = Array.from(new Set([...notificationFilterList, ...usernames]))
|
||||
setNotificationFilterList(next)
|
||||
await configService.setNotificationFilterList(next)
|
||||
showMessage(`已添加 ${usernames.length} 个会话`, true)
|
||||
}
|
||||
|
||||
const handleRemoveAllNotificationFilterSessions = async () => {
|
||||
if (notificationFilterList.length === 0) return
|
||||
setNotificationFilterList([])
|
||||
await configService.setNotificationFilterList([])
|
||||
showMessage('已清空通知过滤列表', true)
|
||||
}
|
||||
|
||||
const handleSetNotificationFilterMode = async (mode: SessionFilterMode) => {
|
||||
setNotificationFilterMode(mode)
|
||||
setFilterModeDropdownOpen(false)
|
||||
await configService.setNotificationFilterMode(mode)
|
||||
showMessage(
|
||||
mode === 'all' ? '已设为接收所有通知' :
|
||||
mode === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
const handleTestInsightConnection = async () => {
|
||||
setIsTestingInsight(true)
|
||||
setInsightTestResult(null)
|
||||
@@ -2815,6 +2644,113 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
/>
|
||||
</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">
|
||||
<label>连接测试</label>
|
||||
<span className="form-hint">
|
||||
@@ -3551,154 +3487,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>推送会话过滤</label>
|
||||
<span className="form-hint">选择只推送特定会话,或屏蔽特定会话</span>
|
||||
<div className="custom-select">
|
||||
<div
|
||||
className={`custom-select-trigger ${messagePushFilterDropdownOpen ? 'open' : ''}`}
|
||||
onClick={() => setMessagePushFilterDropdownOpen(!messagePushFilterDropdownOpen)}
|
||||
>
|
||||
<span className="custom-select-value">
|
||||
{messagePushFilterMode === 'all' ? '推送所有会话' :
|
||||
messagePushFilterMode === 'whitelist' ? '仅推送白名单' : '屏蔽黑名单'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${messagePushFilterDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${messagePushFilterDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{ value: 'all', label: '推送所有会话' },
|
||||
{ value: 'whitelist', label: '仅推送白名单' },
|
||||
{ value: 'blacklist', label: '屏蔽黑名单' }
|
||||
].map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${messagePushFilterMode === option.value ? 'selected' : ''}`}
|
||||
onClick={() => { void handleSetMessagePushFilterMode(option.value as configService.MessagePushFilterMode) }}
|
||||
>
|
||||
{option.label}
|
||||
{messagePushFilterMode === option.value && <Check size={14} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messagePushFilterMode !== 'all' && (
|
||||
<div className="form-group">
|
||||
<label>{messagePushFilterMode === 'whitelist' ? '主动推送白名单' : '主动推送黑名单'}</label>
|
||||
<span className="form-hint">
|
||||
{messagePushFilterMode === 'whitelist'
|
||||
? '点击左侧会话添加到白名单,只有白名单会话会推送'
|
||||
: '点击左侧会话添加到黑名单,黑名单会话不会推送'}
|
||||
</span>
|
||||
<div className="push-filter-type-tabs">
|
||||
{sessionFilterTypeOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`push-filter-type-tab ${messagePushTypeFilter === option.value ? 'active' : ''}`}
|
||||
onClick={() => setMessagePushTypeFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="notification-filter-container">
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>可选会话</span>
|
||||
{messagePushAvailableSessions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-panel-action"
|
||||
onClick={() => { void handleAddAllMessagePushFilterSessions() }}
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索会话..."
|
||||
value={messagePushFilterSearchKeyword}
|
||||
onChange={(e) => setMessagePushFilterSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{messagePushAvailableSessions.length > 0 ? (
|
||||
messagePushAvailableSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className="filter-panel-item"
|
||||
onClick={() => { void handleAddMessagePushFilterSession(session.username) }}
|
||||
>
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || session.username}
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{session.displayName || session.username}</span>
|
||||
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
|
||||
<span className="filter-item-action">+</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="filter-panel-empty">
|
||||
{messagePushFilterSearchKeyword || messagePushTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>{messagePushFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
|
||||
{messagePushFilterList.length > 0 && (
|
||||
<span className="filter-panel-count">{messagePushFilterList.length}</span>
|
||||
)}
|
||||
{messagePushFilterList.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-panel-action"
|
||||
onClick={() => { void handleRemoveAllMessagePushFilterSessions() }}
|
||||
>
|
||||
全不选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{messagePushFilterList.length > 0 ? (
|
||||
messagePushFilterList.map(username => {
|
||||
const session = getSessionFilterOptionInfo(username)
|
||||
return (
|
||||
<div
|
||||
key={username}
|
||||
className="filter-panel-item selected"
|
||||
onClick={() => { void handleRemoveMessagePushFilterSession(username) }}
|
||||
>
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || username}
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{session.displayName || username}</span>
|
||||
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
|
||||
<span className="filter-item-action">×</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="filter-panel-empty">尚未添加任何会话</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>推送地址</label>
|
||||
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||
@@ -3733,7 +3521,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||
<div className="api-params">
|
||||
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
||||
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
||||
<span key={param} className="param">
|
||||
<code>{param}</code>
|
||||
</span>
|
||||
|
||||
@@ -368,13 +368,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setError('')
|
||||
await handleScanWxid(true)
|
||||
} else {
|
||||
if (
|
||||
result.error?.includes('未找到微信安装路径') ||
|
||||
result.error?.includes('启动微信失败') ||
|
||||
result.error?.includes('未能自动启动微信') ||
|
||||
result.error?.includes('未找到微信进程') ||
|
||||
result.error?.includes('微信进程未运行')
|
||||
) {
|
||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||
setIsManualStartPrompt(true)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
@@ -850,9 +844,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div className="key-actions">
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p>未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认</p>
|
||||
<p>未能自动启动微信,请手动启动并登录</p>
|
||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||
我已看到登录窗口,继续
|
||||
我已登录,继续
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -72,8 +72,6 @@ export const CONFIG_KEYS = {
|
||||
HTTP_API_PORT: 'httpApiPort',
|
||||
HTTP_API_HOST: 'httpApiHost',
|
||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||
MESSAGE_PUSH_FILTER_MODE: 'messagePushFilterMode',
|
||||
MESSAGE_PUSH_FILTER_LIST: 'messagePushFilterList',
|
||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||
QUOTE_LAYOUT: 'quoteLayout',
|
||||
|
||||
@@ -88,6 +86,16 @@ export const CONFIG_KEYS = {
|
||||
AI_MODEL_API_BASE_URL: 'aiModelApiBaseUrl',
|
||||
AI_MODEL_API_KEY: 'aiModelApiKey',
|
||||
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_API_BASE_URL: 'aiInsightApiBaseUrl',
|
||||
AI_INSIGHT_API_KEY: 'aiInsightApiKey',
|
||||
@@ -1507,29 +1515,6 @@ export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export type MessagePushFilterMode = 'all' | 'whitelist' | 'blacklist'
|
||||
export type MessagePushSessionType = 'private' | 'group' | 'official' | 'other'
|
||||
|
||||
export async function getMessagePushFilterMode(): Promise<MessagePushFilterMode> {
|
||||
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE)
|
||||
if (value === 'whitelist' || value === 'blacklist') return value
|
||||
return 'all'
|
||||
}
|
||||
|
||||
export async function setMessagePushFilterMode(mode: MessagePushFilterMode): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE, mode)
|
||||
}
|
||||
|
||||
export async function getMessagePushFilterList(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST)
|
||||
return Array.isArray(value) ? value.map(item => String(item || '').trim()).filter(Boolean) : []
|
||||
}
|
||||
|
||||
export async function setMessagePushFilterList(list: string[]): Promise<void> {
|
||||
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
|
||||
await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST, normalized)
|
||||
}
|
||||
|
||||
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||
if (value === 'tray' || value === 'quit') return value
|
||||
@@ -1651,6 +1636,100 @@ export async function setAiModelApiModel(model: string): Promise<void> {
|
||||
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> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED)
|
||||
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']
|
||||
}
|
||||
|
||||
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 {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -482,6 +513,32 @@ export interface ElectronAPI {
|
||||
filePath?: 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
|
||||
}
|
||||
biz: {
|
||||
@@ -1093,6 +1150,372 @@ export interface ElectronAPI {
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => 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 {
|
||||
@@ -1149,6 +1572,11 @@ export interface WxidInfo {
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
aiApi: ElectronAPI['aiApi']
|
||||
agentApi: ElectronAPI['agentApi']
|
||||
assistantApi: ElectronAPI['assistantApi']
|
||||
skillApi: ElectronAPI['skillApi']
|
||||
llmApi: ElectronAPI['llmApi']
|
||||
}
|
||||
|
||||
// Electron 类型声明
|
||||
|
||||
@@ -138,24 +138,19 @@ export const formatDateInputValue = (date: Date): string => {
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
const h = `${date.getHours()}`.padStart(2, '0')
|
||||
const min = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${h}:${min}`
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export const parseDateInputValue = (raw: string): Date | null => {
|
||||
const text = String(raw || '').trim()
|
||||
const matched = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?$/.exec(text)
|
||||
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
|
||||
if (!matched) return null
|
||||
const year = Number(matched[1])
|
||||
const month = Number(matched[2])
|
||||
const day = Number(matched[3])
|
||||
const hour = matched[4] !== undefined ? Number(matched[4]) : 0
|
||||
const minute = matched[5] !== undefined ? Number(matched[5]) : 0
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
|
||||
const parsed = new Date(year, month - 1, day, hour, minute, 0, 0)
|
||||
const parsed = new Date(year, month - 1, day)
|
||||
if (
|
||||
parsed.getFullYear() !== year ||
|
||||
parsed.getMonth() !== month - 1 ||
|
||||
@@ -296,14 +291,14 @@ export const resolveExportDateRangeConfig = (
|
||||
const parsedStart = parseStoredDate(raw.start)
|
||||
const parsedEnd = parseStoredDate(raw.end)
|
||||
if (parsedStart && parsedEnd) {
|
||||
const start = parsedStart
|
||||
const end = parsedEnd
|
||||
const start = startOfDay(parsedStart)
|
||||
const end = endOfDay(parsedEnd)
|
||||
return {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: end < start ? start : end
|
||||
end: end < start ? endOfDay(start) : end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user