Compare commits

..

27 Commits

Author SHA1 Message Date
cc
e79d18da03 Merge pull request #733 from hicccc77/dev
Update release.yml
2026-04-12 14:46:41 +08:00
cc
69a598f196 Update release.yml 2026-04-12 14:46:17 +08:00
cc
ac84606f20 Merge pull request #732 from hicccc77/dev
Dev
2026-04-12 14:22:42 +08:00
cc
b086507569 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-12 14:19:51 +08:00
cc
360f4917b1 更新提示文案 2026-04-12 14:19:46 +08:00
H3CoF6
89d0f22dac Merge pull request #731 from H3CoF6/fix/linux-key
适配更多wechat路径,优化拉起失败提示
2026-04-12 13:37:01 +08:00
H3CoF6
f4d63d01bd 适配更多wechat路径,优化拉起失败提示 2026-04-12 13:27:18 +08:00
xuncha
48ca54a856 Merge pull request #721 from xunchahaha/dev
Dev
2026-04-12 08:39:41 +08:00
xuncha
bf3dfbba0f Merge branch 'dev' into dev 2026-04-12 08:37:03 +08:00
xuncha
bd1bd8a8aa Merge pull request #716 from zgshe/feature/export-date-range-time-picker-v2
feat(export): 导出日期范围添加时间选择功能
2026-04-12 08:36:32 +08:00
xuncha
7e1ca95bef 修复导出页头像丢失 2026-04-12 08:36:13 +08:00
xuncha
b7cb2cd42d 优化了选择会话逻辑 2026-04-12 08:20:54 +08:00
xuncha
6359123323 优化了接龙的消息样式 2026-04-12 08:11:20 +08:00
xuncha
f2f78bb4e2 实现了服务号的推送以及未读 2026-04-12 08:03:12 +08:00
xuncha
716b21b0dd Merge branch 'dev' into feature/export-date-range-time-picker-v2 2026-04-12 07:26:00 +08:00
xuncha
cde3590986 优化一下ui 2026-04-12 07:10:59 +08:00
H3CoF6
f89ad6ec15 修release action
fix: 修复yml空格错误
2026-04-12 00:55:57 +08:00
H3CoF6
4efa169313 Merge remote-tracking branch 'upstream/dev' 2026-04-12 00:52:29 +08:00
H3CoF6
933912f15d fix: 修复yml空格的bug 2026-04-12 00:50:52 +08:00
H3CoF6
4e216ce036 Merge pull request #718 from H3CoF6/main
修复linux资源/打包和aur更新
2026-04-11 23:55:48 +08:00
H3CoF6
567fcd3683 Auto update aur release 2026-04-11 23:27:33 +08:00
H3CoF6
49ab0de7b3 release action 为linux文件添加可执行权 2026-04-11 22:59:20 +08:00
H3CoF6
0f34222954 chore: update xkey for linux 2026-04-11 22:53:38 +08:00
佘志高
caf5b0c9db fix(export): 统一时间输入框字体与日期输入框一致 2026-04-11 22:18:03 +08:00
佘志高
f2d6188c53 feat(export): 导出日期范围添加时间选择功能
为导出窗口的日期范围选择器添加了时间(HH:mm)选择功能:

- 在日期输入框下方添加了时间选择控件(type="time")
- 默认时间范围:开始 00:00,结束 23:59
- 支持精确到分钟的时间范围设置
- 预设类型(今天、昨天、最近7天等)默认使用 00:00-23:59
- 自定义时间范围保留用户设置的具体时间
- 添加了结束时间不能早于开始时间的验证

修改文件:
- src/utils/exportDateRange.ts - 支持 YYYY-MM-DD HH:mm 格式的解析和格式化
- src/components/Export/ExportDateRangeDialog.tsx - 添加时间选择 UI 和逻辑
- src/components/Export/ExportDateRangeDialog.scss - 时间输入框样式
- src/pages/ExportPage.tsx - 修复 preset 类型的默认时间不正确的 bug
2026-04-11 22:00:32 +08:00
cc
5bec4f3cd6 Merge pull request #713 from hicccc77/dev
Dev
2026-04-11 17:15:22 +08:00
cc
56b767ff46 Merge pull request #705 from hicccc77/dev
Dev
2026-04-10 21:08:43 +08:00
50 changed files with 1821 additions and 9745 deletions

View File

@@ -104,6 +104,11 @@ 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: |
@@ -311,3 +316,22 @@ 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

View File

@@ -31,10 +31,6 @@ 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'
// 配置自动更新
@@ -1602,14 +1598,6 @@ 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()
@@ -1663,305 +1651,6 @@ 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)

View File

@@ -276,13 +276,6 @@ 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)
@@ -547,337 +540,5 @@ 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')
}
})

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export interface BizAccount {
type: number
last_time: number
formatted_last_time: string
unread_count?: number
}
export interface BizMessage {
@@ -104,19 +105,24 @@ export class BizService {
if (!root || !accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
try {
const sessionsRes = await wcdbService.getSessions()
const sessionsRes = await chatService.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.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const timeStr = session.lastTimestamp || session.sortTimestamp || 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) {
@@ -152,7 +158,8 @@ export class BizService {
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime)
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
}
})

View File

@@ -174,36 +174,6 @@ 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
@@ -262,6 +232,16 @@ 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
@@ -408,6 +388,7 @@ 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
@@ -895,6 +876,10 @@ 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 }
@@ -904,6 +889,242 @@ 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 }>
@@ -1844,6 +2065,9 @@ 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}`
)
@@ -4446,6 +4670,8 @@ 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':
@@ -4475,6 +4701,8 @@ class ChatService {
return '[链接]'
case '87':
return '[群公告]'
case '53':
return '[接龙]'
default:
return '[消息]'
}
@@ -5074,6 +5302,8 @@ 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) {
@@ -8504,451 +8734,6 @@ 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)

View File

@@ -61,6 +61,8 @@ interface ConfigSchema {
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
messagePushFilterList: string[]
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
@@ -74,16 +76,6 @@ 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
@@ -187,6 +179,8 @@ export class ConfigService {
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
messagePushFilterMode: 'all',
messagePushFilterList: [],
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: [],
@@ -194,16 +188,6 @@ 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: '',

View File

@@ -2119,6 +2119,7 @@ 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
@@ -3220,6 +3221,8 @@ 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) {

View File

@@ -98,7 +98,12 @@ export class KeyServiceLinux {
'xwechat',
'/opt/wechat/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/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'
]
for (const binName of wechatBins) {
@@ -152,7 +157,7 @@ export class KeyServiceLinux {
}
if (!pid) {
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动并登录。'
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
onStatus?.(err, 2)
return { success: false, error: err }
}

View File

@@ -555,7 +555,19 @@ export class KeyServiceMac {
if (code === 'HOOK_TARGET_ONLY') {
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
}
if (code === 'SCAN_FAILED') return '内存扫描失败'
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}`
}
return '未知错误'
}

View File

@@ -11,6 +11,7 @@ interface SessionBaseline {
interface MessagePushPayload {
event: 'message.new'
sessionId: string
sessionType: 'private' | 'group' | 'official' | 'other'
messageKey: string
avatarUrl?: string
sourceName: string
@@ -20,6 +21,8 @@ interface MessagePushPayload {
const PUSH_CONFIG_KEYS = new Set([
'messagePushEnabled',
'messagePushFilterMode',
'messagePushFilterList',
'dbPath',
'decryptKey',
'myWxid'
@@ -38,6 +41,7 @@ class MessagePushService {
private rerunRequested = false
private started = false
private baselineReady = false
private messageTableScanRequested = false
constructor() {
this.configService = ConfigService.getInstance()
@@ -60,12 +64,15 @@ class MessagePushService {
payload = null
}
const tableName = String(payload?.table || '').trim().toLowerCase()
if (tableName && tableName !== 'session') {
const tableName = String(payload?.table || '').trim()
if (this.isSessionTableChange(tableName)) {
this.scheduleSync()
return
}
this.scheduleSync()
if (!tableName || this.isMessageTableChange(tableName)) {
this.scheduleSync({ scanMessageBackedSessions: true })
}
}
async handleConfigChanged(key: string): Promise<void> {
@@ -91,6 +98,7 @@ class MessagePushService {
this.recentMessageKeys.clear()
this.groupNicknameCache.clear()
this.baselineReady = false
this.messageTableScanRequested = false
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
@@ -121,7 +129,11 @@ class MessagePushService {
this.baselineReady = true
}
private scheduleSync(): void {
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
if (options.scanMessageBackedSessions) {
this.messageTableScanRequested = true
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
@@ -141,6 +153,8 @@ 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) {
@@ -163,27 +177,47 @@ class MessagePushService {
const previousBaseline = new Map(this.sessionBaseline)
this.setBaseline(sessions)
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
const candidates = sessions.filter((session) => {
const previous = previousBaseline.get(session.username)
if (this.shouldInspectSession(previous, session)) {
return true
}
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
})
for (const session of candidates) {
await this.pushSessionMessages(session, previousBaseline.get(session.username))
await this.pushSessionMessages(
session,
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
)
}
} finally {
this.processing = false
if (this.rerunRequested) {
this.rerunRequested = false
this.scheduleSync()
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
}
}
}
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) {
this.sessionBaseline.set(session.username, {
lastTimestamp: Number(session.lastTimestamp || 0),
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),
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 {
@@ -204,16 +238,30 @@ class MessagePushService {
return unreadCount > 0 && lastTimestamp > 0
}
if (lastTimestamp <= previous.lastTimestamp) {
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')) {
return false
}
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
return unreadCount > previous.unreadCount
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
}
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
return
@@ -224,7 +272,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
}
@@ -234,9 +282,11 @@ 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)
}
}
@@ -246,6 +296,7 @@ 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) {
@@ -255,6 +306,7 @@ class MessagePushService {
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
groupName,
@@ -267,6 +319,7 @@ class MessagePushService {
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId,
@@ -274,10 +327,84 @@ 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 message.rawContent || null
return cleanOfficialPrefix(message.rawContent || null)
case 3:
return '[图片]'
case 34:
@@ -287,13 +414,13 @@ class MessagePushService {
case 47:
return '[表情]'
case 42:
return message.cardNickname || '[名片]'
return cleanOfficialPrefix(message.cardNickname || '[名片]')
case 48:
return '[位置]'
case 49:
return message.linkTitle || message.fileName || '[消息]'
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
default:
return message.parsedContent || message.rawContent || null
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
}
}

View File

@@ -62,8 +62,6 @@ 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
@@ -87,19 +85,6 @@ 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
@@ -958,15 +943,6 @@ 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)')
@@ -974,15 +950,6 @@ 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)')
@@ -1093,71 +1060,6 @@ 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 {
@@ -3354,80 +3256,6 @@ 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 未连接' }
@@ -3490,83 +3318,6 @@ 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 未连接' }
@@ -3619,204 +3370,6 @@ export class WcdbCore {
}
}
private normalizeSqlIdentifier(name: string): string {
return `"${String(name || '').replace(/"/g, '""')}"`
}
private stripSqlComments(sql: string): string {
return String(sql || '')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/--[^\n\r]*/g, ' ')
.trim()
}
private isSqlLabReadOnly(sql: string): boolean {
const normalized = this.stripSqlComments(sql).trim()
if (!normalized) return false
if (normalized.includes('\u0000')) return false
const hasMultipleStatements = /;[\s\r\n]*\S/.test(normalized)
if (hasMultipleStatements) return false
const lower = normalized.toLowerCase()
if (/(insert|update|delete|drop|alter|create|attach|detach|replace|truncate|reindex|vacuum|analyze|begin|commit|rollback|savepoint|release)\b/.test(lower)) {
return false
}
if (/pragma\s+.*(writable_schema|journal_mode|locking_mode|foreign_keys)\s*=/.test(lower)) {
return false
}
return /^(select|with|pragma|explain)\b/.test(lower)
}
private async sqlLabListTablesForSource(
kind: 'message' | 'contact' | 'biz',
path: string | null,
maxTables: number = 60,
maxColumns: number = 120
): Promise<Array<{ name: string; columns: string[] }>> {
const tableRows = await this.execQuery(
kind,
path,
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT ${Math.max(1, maxTables)}`
)
if (!tableRows.success || !Array.isArray(tableRows.rows)) return []
const tables: Array<{ name: string; columns: string[] }> = []
for (const row of tableRows.rows) {
const tableName = String((row as any)?.name || '').trim()
if (!tableName) continue
const pragma = await this.execQuery(kind, path, `PRAGMA table_info(${this.normalizeSqlIdentifier(tableName)})`)
const columns = pragma.success && Array.isArray(pragma.rows)
? pragma.rows
.map((item: any) => String(item?.name || '').trim())
.filter(Boolean)
.slice(0, maxColumns)
: []
tables.push({ name: tableName, columns })
}
return tables
}
async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const sessionId = String(payload?.sessionId || '').trim()
const sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}> = []
if (sessionId) {
const tableStats = await this.getMessageTableStats(sessionId)
const tableEntries = tableStats.success && Array.isArray(tableStats.tables) ? tableStats.tables : []
const dbPathSet = new Set<string>()
for (const entry of tableEntries) {
const dbPath = String((entry as any)?.db_path || '').trim()
if (!dbPath) continue
dbPathSet.add(dbPath)
}
for (const dbPath of Array.from(dbPathSet).slice(0, 8)) {
sources.push({
kind: 'message',
path: dbPath,
label: dbPath.split(/[\\/]/).pop() || dbPath,
tables: await this.sqlLabListTablesForSource('message', dbPath)
})
}
} else {
const messageDbs = await this.listMessageDbs()
const paths = messageDbs.success && Array.isArray(messageDbs.data) ? messageDbs.data : []
for (const dbPath of paths.slice(0, 8)) {
sources.push({
kind: 'message',
path: dbPath,
label: dbPath.split(/[\\/]/).pop() || dbPath,
tables: await this.sqlLabListTablesForSource('message', dbPath)
})
}
}
sources.push({
kind: 'contact',
path: null,
label: 'contact',
tables: await this.sqlLabListTablesForSource('contact', null)
})
sources.push({
kind: 'biz',
path: null,
label: 'biz',
tables: await this.sqlLabListTablesForSource('biz', null)
})
const schemaText = sources
.map((source) => {
const tableLines = source.tables
.map((table) => `- ${table.name} (${table.columns.join(', ')})`)
.join('\n')
return `[${source.kind}] ${source.label}\n${tableLines}`
})
.join('\n\n')
return {
success: true,
schema: {
generatedAt: Date.now(),
sources
},
schemaText
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async sqlLabExecuteReadonly(payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}): Promise<{
success: boolean
rows?: any[]
columns?: string[]
total?: number
error?: string
}> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const sql = String(payload?.sql || '').trim()
if (!this.isSqlLabReadOnly(sql)) {
return { success: false, error: '仅允许只读 SQLSELECT/WITH/PRAGMA/EXPLAIN' }
}
const kind = payload?.kind === 'contact' || payload?.kind === 'biz' ? payload.kind : 'message'
const path = kind === 'message'
? (payload?.path == null ? null : String(payload.path))
: null
const limit = Math.max(1, Math.min(1000, Number(payload?.limit || 200)))
const sqlNoTail = sql.replace(/;+\s*$/, '')
const lower = sqlNoTail.toLowerCase()
const executable = /^(select|with)\b/.test(lower)
? `SELECT * FROM (${sqlNoTail}) LIMIT ${limit}`
: sqlNoTail
const result = await this.execQuery(kind, path, executable)
if (!result.success) {
return { success: false, error: result.error || '执行 SQL 失败' }
}
const rows = Array.isArray(result.rows) ? result.rows : []
return {
success: true,
rows,
columns: rows[0] && typeof rows[0] === 'object' ? Object.keys(rows[0] as Record<string, unknown>) : [],
total: rows.length
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -4426,347 +3979,6 @@ 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: '当前数据服务版本不支持获取朋友圈' }

View File

@@ -468,24 +468,6 @@ 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
})
}
/**
* 打开轻量级消息游标
*/
@@ -493,24 +475,6 @@ 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
})
}
/**
* 获取下一批消息
*/
@@ -525,44 +489,6 @@ 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/低频兼容)
*/
@@ -616,128 +542,6 @@ 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 })
}
/**
* 获取语音数据
*/

View File

@@ -164,41 +164,15 @@ 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
@@ -223,45 +197,6 @@ 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 Normal file → Executable file
View File

View File

@@ -6,7 +6,6 @@ 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'
@@ -680,7 +679,6 @@ 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 />} />

View File

@@ -192,6 +192,149 @@
}
}
.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;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
@@ -10,7 +10,6 @@ import {
createDateRangeByPreset,
createDefaultDateRange,
formatCalendarMonthTitle,
formatDateInputValue,
isSameDay,
parseDateInputValue,
startOfDay,
@@ -37,6 +36,10 @@ 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
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
const bounds = resolveBounds(minDate, maxDate)
if (!bounds) return cloneExportDateRangeSelection(value)
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()
// 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)
return {
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
useAllTime: value.useAllTime,
preset: value.preset,
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
@@ -95,62 +124,129 @@ 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: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end)
start: formatDateOnly(value.dateRange.start),
end: formatDateOnly(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: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
start: formatDateOnly(nextDraft.dateRange.start),
end: formatDateOnly(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: formatDateInputValue(draft.dateRange.start),
end: formatDateInputValue(draft.dateRange.end)
start: formatDateOnly(draft.dateRange.start),
end: formatDateOnly(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) => {
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
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
}, [bounds])
const clampEndDate = useCallback((targetDate: Date) => {
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
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
}, [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: nextEnd
end: prev.dateRange.end
},
panelMonth: toMonthStart(start)
}
@@ -161,14 +257,13 @@ 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: nextEnd
end: end
},
panelMonth: toMonthStart(targetDate)
}
@@ -180,6 +275,11 @@ 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,
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
useAllTime: false,
dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({
...prev,
preset,
@@ -206,25 +311,149 @@ 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 parsed = parseDateInputValue(dateInput.start)
if (!parsed) {
const parsedDate = parseDateInputValue(dateInput.start)
if (!parsedDate) {
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(parsed)
}, [dateInput.start, setRangeStart])
setRangeStart(parsedDate)
}, [dateInput.start, timeInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
if (!parsed) {
const parsedDate = parseDateInputValue(dateInput.end)
if (!parsedDate) {
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(parsed)
}, [dateInput.end, setRangeEnd])
setRangeEnd(parsedDate)
}, [dateInput.end, timeInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ({
@@ -234,30 +463,50 @@ 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') {
setRangeStart(targetDate)
const newStart = new Date(targetDate)
const time = parseTime(timeInput.start)
newStart.setHours(time.hours, time.minutes, 0, 0)
setRangeStart(newStart)
setActiveBoundary('end')
setOpenTimeDropdown(null)
return
}
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)
}
})
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)
}))
setActiveBoundary('start')
}, [activeBoundary, setRangeEnd, setRangeStart])
setOpenTimeDropdown(null)
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
@@ -364,6 +613,23 @@ 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' : ''}`}
@@ -391,6 +657,23 @@ 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>
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
<button
type="button"
className="export-date-range-dialog-btn primary"
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
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))
}}
>
</button>

View File

@@ -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, Sparkles } from 'lucide-react'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -409,16 +409,6 @@ 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"

View File

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

View File

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

View File

@@ -11,6 +11,7 @@
}
.biz-account-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
@@ -46,6 +47,24 @@
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;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useThemeStore } from '../stores/themeStore';
import { Newspaper, MessageSquareOff } from 'lucide-react';
import './BizPage.scss';
@@ -10,6 +10,7 @@ export interface BizAccount {
type: string;
last_time: number;
formatted_last_time: string;
unread_count?: number;
}
export const BizAccountList: React.FC<{
@@ -36,25 +37,42 @@ export const BizAccountList: React.FC<{
initWxid().then(_r => { });
}, []);
useEffect(() => {
const fetch = async () => {
if (!myWxid) {
return;
}
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);
}
};
fetch().then(_r => { } );
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 => { });
}
});
return () => removeListener();
}, [fetchAccounts]);
const filtered = useMemo(() => {
let result = accounts;
@@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{
{filtered.map(item => (
<div
key={item.username}
onClick={() => onSelect(item)}
onClick={() => {
setAccounts(prev => prev.map(account =>
account.username === item.username ? { ...account, unread_count: 0 } : account
));
onSelect({ ...item, unread_count: 0 });
}}
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
>
<img
@@ -88,6 +111,9 @@ 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>

View File

@@ -2064,6 +2064,7 @@
.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) {
@@ -3604,6 +3605,140 @@
}
}
// 接龙消息
.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;

View File

@@ -181,6 +181,51 @@ 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[]>
@@ -1058,6 +1103,13 @@ 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>
@@ -5049,24 +5101,37 @@ 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: '查看公众号历史消息',
summary: latestOfficial
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
: '查看公众号历史消息',
type: 0,
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
lastTimestamp: 0,
lastMsgType: 0,
unreadCount: 0,
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
lastMsgType: latestOfficial?.lastMsgType || 0,
unreadCount: officialUnreadCount,
isMuted: false,
isFolded: false
}
@@ -7805,6 +7870,7 @@ 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)
@@ -9413,6 +9479,71 @@ 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')

View File

@@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = (
): ExportDateRangeSelection => {
if (!bounds) 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 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)
}
// 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.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
useAllTime: selection.useAllTime,
preset: selection.preset,
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
start: boundedStart,
end: boundedEnd
}
}
}
@@ -6866,6 +6887,7 @@ 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' : '',
@@ -6889,7 +6911,7 @@ function ExportPage() {
</div>
<div className="contact-avatar">
<Avatar
src={normalizeExportAvatarUrl(contact.avatarUrl)}
src={resolvedAvatarUrl}
name={contact.displayName}
size="100%"
shape="rounded"

View File

@@ -2349,6 +2349,24 @@
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;
@@ -2412,6 +2430,16 @@
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;
@@ -2421,6 +2449,36 @@
}
}
.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;

View File

@@ -6,6 +6,7 @@ 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,
@@ -71,6 +72,25 @@ 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
}
@@ -170,6 +190,7 @@ 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)
@@ -225,6 +246,12 @@ 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 }>>({})
@@ -240,16 +267,6 @@ 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)
@@ -366,15 +383,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setFilterModeDropdownOpen(false)
setPositionDropdownOpen(false)
setCloseBehaviorDropdownOpen(false)
setMessagePushFilterDropdownOpen(false)
}
}
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
document.addEventListener('click', handleClickOutside)
}
return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
const loadConfig = async () => {
@@ -397,6 +415,9 @@ 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()
@@ -447,6 +468,11 @@ 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 || '')
@@ -489,16 +515,6 @@ 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()
@@ -516,16 +532,6 @@ 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)
@@ -1216,7 +1222,13 @@ 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('启动微信失败')) {
if (
result.error?.includes('未找到微信安装路径') ||
result.error?.includes('启动微信失败') ||
result.error?.includes('未能自动启动微信') ||
result.error?.includes('未找到微信进程') ||
result.error?.includes('微信进程未运行')
) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
@@ -1672,15 +1684,6 @@ 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
@@ -1698,18 +1701,6 @@ 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">
@@ -1801,17 +1792,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div
key={option.value}
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
onClick={async () => {
const val = option.value as 'all' | 'whitelist' | 'blacklist'
setNotificationFilterMode(val)
setFilterModeDropdownOpen(false)
await configService.setNotificationFilterMode(val)
showMessage(
val === 'all' ? '已设为接收所有通知' :
val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
true
)
}}
onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }}
>
{option.label}
{notificationFilterMode === option.value && <Check size={14} />}
@@ -1830,11 +1811,33 @@ 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
@@ -1846,8 +1849,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
<div className="filter-panel-list">
{availableSessions.length > 0 ? (
availableSessions.map(session => (
{notificationAvailableSessions.length > 0 ? (
notificationAvailableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
@@ -1859,12 +1862,13 @@ 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 ? '没有匹配的会话' : '暂无可添加的会话'}
{filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
</div>
)}
</div>
@@ -1877,11 +1881,20 @@ 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 = getSessionInfo(username)
const info = getSessionFilterOptionInfo(username)
return (
<div
key={username}
@@ -1894,6 +1907,7 @@ 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>
)
@@ -2138,9 +2152,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>
) : (
@@ -2547,6 +2561,163 @@ 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)
@@ -2644,113 +2815,6 @@ 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">
@@ -3487,6 +3551,154 @@ 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>
@@ -3521,7 +3733,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
<p className="api-desc"> SSE `messageKey` </p>
<div className="api-params">
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
<span key={param} className="param">
<code>{param}</code>
</span>

View File

@@ -368,7 +368,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setError('')
await handleScanWxid(true)
} else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
if (
result.error?.includes('未找到微信安装路径') ||
result.error?.includes('启动微信失败') ||
result.error?.includes('未能自动启动微信') ||
result.error?.includes('未找到微信进程') ||
result.error?.includes('微信进程未运行')
) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
@@ -844,9 +850,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>
) : (

View File

@@ -72,6 +72,8 @@ 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',
@@ -86,16 +88,6 @@ 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',
@@ -1515,6 +1507,29 @@ 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
@@ -1636,100 +1651,6 @@ 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

View File

@@ -1,319 +0,0 @@
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 }
})
}))

View File

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

View File

@@ -7,37 +7,6 @@ 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
@@ -513,32 +482,6 @@ 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: {
@@ -1150,372 +1093,6 @@ 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 {
@@ -1572,11 +1149,6 @@ 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 类型声明

View File

@@ -138,19 +138,24 @@ 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')
return `${y}-${m}-${d}`
const h = `${date.getHours()}`.padStart(2, '0')
const min = `${date.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}`
}
export const parseDateInputValue = (raw: string): Date | null => {
const text = String(raw || '').trim()
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
const matched = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\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
const parsed = new Date(year, month - 1, day)
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
const parsed = new Date(year, month - 1, day, hour, minute, 0, 0)
if (
parsed.getFullYear() !== year ||
parsed.getMonth() !== month - 1 ||
@@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = (
const parsedStart = parseStoredDate(raw.start)
const parsedEnd = parseStoredDate(raw.end)
if (parsedStart && parsedEnd) {
const start = startOfDay(parsedStart)
const end = endOfDay(parsedEnd)
const start = parsedStart
const end = parsedEnd
return {
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: end < start ? endOfDay(start) : end
end: end < start ? start : end
}
}
}