Compare commits

...

1 Commits

Author SHA1 Message Date
cc
599fd1af26 feat: ai功能的初次提交 2026-04-11 23:12:03 +08:00
32 changed files with 7159 additions and 1 deletions

View File

@@ -31,6 +31,10 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService' import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService' import { insightService } from './services/insightService'
import { aiAnalysisService } from './services/aiAnalysisService'
import { aiAgentService } from './services/aiAgentService'
import { aiAssistantService } from './services/aiAssistantService'
import { aiSkillService } from './services/aiSkillService'
import { bizService } from './services/bizService' import { bizService } from './services/bizService'
// 配置自动更新 // 配置自动更新
@@ -1598,6 +1602,14 @@ const runLegacySnsCacheMigration = async (
return { copied, skipped, totalFiles: total } return { copied, skipped, totalFiles: total }
} }
async function ensureAiSqlLabConnected(): Promise<{ success: boolean; error?: string }> {
const connectResult = await chatService.connect()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
return { success: true }
}
// 注册 IPC 处理器 // 注册 IPC 处理器
function registerIpcHandlers() { function registerIpcHandlers() {
registerNotificationHandlers() registerNotificationHandlers()
@@ -1651,6 +1663,164 @@ function registerIpcHandlers() {
return insightService.generateFootprintInsight(payload) return insightService.generateFootprintInsight(payload)
}) })
// ==================== AI Analysis V2 ====================
ipcMain.handle('ai:listConversations', async (_, payload?: { page?: number; pageSize?: number }) =>
aiAnalysisService.listConversations(payload?.page, payload?.pageSize)
)
ipcMain.handle('ai:createConversation', async (_, payload?: { title?: string }) =>
aiAnalysisService.createConversation(payload?.title || '')
)
ipcMain.handle('ai:renameConversation', async (_, payload: { conversationId: string; title: string }) =>
aiAnalysisService.renameConversation(payload.conversationId, payload.title)
)
ipcMain.handle('ai:deleteConversation', async (_, conversationId: string) =>
aiAnalysisService.deleteConversation(conversationId)
)
ipcMain.handle('ai:listMessages', async (_, payload: { conversationId: string; limit?: number }) =>
aiAnalysisService.listMessages(payload.conversationId, payload.limit)
)
ipcMain.handle('ai:exportConversation', async (_, payload: { conversationId: string }) =>
aiAnalysisService.exportConversation(payload.conversationId)
)
ipcMain.handle('ai:getToolCatalog', async () => aiAnalysisService.getToolCatalog())
ipcMain.handle('ai:executeTool', async (_, payload: { name: string; args?: Record<string, any> }) =>
aiAnalysisService.executeTool(payload.name, payload.args || {})
)
ipcMain.handle('ai:cancelToolTest', async (_, payload?: { taskId?: string }) =>
aiAnalysisService.cancelToolTest(payload?.taskId)
)
ipcMain.handle('agent:runStream', async (event, payload: {
mode?: 'chat' | 'sql'
conversationId?: string
userInput: string
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
sqlContext?: { schemaText?: string; targetHint?: string }
}) => {
return aiAgentService.runStream(payload, {
onChunk: (chunk) => {
try {
event.sender.send('agent:stream', chunk)
} catch {
// ignore sender errors
}
}
})
})
ipcMain.handle('agent:abort', async (_, payload: { runId?: string; conversationId?: string }) =>
aiAgentService.abort(payload || {})
)
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)
})
// 兼容层:旧 aiAnalysis API 转调新实现
ipcMain.handle('aiAnalysis:listConversations', async (_, payload?: { page?: number; pageSize?: number }) =>
aiAnalysisService.listConversations(payload?.page, payload?.pageSize)
)
ipcMain.handle('aiAnalysis:createConversation', async (_, payload?: { title?: string }) =>
aiAnalysisService.createConversation(payload?.title || '')
)
ipcMain.handle('aiAnalysis:deleteConversation', async (_, conversationId: string) =>
aiAnalysisService.deleteConversation(conversationId)
)
ipcMain.handle('aiAnalysis:listMessages', async (_, payload: { conversationId: string; limit?: number }) =>
aiAnalysisService.listMessages(payload.conversationId, payload.limit)
)
ipcMain.handle('aiAnalysis:sendMessage', async (event, payload: {
conversationId: string
userInput: string
options?: { parentMessageId?: string; persistUserMessage?: boolean; assistantId?: string; activeSkillId?: string }
}) =>
aiAnalysisService.sendMessage(payload.conversationId, payload.userInput, payload.options, {
onRunEvent: (runEvent) => {
try {
event.sender.send('aiAnalysis:runEvent', runEvent)
} catch {
// ignore sender errors
}
}
})
)
ipcMain.handle('aiAnalysis:retryMessage', async (event, payload: { conversationId: string; userMessageId?: string }) =>
aiAnalysisService.retryMessage(payload, {
onRunEvent: (runEvent) => {
try {
event.sender.send('aiAnalysis:runEvent', runEvent)
} catch {
// ignore sender errors
}
}
})
)
ipcMain.handle('aiAnalysis:abortRun', async (_, payload: { runId?: string; conversationId?: string }) =>
aiAnalysisService.abortRun(payload || {})
)
ipcMain.handle('config:clear', async () => { ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false) const result = setSystemLaunchAtStartup(false)

View File

@@ -276,6 +276,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
format: 'csv' | 'json', format: 'csv' | 'json',
filePath: string filePath: string
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath), ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
getSchema: (payload?: { sessionId?: string }) => ipcRenderer.invoke('chat:getSchema', payload),
executeSQL: (payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}) => ipcRenderer.invoke('chat:executeSQL', payload),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback) ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback) return () => ipcRenderer.removeListener('wcdb-change', callback)
@@ -540,5 +547,174 @@ contextBridge.exposeInMainWorld('electronAPI', {
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
},
aiApi: {
listConversations: (payload?: { page?: number; pageSize?: number }) =>
ipcRenderer.invoke('ai:listConversations', payload),
createConversation: (payload?: { title?: string }) =>
ipcRenderer.invoke('ai:createConversation', payload),
renameConversation: (payload: { conversationId: string; title: string }) =>
ipcRenderer.invoke('ai:renameConversation', payload),
deleteConversation: (conversationId: string) =>
ipcRenderer.invoke('ai:deleteConversation', conversationId),
listMessages: (payload: { conversationId: string; limit?: number }) =>
ipcRenderer.invoke('ai:listMessages', payload),
exportConversation: (payload: { conversationId: string }) =>
ipcRenderer.invoke('ai:exportConversation', payload),
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 }
}) => ipcRenderer.invoke('agent:runStream', payload),
abort: (payload: { runId?: string; conversationId?: string }) =>
ipcRenderer.invoke('agent:abort', payload),
onStream: (callback: (payload: any) => void) => {
const listener = (_: unknown, payload: any) => callback(payload)
ipcRenderer.on('agent:stream', listener)
return () => ipcRenderer.removeListener('agent:stream', listener)
}
},
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')
},
aiAnalysis: {
listConversations: (payload?: { page?: number; pageSize?: number }) =>
ipcRenderer.invoke('aiAnalysis:listConversations', payload),
createConversation: (payload?: { title?: string }) =>
ipcRenderer.invoke('aiAnalysis:createConversation', payload),
deleteConversation: (conversationId: string) =>
ipcRenderer.invoke('aiAnalysis:deleteConversation', conversationId),
listMessages: (payload: { conversationId: string; limit?: number }) =>
ipcRenderer.invoke('aiAnalysis:listMessages', payload),
sendMessage: (payload: {
conversationId: string
userInput: string
options?: {
parentMessageId?: string
persistUserMessage?: boolean
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
}
}) => ipcRenderer.invoke('aiAnalysis:sendMessage', payload),
retryMessage: (payload: { conversationId: string; userMessageId?: string }) =>
ipcRenderer.invoke('aiAnalysis:retryMessage', payload),
abortRun: (payload: { runId?: string; conversationId?: string }) =>
ipcRenderer.invoke('aiAnalysis:abortRun', payload),
onRunEvent: (callback: (payload: {
runId: string
conversationId: string
stage: string
ts: number
message: string
intent?: string
round?: number
toolName?: string
status?: string
durationMs?: number
data?: Record<string, unknown>
}) => void) => {
const listener = (_: unknown, payload: any) => callback(payload)
ipcRenderer.on('aiAnalysis:runEvent', listener)
return () => ipcRenderer.removeListener('aiAnalysis:runEvent', listener)
}
} }
}) })
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),
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 }
}) => ipcRenderer.invoke('agent:runStream', payload),
abort: (payload: { runId?: string; conversationId?: string }) => ipcRenderer.invoke('agent:abort', payload),
onStream: (callback: (payload: any) => void) => {
const listener = (_: unknown, payload: any) => callback(payload)
ipcRenderer.on('agent:stream', listener)
return () => ipcRenderer.removeListener('agent:stream', listener)
}
})
contextBridge.exposeInMainWorld('assistantApi', {
getAll: () => ipcRenderer.invoke('assistant:getAll'),
getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id),
create: (payload: any) => ipcRenderer.invoke('assistant:create', payload),
update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload),
delete: (id: string) => ipcRenderer.invoke('assistant:delete', id),
reset: (id: string) => ipcRenderer.invoke('assistant:reset', id),
getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'),
getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'),
importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd)
})
contextBridge.exposeInMainWorld('skillApi', {
getAll: () => ipcRenderer.invoke('skill:getAll'),
getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id),
create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd),
update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload),
delete: (id: string) => ipcRenderer.invoke('skill:delete', id),
getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'),
importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd)
})
contextBridge.exposeInMainWorld('llmApi', {
getConfig: () => ipcRenderer.invoke('llm:getConfig'),
setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => ipcRenderer.invoke('llm:setConfig', payload),
listModels: () => ipcRenderer.invoke('llm:listModels')
})

View File

@@ -0,0 +1,450 @@
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 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: Number(res?.usage?.prompt_tokens || 0),
completionTokens: Number(res?.usage?.completion_tokens || 0),
totalTokens: Number(res?.usage?.total_tokens || 0)
}
if (!sql) {
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'error',
error: 'SQL 生成失败',
isFinished: true
})
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: 'SQL 生成失败' })
this.runs.delete(run.runId)
return
}
for (let i = 0; i < sql.length; i += 36) {
if (run.aborted) break
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'content',
content: sql.slice(i, i + 36)
})
}
runtime.onChunk({
runId: run.runId,
conversationId: run.conversationId,
type: 'done',
usage,
isFinished: true
})
runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId })
this.runs.delete(run.runId)
}
async abort(payload: { runId?: string; conversationId?: string }): Promise<{ success: boolean }> {
const runId = normalizeText(payload.runId)
const conversationId = normalizeText(payload.conversationId)
if (runId) {
const run = this.runs.get(runId)
if (run) {
run.aborted = true
if (run.mode === 'chat') {
await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId })
}
}
return { success: true }
}
if (conversationId) {
for (const run of this.runs.values()) {
if (run.conversationId !== conversationId) continue
run.aborted = true
if (run.mode === 'chat') {
await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId })
}
}
return { success: true }
}
return { success: true }
}
}
export const aiAgentService = new AiAgentService()

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,444 @@
import { randomUUID } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { ConfigService } from './config'
export type AssistantChatType = 'group' | 'private'
export type AssistantToolCategory = 'core' | 'analysis'
export interface AssistantSummary {
id: string
name: string
systemPrompt: string
presetQuestions: string[]
allowedBuiltinTools?: string[]
builtinId?: string
applicableChatTypes?: AssistantChatType[]
supportedLocales?: string[]
}
export interface AssistantConfigFull extends AssistantSummary {}
export interface BuiltinAssistantInfo {
id: string
name: string
systemPrompt: string
applicableChatTypes?: AssistantChatType[]
supportedLocales?: string[]
imported: boolean
}
const GENERAL_CN_MD = `---
id: general_cn
name: 通用分析助手
supportedLocales:
- zh
presetQuestions:
- 最近都在聊什么?
- 谁是最活跃的人?
- 帮我总结一下最近一周的重要聊天
- 帮我找一下关于“旅游”的讨论
allowedBuiltinTools:
- ai_query_time_window_activity
- ai_query_session_candidates
- ai_query_session_glimpse
- ai_query_timeline
- ai_fetch_message_briefs
- ai_list_voice_messages
- ai_transcribe_voice_messages
- ai_query_topic_stats
- ai_query_source_refs
- ai_query_top_contacts
---
你是 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:
- ai_query_time_window_activity
- ai_query_session_candidates
- ai_query_session_glimpse
- ai_query_timeline
- ai_fetch_message_briefs
- ai_list_voice_messages
- ai_transcribe_voice_messages
- ai_query_topic_stats
- ai_query_source_refs
- ai_query_top_contacts
---
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:
- ai_query_time_window_activity
- ai_query_session_candidates
- ai_query_session_glimpse
- ai_query_timeline
- ai_fetch_message_briefs
- ai_list_voice_messages
- ai_transcribe_voice_messages
- ai_query_topic_stats
- ai_query_source_refs
- ai_query_top_contacts
---
あなたは 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: 'ai_query_time_window_activity', category: 'core' },
{ name: 'ai_query_session_candidates', category: 'core' },
{ name: 'ai_query_session_glimpse', category: 'core' },
{ name: 'ai_query_timeline', category: 'core' },
{ name: 'ai_fetch_message_briefs', category: 'core' },
{ name: 'ai_list_voice_messages', category: 'core' },
{ name: 'ai_transcribe_voice_messages', category: 'core' },
{ name: 'ai_query_topic_stats', category: 'analysis' },
{ name: 'ai_query_source_refs', category: 'analysis' },
{ name: 'ai_query_top_contacts', category: 'analysis' },
{ name: 'activate_skill', category: 'analysis' }
]
}
class AiAssistantService {
private readonly config = ConfigService.getInstance()
private initialized = false
private readonly cache = new Map<string, AssistantConfigFull>()
private getRootDirCandidates(): string[] {
const dbPath = normalizeText(this.config.get('dbPath'))
const wxid = normalizeText(this.config.get('myWxid'))
const roots: string[] = []
if (dbPath && wxid) {
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2'))
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai'))
}
roots.push(join(process.cwd(), 'data', 'wf_ai_v2'))
return roots
}
private async getRootDir(): Promise<string> {
const roots = this.getRootDirCandidates()
const dir = roots[0]
await mkdir(dir, { recursive: true })
return dir
}
private async getAssistantsDir(): Promise<string> {
const root = await this.getRootDir()
const dir = join(root, 'assistants')
await mkdir(dir, { recursive: true })
return dir
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return
const dir = await this.getAssistantsDir()
for (const builtin of BUILTIN_ASSISTANTS) {
const filePath = join(dir, `${builtin.id}.md`)
if (!existsSync(filePath)) {
const parsed = parseAssistantMarkdown(builtin.raw)
const config: AssistantConfigFull = {
...parsed,
builtinId: parsed.id
}
await writeFile(filePath, toMarkdown(config), 'utf8')
}
}
this.cache.clear()
const files = await readdir(dir)
for (const fileName of files) {
if (!fileName.endsWith('.md')) continue
const filePath = join(dir, fileName)
try {
const raw = await readFile(filePath, 'utf8')
const parsed = parseAssistantMarkdown(raw)
if (!parsed.id) continue
this.cache.set(parsed.id, parsed)
} catch {
// ignore broken file
}
}
this.initialized = true
}
async getAll(): Promise<AssistantSummary[]> {
await this.ensureInitialized()
return Array.from(this.cache.values())
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
.map((assistant) => ({ ...assistant }))
}
async getConfig(id: string): Promise<AssistantConfigFull | null> {
await this.ensureInitialized()
const key = normalizeText(id)
const config = this.cache.get(key)
return config ? { ...config } : null
}
async create(
payload: Omit<AssistantConfigFull, 'id'> & { id?: string }
): Promise<{ success: boolean; id?: string; error?: string }> {
await this.ensureInitialized()
const id = normalizeText(payload.id, `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`)
if (this.cache.has(id)) return { success: false, error: '助手 ID 已存在' }
const config: AssistantConfigFull = {
id,
name: normalizeText(payload.name, '新助手'),
systemPrompt: normalizeText(payload.systemPrompt),
presetQuestions: Array.isArray(payload.presetQuestions) ? payload.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : [],
allowedBuiltinTools: Array.isArray(payload.allowedBuiltinTools) ? payload.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : [],
builtinId: normalizeText(payload.builtinId) || undefined,
applicableChatTypes: Array.isArray(payload.applicableChatTypes) ? payload.applicableChatTypes : [],
supportedLocales: Array.isArray(payload.supportedLocales) ? payload.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : []
}
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${id}.md`), toMarkdown(config), 'utf8')
this.cache.set(id, config)
return { success: true, id }
}
async update(
id: string,
updates: Partial<AssistantConfigFull>
): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
const existing = this.cache.get(key)
if (!existing) return { success: false, error: '助手不存在' }
const next: AssistantConfigFull = {
...existing,
...updates,
id: key,
name: normalizeText(updates.name, existing.name),
systemPrompt: updates.systemPrompt == null ? existing.systemPrompt : normalizeText(updates.systemPrompt),
presetQuestions: Array.isArray(updates.presetQuestions) ? updates.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : existing.presetQuestions,
allowedBuiltinTools: Array.isArray(updates.allowedBuiltinTools) ? updates.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : existing.allowedBuiltinTools,
applicableChatTypes: Array.isArray(updates.applicableChatTypes) ? updates.applicableChatTypes : existing.applicableChatTypes,
supportedLocales: Array.isArray(updates.supportedLocales) ? updates.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : existing.supportedLocales
}
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${key}.md`), toMarkdown(next), 'utf8')
this.cache.set(key, next)
return { success: true }
}
async delete(id: string): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
if (key === 'general_cn' || key === 'general_en' || key === 'general_ja') {
return { success: false, error: '默认助手不可删除' }
}
const dir = await this.getAssistantsDir()
const filePath = join(dir, `${key}.md`)
if (existsSync(filePath)) {
await rm(filePath, { force: true })
}
this.cache.delete(key)
return { success: true }
}
async reset(id: string): Promise<{ success: boolean; error?: string }> {
await this.ensureInitialized()
const key = normalizeText(id)
const existing = this.cache.get(key)
if (!existing?.builtinId) {
return { success: false, error: '该助手不支持重置' }
}
const builtin = BUILTIN_ASSISTANTS.find((item) => item.id === existing.builtinId)
if (!builtin) return { success: false, error: '内置模板不存在' }
const parsed = parseAssistantMarkdown(builtin.raw)
const config: AssistantConfigFull = {
...parsed,
id: key,
builtinId: existing.builtinId
}
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${key}.md`), toMarkdown(config), 'utf8')
this.cache.set(key, config)
return { success: true }
}
async getBuiltinCatalog(): Promise<BuiltinAssistantInfo[]> {
await this.ensureInitialized()
return BUILTIN_ASSISTANTS.map((builtin) => {
const parsed = parseAssistantMarkdown(builtin.raw)
const imported = Array.from(this.cache.values()).some((config) => config.builtinId === builtin.id || config.id === builtin.id)
return {
id: parsed.id,
name: parsed.name,
systemPrompt: parsed.systemPrompt,
applicableChatTypes: parsed.applicableChatTypes,
supportedLocales: parsed.supportedLocales,
imported
}
})
}
async getBuiltinToolCatalog(): Promise<Array<{ name: string; category: AssistantToolCategory }>> {
return defaultBuiltinToolCatalog()
}
async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const parsed = parseAssistantMarkdown(rawMd)
if (!parsed.id) return { success: false, error: '缺少 id' }
if (this.cache.has(parsed.id)) return { success: false, error: '助手 ID 已存在' }
const dir = await this.getAssistantsDir()
await writeFile(join(dir, `${parsed.id}.md`), toMarkdown(parsed), 'utf8')
this.cache.set(parsed.id, parsed)
return { success: true, id: parsed.id }
} catch (error) {
return { success: false, error: String((error as Error)?.message || error) }
}
}
}
export const aiAssistantService = new AiAssistantService()

View File

@@ -0,0 +1,395 @@
import { existsSync } from 'fs'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { ConfigService } from './config'
export type SkillChatScope = 'all' | 'group' | 'private'
export interface SkillSummary {
id: string
name: string
description: string
tags: string[]
chatScope: SkillChatScope
tools: string[]
builtinId?: string
}
export interface SkillDef extends SkillSummary {
prompt: string
}
export interface BuiltinSkillInfo extends SkillSummary {
imported: boolean
}
const SKILL_DEEP_TIMELINE_MD = `---
id: deep_timeline
name: 深度时间线追踪
description: 适合还原某段时间内发生了什么,强调事件顺序与证据引用。
tags:
- timeline
- evidence
chatScope: all
tools:
- ai_query_time_window_activity
- ai_query_session_candidates
- ai_query_session_glimpse
- ai_query_timeline
- ai_fetch_message_briefs
- ai_query_source_refs
---
你是“深度时间线追踪”技能。
执行步骤:
1. 先按时间窗扫描活跃会话,必要时补关键词筛选候选会话。
2. 对候选会话先抽样,再拉取时间轴。
3. 对关键节点用 ai_fetch_message_briefs 校对原文。
4. 最后输出“结论 + 关键节点 + 来源范围”。`
const SKILL_CONTACT_FOCUS_MD = `---
id: contact_focus
name: 联系人关系聚焦
description: 用于“我和谁聊得最多/关系变化”这类问题,强调联系人维度。
tags:
- contacts
- relation
chatScope: private
tools:
- ai_query_top_contacts
- ai_query_topic_stats
- ai_query_session_glimpse
- ai_query_timeline
- ai_query_source_refs
---
你是“联系人关系聚焦”技能。
执行步骤:
1. 优先调用 ai_query_top_contacts 得到候选联系人排名。
2. 针对 Top 联系人读取抽样消息并补充时间轴。
3. 如果用户问题涉及“变化趋势”,补 ai_query_topic_stats。
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
- ai_query_source_refs
---
你是“语音证据审计”技能。
硬规则:
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

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

View File

@@ -85,6 +85,10 @@ export class WcdbCore {
private wcdbScanMediaStream: any = null private wcdbScanMediaStream: any = null
private wcdbGetHeadImageBuffers: any = null private wcdbGetHeadImageBuffers: any = null
private wcdbSearchMessages: any = null private wcdbSearchMessages: any = null
private wcdbAiQuerySessionCandidates: any = null
private wcdbAiQueryTimeline: any = null
private wcdbAiQueryTopicStats: any = null
private wcdbAiQuerySourceRefs: any = null
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null private wcdbGetSnsAnnualStats: any = null
private wcdbGetSnsUsernames: any = null private wcdbGetSnsUsernames: any = null
@@ -1060,6 +1064,26 @@ export class WcdbCore {
} catch { } catch {
this.wcdbSearchMessages = null this.wcdbSearchMessages = null
} }
try {
this.wcdbAiQuerySessionCandidates = this.lib.func('int32 wcdb_ai_query_session_candidates(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQuerySessionCandidates = null
}
try {
this.wcdbAiQueryTimeline = this.lib.func('int32 wcdb_ai_query_timeline(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQueryTimeline = null
}
try {
this.wcdbAiQueryTopicStats = this.lib.func('int32 wcdb_ai_query_topic_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQueryTopicStats = null
}
try {
this.wcdbAiQuerySourceRefs = this.lib.func('int32 wcdb_ai_query_source_refs(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbAiQuerySourceRefs = null
}
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
try { try {
@@ -3370,6 +3394,204 @@ export class WcdbCore {
} }
} }
private normalizeSqlIdentifier(name: string): string {
return `"${String(name || '').replace(/"/g, '""')}"`
}
private stripSqlComments(sql: string): string {
return String(sql || '')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/--[^\n\r]*/g, ' ')
.trim()
}
private isSqlLabReadOnly(sql: string): boolean {
const normalized = this.stripSqlComments(sql).trim()
if (!normalized) return false
if (normalized.includes('\u0000')) return false
const hasMultipleStatements = /;[\s\r\n]*\S/.test(normalized)
if (hasMultipleStatements) return false
const lower = normalized.toLowerCase()
if (/(insert|update|delete|drop|alter|create|attach|detach|replace|truncate|reindex|vacuum|analyze|begin|commit|rollback|savepoint|release)\b/.test(lower)) {
return false
}
if (/pragma\s+.*(writable_schema|journal_mode|locking_mode|foreign_keys)\s*=/.test(lower)) {
return false
}
return /^(select|with|pragma|explain)\b/.test(lower)
}
private async sqlLabListTablesForSource(
kind: 'message' | 'contact' | 'biz',
path: string | null,
maxTables: number = 60,
maxColumns: number = 120
): Promise<Array<{ name: string; columns: string[] }>> {
const tableRows = await this.execQuery(
kind,
path,
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name LIMIT ${Math.max(1, maxTables)}`
)
if (!tableRows.success || !Array.isArray(tableRows.rows)) return []
const tables: Array<{ name: string; columns: string[] }> = []
for (const row of tableRows.rows) {
const tableName = String((row as any)?.name || '').trim()
if (!tableName) continue
const pragma = await this.execQuery(kind, path, `PRAGMA table_info(${this.normalizeSqlIdentifier(tableName)})`)
const columns = pragma.success && Array.isArray(pragma.rows)
? pragma.rows
.map((item: any) => String(item?.name || '').trim())
.filter(Boolean)
.slice(0, maxColumns)
: []
tables.push({ name: tableName, columns })
}
return tables
}
async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const sessionId = String(payload?.sessionId || '').trim()
const sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}> = []
if (sessionId) {
const tableStats = await this.getMessageTableStats(sessionId)
const tableEntries = tableStats.success && Array.isArray(tableStats.tables) ? tableStats.tables : []
const dbPathSet = new Set<string>()
for (const entry of tableEntries) {
const dbPath = String((entry as any)?.db_path || '').trim()
if (!dbPath) continue
dbPathSet.add(dbPath)
}
for (const dbPath of Array.from(dbPathSet).slice(0, 8)) {
sources.push({
kind: 'message',
path: dbPath,
label: dbPath.split(/[\\/]/).pop() || dbPath,
tables: await this.sqlLabListTablesForSource('message', dbPath)
})
}
} else {
const messageDbs = await this.listMessageDbs()
const paths = messageDbs.success && Array.isArray(messageDbs.data) ? messageDbs.data : []
for (const dbPath of paths.slice(0, 8)) {
sources.push({
kind: 'message',
path: dbPath,
label: dbPath.split(/[\\/]/).pop() || dbPath,
tables: await this.sqlLabListTablesForSource('message', dbPath)
})
}
}
sources.push({
kind: 'contact',
path: null,
label: 'contact',
tables: await this.sqlLabListTablesForSource('contact', null)
})
sources.push({
kind: 'biz',
path: null,
label: 'biz',
tables: await this.sqlLabListTablesForSource('biz', null)
})
const schemaText = sources
.map((source) => {
const tableLines = source.tables
.map((table) => `- ${table.name} (${table.columns.join(', ')})`)
.join('\n')
return `[${source.kind}] ${source.label}\n${tableLines}`
})
.join('\n\n')
return {
success: true,
schema: {
generatedAt: Date.now(),
sources
},
schemaText
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async sqlLabExecuteReadonly(payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}): Promise<{
success: boolean
rows?: any[]
columns?: string[]
total?: number
error?: string
}> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const sql = String(payload?.sql || '').trim()
if (!this.isSqlLabReadOnly(sql)) {
return { success: false, error: '仅允许只读 SQLSELECT/WITH/PRAGMA/EXPLAIN' }
}
const kind = payload?.kind === 'contact' || payload?.kind === 'biz' ? payload.kind : 'message'
const path = kind === 'message'
? (payload?.path == null ? null : String(payload.path))
: null
const limit = Math.max(1, Math.min(1000, Number(payload?.limit || 200)))
const sqlNoTail = sql.replace(/;+\s*$/, '')
const lower = sqlNoTail.toLowerCase()
const executable = /^(select|with)\b/.test(lower)
? `SELECT * FROM (${sqlNoTail}) LIMIT ${limit}`
: sqlNoTail
const result = await this.execQuery(kind, path, executable)
if (!result.success) {
return { success: false, error: result.error || '执行 SQL 失败' }
}
const rows = Array.isArray(result.rows) ? result.rows : []
return {
success: true,
rows,
columns: rows[0] && typeof rows[0] === 'object' ? Object.keys(rows[0] as Record<string, unknown>) : [],
total: rows.length
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -3979,6 +4201,110 @@ 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 getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' }

View File

@@ -489,6 +489,44 @@ export class WcdbService {
return this.callWorker('closeMessageCursor', { cursor }) return this.callWorker('closeMessageCursor', { cursor })
} }
/**
* SQL Lab: 获取多数据源 Schema 摘要
*/
async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}> {
return this.callWorker('sqlLabGetSchema', payload || {})
}
/**
* SQL Lab: 执行只读 SQL
*/
async sqlLabExecuteReadonly(payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}): Promise<{
success: boolean
rows?: any[]
columns?: string[]
total?: number
error?: string
}> {
return this.callWorker('sqlLabExecuteReadonly', payload)
}
/** /**
* 执行 SQL 查询仅主进程内部使用fallback/diagnostic/低频兼容) * 执行 SQL 查询仅主进程内部使用fallback/diagnostic/低频兼容)
*/ */
@@ -542,6 +580,42 @@ export class WcdbService {
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp }) return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
} }
async aiQuerySessionCandidates(options: {
keyword: string
limit?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiQuerySessionCandidates', { options })
}
async aiQueryTimeline(options: {
sessionId?: string
keyword: string
limit?: number
offset?: number
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('aiQueryTimeline', { options })
}
async aiQueryTopicStats(options: {
sessionIds: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('aiQueryTopicStats', { options })
}
async aiQuerySourceRefs(options: {
sessionIds: string[]
beginTimestamp?: number
endTimestamp?: number
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('aiQuerySourceRefs', { options })
}
/** /**
* 获取语音数据 * 获取语音数据
*/ */

View File

@@ -173,6 +173,12 @@ if (parentPort) {
case 'closeMessageCursor': case 'closeMessageCursor':
result = await core.closeMessageCursor(payload.cursor) result = await core.closeMessageCursor(payload.cursor)
break break
case 'sqlLabGetSchema':
result = await core.sqlLabGetSchema(payload)
break
case 'sqlLabExecuteReadonly':
result = await core.sqlLabExecuteReadonly(payload)
break
case 'execQuery': case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params) result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
break break
@@ -197,6 +203,18 @@ if (parentPort) {
case 'searchMessages': case 'searchMessages':
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp) result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
break break
case 'aiQuerySessionCandidates':
result = await core.aiQuerySessionCandidates(payload.options || {})
break
case 'aiQueryTimeline':
result = await core.aiQueryTimeline(payload.options || {})
break
case 'aiQueryTopicStats':
result = await core.aiQueryTopicStats(payload.options || {})
break
case 'aiQuerySourceRefs':
result = await core.aiQuerySourceRefs(payload.options || {})
break
case 'getVoiceData': case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId) result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) { if (!result.success) {

View File

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

View File

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

View File

@@ -0,0 +1,680 @@
.ai-analysis-v2 {
--ai-surface: color-mix(in srgb, var(--card-bg) 92%, #ffffff 8%);
--ai-surface-soft: color-mix(in srgb, var(--card-bg) 86%, #cbd5e1 14%);
--ai-border: color-mix(in srgb, var(--border-color) 85%, #94a3b8 15%);
--ai-accent: #0f766e;
--ai-accent-soft: color-mix(in srgb, #0f766e 16%, transparent);
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 16px;
background:
radial-gradient(1200px 380px at 8% -15%, color-mix(in srgb, #22c55e 20%, transparent), transparent 70%),
radial-gradient(1000px 320px at 96% -10%, color-mix(in srgb, #06b6d4 15%, transparent), transparent 68%),
var(--bg-primary);
}
.ai-header {
border: 1px solid var(--ai-border);
border-radius: 16px;
background: var(--ai-surface);
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
h1 {
margin: 0;
font-size: 16px;
}
span {
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.tabs {
display: flex;
align-items: center;
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 {
background: var(--ai-accent-soft);
color: var(--text-primary);
border-color: color-mix(in srgb, var(--ai-accent) 58%, transparent);
}
}
}
.chat-layout,
.sql-layout,
.tool-layout {
min-height: 0;
display: grid;
gap: 12px;
}
.chat-layout {
grid-template-columns: 300px minmax(0, 1fr);
}
.conversation-panel,
.chat-main,
.schema-panel,
.sql-main,
.tool-catalog,
.tool-main {
border: 1px solid var(--ai-border);
border-radius: 16px;
background: var(--ai-surface);
min-height: 0;
}
.panel-head {
padding: 10px 12px;
border-bottom: 1px solid var(--ai-border);
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
}
button {
width: 28px;
height: 28px;
border-radius: 9px;
border: 1px solid var(--ai-border);
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
cursor: pointer;
}
}
.conversation-list {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
max-height: 100%;
}
.conversation-item {
border: 1px solid transparent;
border-radius: 12px;
padding: 10px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
text-align: left;
cursor: pointer;
color: var(--text-primary);
.main {
display: grid;
gap: 4px;
}
strong {
font-size: 13px;
}
small {
font-size: 11px;
color: var(--text-tertiary);
}
.ops {
margin-top: 8px;
display: flex;
gap: 10px;
color: var(--text-secondary);
font-size: 12px;
span {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
}
}
.conversation-item.active {
border-color: color-mix(in srgb, var(--ai-accent) 52%, transparent);
background: color-mix(in srgb, var(--ai-accent) 10%, transparent);
}
.chat-main {
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;
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
label {
font-size: 12px;
color: var(--text-secondary);
}
select,
input {
border: 1px solid var(--ai-border);
background: var(--ai-surface-soft);
color: var(--text-primary);
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
min-width: 120px;
}
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
button {
border: 1px solid var(--ai-border);
background: color-mix(in srgb, var(--ai-accent) 8%, transparent);
color: var(--text-secondary);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
}
.message-panel {
min-height: 0;
overflow: hidden;
}
.message-list {
height: 100%;
overflow: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.msg {
border: 1px solid var(--ai-border);
border-radius: 12px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
padding: 10px;
.head {
font-size: 12px;
color: var(--text-secondary);
}
.body {
margin-top: 6px;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
}
.msg.user {
background: color-mix(in srgb, #0f766e 14%, transparent);
}
.runtime-cards {
display: grid;
gap: 6px;
}
.chunk {
border: 1px dashed var(--ai-border);
border-radius: 10px;
padding: 8px;
font-size: 12px;
color: var(--text-secondary);
strong {
margin-right: 8px;
}
pre {
margin: 6px 0 0;
white-space: pre-wrap;
font-size: 12px;
}
.err {
color: #dc2626;
}
}
.footer-actions {
border-top: 1px solid var(--ai-border);
padding: 8px 12px;
display: flex;
gap: 8px;
.ghost {
border: 1px solid var(--ai-border);
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
border-radius: 9px;
padding: 6px 10px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
}
}
.input-panel {
border-top: 1px solid var(--ai-border);
padding: 10px 12px;
display: grid;
gap: 8px;
textarea {
width: 100%;
min-height: 80px;
border: 1px solid var(--ai-border);
border-radius: 10px;
background: var(--ai-surface-soft);
color: var(--text-primary);
padding: 10px;
resize: vertical;
}
}
.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);
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
}
.input-actions {
display: flex;
gap: 8px;
button {
border-radius: 9px;
border: 1px solid var(--ai-border);
padding: 7px 12px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
}
.primary {
background: color-mix(in srgb, var(--ai-accent) 18%, transparent);
color: var(--text-primary);
border-color: color-mix(in srgb, var(--ai-accent) 52%, transparent);
}
.danger {
background: color-mix(in srgb, #ef4444 12%, transparent);
color: var(--text-primary);
border-color: color-mix(in srgb, #ef4444 45%, transparent);
}
}
.sql-layout {
grid-template-columns: 300px minmax(0, 1fr);
}
.schema-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.schema-list {
overflow: auto;
padding: 10px;
display: grid;
gap: 10px;
}
.schema-source {
border: 1px solid var(--ai-border);
border-radius: 12px;
padding: 8px;
h4 {
margin: 0 0 8px;
font-size: 12px;
}
ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
li {
display: grid;
gap: 4px;
font-size: 12px;
}
small {
color: var(--text-tertiary);
}
}
.sql-main {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
gap: 8px;
padding: 12px;
}
.sql-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
select,
button {
border: 1px solid var(--ai-border);
border-radius: 9px;
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
padding: 6px 10px;
font-size: 12px;
}
button {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
}
.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-size: 12px;
min-height: 90px;
resize: vertical;
}
.sql-generated {
min-height: 120px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.sql-table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid var(--ai-border);
border-radius: 12px;
}
.sql-table {
width: 100%;
border-collapse: collapse;
th,
td {
border-bottom: 1px solid var(--ai-border);
border-right: 1px solid var(--ai-border);
padding: 7px 8px;
font-size: 12px;
text-align: left;
white-space: nowrap;
}
th {
cursor: pointer;
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
}
.pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px;
button {
border: 1px solid var(--ai-border);
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
border-radius: 8px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
}
.sql-history {
border-top: 1px solid var(--ai-border);
padding-top: 8px;
h4 {
margin: 0 0 8px;
font-size: 13px;
}
}
.history-list {
display: flex;
flex-wrap: wrap;
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: 8px;
padding: 5px 8px;
font-size: 12px;
cursor: pointer;
max-width: 100%;
text-align: left;
}
}
.tool-layout {
grid-template-columns: 320px minmax(0, 1fr);
}
.tool-catalog {
padding: 12px;
overflow: auto;
h3,
h4 {
margin: 0 0 8px;
}
h4 {
margin-top: 14px;
font-size: 12px;
color: var(--text-secondary);
}
}
.tool-list {
display: grid;
gap: 6px;
button {
border: 1px solid var(--ai-border);
border-radius: 8px;
padding: 7px 8px;
background: color-mix(in srgb, var(--text-primary) 3%, transparent);
color: var(--text-secondary);
text-align: left;
font-size: 12px;
cursor: pointer;
}
.active {
border-color: color-mix(in srgb, var(--ai-accent) 52%, transparent);
background: color-mix(in srgb, var(--ai-accent) 12%, transparent);
color: var(--text-primary);
}
}
.tool-main {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
padding: 12px;
}
.tool-top {
display: flex;
justify-content: space-between;
gap: 12px;
h3 {
margin: 0 0 6px;
font-size: 14px;
}
p {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
}
.actions {
display: flex;
gap: 8px;
button {
border: 1px solid var(--ai-border);
border-radius: 8px;
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
color: var(--text-secondary);
padding: 6px 9px;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
}
.tool-output {
margin: 0;
border: 1px solid var(--ai-border);
border-radius: 10px;
background: var(--ai-surface-soft);
padding: 10px;
min-height: 160px;
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
}
.empty {
padding: 14px;
color: var(--text-secondary);
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.error,
.global-error {
border: 1px solid color-mix(in srgb, #dc2626 55%, transparent);
background: color-mix(in srgb, #dc2626 12%, transparent);
color: color-mix(in srgb, #dc2626 85%, #111827 15%);
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
}
.spin {
animation: ai-spin 1s linear infinite;
}
@keyframes ai-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 1100px) {
.chat-layout,
.sql-layout,
.tool-layout {
grid-template-columns: minmax(0, 1fr);
}
.conversation-panel,
.schema-panel,
.tool-catalog {
max-height: 260px;
}
}

View File

@@ -0,0 +1,881 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Bot,
Braces,
CircleStop,
Database,
Download,
Loader2,
Play,
RefreshCw,
Search,
Send,
Sparkles,
SquareTerminal,
Trash2,
Wrench
} from 'lucide-react'
import type {
AiConversation,
AiMessageRecord,
AssistantSummary,
SkillSummary,
SqlResultPayload,
SqlSchemaPayload,
ToolCatalogEntry
} from '../types/aiAnalysis'
import { useAiRuntimeStore } from '../stores/aiRuntimeStore'
import type { AgentStreamChunk } from '../types/electron'
import './AiAnalysisPage.scss'
type MainTab = 'chat' | 'sql' | 'tool'
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 [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 [toolCatalog, setToolCatalog] = useState<ToolCatalogEntry[]>([])
const [toolName, setToolName] = useState('')
const [toolArgsText, setToolArgsText] = useState('{}')
const [toolRunning, setToolRunning] = useState(false)
const [toolOutput, setToolOutput] = useState('')
const sqlRunIdRef = useRef('')
const sqlGeneratedRef = useRef('')
const messageEndRef = useRef<HTMLDivElement | null>(null)
const activeRunId = useAiRuntimeStore((state) => state.activeRunId)
const runtimeState = useAiRuntimeStore((state) => (
currentConversationId ? state.states[currentConversationId] : undefined
))
const startRun = useAiRuntimeStore((state) => state.startRun)
const appendChunk = useAiRuntimeStore((state) => state.appendChunk)
const finishRun = useAiRuntimeStore((state) => state.finishRun)
const selectedAssistant = useMemo(
() => assistants.find((assistant) => assistant.id === selectedAssistantId) || null,
[assistants, selectedAssistantId]
)
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 loadToolCatalog = useCallback(async () => {
try {
const catalog = await aiApi.getToolCatalog()
setToolCatalog(Array.isArray(catalog) ? catalog : [])
if (!toolName && Array.isArray(catalog) && catalog.length > 0) {
setToolName(catalog[0].name)
}
} catch (error) {
setErrorText(String((error as Error)?.message || error))
}
}, [aiApi, toolName])
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()
if (activeTab === 'tool' && toolCatalog.length === 0) void loadToolCatalog()
}, [activeTab, sqlSchema, loadSchema, toolCatalog.length, loadToolCatalog])
useEffect(() => {
const off = agentApi.onStream((chunk: AgentStreamChunk) => {
if (sqlRunIdRef.current && chunk.runId === sqlRunIdRef.current) {
if (chunk.type === 'content') {
setSqlGenerated((prev) => {
const next = `${prev}${chunk.content || ''}`
sqlGeneratedRef.current = next
return next
})
} else if (chunk.type === 'done') {
setSqlGenerating(false)
if (normalizeText(sqlGeneratedRef.current)) {
setSqlHistory((prev) => [sqlGeneratedRef.current.trim(), ...prev].slice(0, 30))
}
sqlRunIdRef.current = ''
} else if (chunk.type === 'error') {
setSqlGenerating(false)
setSqlError(chunk.error || 'SQL 生成失败')
sqlRunIdRef.current = ''
}
return
}
const conversationId = normalizeText(chunk.conversationId, currentConversationId)
if (!conversationId) return
appendChunk(conversationId, chunk)
if (chunk.type === 'done' || chunk.type === 'error' || chunk.isFinished) {
finishRun(conversationId)
void loadMessages(conversationId)
void loadConversations()
}
})
return () => off()
}, [agentApi, appendChunk, currentConversationId, finishRun, loadConversations, loadMessages])
useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [messages, runtimeState?.draft, runtimeState?.chunks.length])
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 = await agentApi.runStream({
mode: 'chat',
conversationId,
userInput: text,
assistantId: selectedAssistantId,
activeSkillId: selectedSkillId || undefined,
chatScope: scopeMode === 'session' ? 'private' : 'private'
})
if (!run.success || !run.runId) {
setErrorText('启动失败')
return
}
startRun(conversationId, run.runId)
}
const handleStop = async () => {
if (!currentConversationId) return
await agentApi.abort({ runId: activeRunId || undefined, conversationId: currentConversationId })
finishRun(currentConversationId)
}
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 handleOpenLog = async () => {
const logPath = await window.electronAPI.log.getPath()
await window.electronAPI.shell.openPath(logPath)
}
const handleGenerateSql = async () => {
const prompt = normalizeText(sqlPrompt)
if (!prompt) return
setSqlGenerating(true)
setSqlGenerated('')
sqlGeneratedRef.current = ''
setSqlError('')
const target = extractSqlTarget(sqlSchema, sqlTargetKey)
const run = await agentApi.runStream({
mode: 'sql',
userInput: prompt,
sqlContext: {
schemaText: sqlSchemaText,
targetHint: target ? `${target.kind}:${target.path || ''}` : ''
}
})
if (!run.success || !run.runId) {
setSqlGenerating(false)
setSqlError('SQL 生成失败')
return
}
sqlRunIdRef.current = run.runId
}
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)
}
const handleRunTool = async () => {
setToolRunning(true)
try {
const args = JSON.parse(toolArgsText || '{}')
const result = await aiApi.executeTool({ name: toolName, args })
setToolOutput(JSON.stringify(result, null, 2))
} catch (error) {
setToolOutput(String((error as Error)?.message || error))
} finally {
setToolRunning(false)
}
}
const groupedTools = useMemo(() => ({
core: toolCatalog.filter((item) => item.category === 'core'),
analysis: toolCatalog.filter((item) => item.category === 'analysis')
}), [toolCatalog])
return (
<div className="ai-analysis-v2">
<header className="ai-header">
<div className="left">
<Sparkles size={18} />
<h1>AI Analysis</h1>
<span>Chat Explorer + SQL Lab + Tool Test</span>
</div>
<div className="tabs">
<button type="button" className={activeTab === 'chat' ? 'active' : ''} onClick={() => setActiveTab('chat')}>
<Bot size={14} />
Chat Explorer
</button>
<button type="button" className={activeTab === 'sql' ? 'active' : ''} onClick={() => setActiveTab('sql')}>
<Database size={14} />
SQL Lab
</button>
<button type="button" className={activeTab === 'tool' ? 'active' : ''} onClick={() => setActiveTab('tool')}>
<SquareTerminal size={14} />
Tool Test
</button>
</div>
</header>
{activeTab === 'chat' && (
<div className="chat-layout">
<aside className="conversation-panel">
<div className="panel-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">
<span onClick={(event) => { event.stopPropagation(); void handleRenameConversation(conversation.conversationId) }}></span>
<span onClick={(event) => { event.stopPropagation(); void handleDeleteConversation(conversation.conversationId) }}><Trash2 size={12} /></span>
</div>
</button>
))}
{conversations.length === 0 && <div className="empty"></div>}
</div>
)}
</aside>
<section className="chat-main">
<div className="chat-toolbar">
<div className="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'}
/>
)}
</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="message-panel">
{loadingMessages ? (
<div className="empty"><Loader2 className="spin" size={14} /> ...</div>
) : (
<div className="message-list">
{messages.map((message) => (
<div key={message.messageId} className={`msg ${message.role === 'user' ? 'user' : message.role}`}>
<div className="head">{message.role === 'user' ? '你' : message.role === 'assistant' ? '助手' : message.role}</div>
<div className="body">{message.content || '(空)'}</div>
</div>
))}
{runtimeState?.running && runtimeState?.chunks?.length ? (
<div className="runtime-cards">
{runtimeState.chunks
.filter((chunk) => chunk.type === 'tool_start' || chunk.type === 'tool_result' || chunk.type === 'error')
.slice(-16)
.map((chunk, index) => (
<div key={`${chunk.runId}-${index}`} className={`chunk ${chunk.type}`}>
<strong>{chunk.type}</strong>
{chunk.toolName ? <span>{chunk.toolName}</span> : null}
{chunk.content ? <pre>{chunk.content}</pre> : null}
{chunk.type === 'tool_result' && chunk.toolResult !== undefined ? (
<pre>{JSON.stringify(chunk.toolResult, null, 2)}</pre>
) : null}
{chunk.error ? <span className="err">{chunk.error}</span> : null}
</div>
))}
</div>
) : null}
{runtimeState?.draft ? (
<div className="msg assistant draft">
<div className="head"></div>
<div className="body">{runtimeState.draft}</div>
</div>
) : null}
<div ref={messageEndRef} />
</div>
)}
</div>
<div className="footer-actions">
<button type="button" className="ghost" onClick={() => void loadConversations()}>
<RefreshCw size={14} />
</button>
<button type="button" className="ghost" onClick={() => void handleExportConversation()}>
<Download size={14} />
</button>
<button type="button" className="ghost" onClick={() => void handleOpenLog()}>
<Search size={14} />
</button>
</div>
<div className="input-panel">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="输入问题,支持 /技能 和 @成员"
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>
<button type="button" className="danger" onClick={() => void handleStop()} disabled={!runtimeState?.running}>
<CircleStop size={14} />
</button>
</div>
</div>
</section>
</div>
)}
{activeTab === 'sql' && (
<div className="sql-layout">
<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}
</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, 120)}
</button>
))}
</div>
</div>
</section>
</div>
)}
{activeTab === 'tool' && (
<div className="tool-layout">
<aside className="tool-catalog">
<h3></h3>
<h4>Core</h4>
<div className="tool-list">
{groupedTools.core.map((tool) => (
<button key={tool.name} type="button" className={toolName === tool.name ? 'active' : ''} onClick={() => setToolName(tool.name)}>
{tool.name}
</button>
))}
</div>
<h4>Analysis</h4>
<div className="tool-list">
{groupedTools.analysis.map((tool) => (
<button key={tool.name} type="button" className={toolName === tool.name ? 'active' : ''} onClick={() => setToolName(tool.name)}>
{tool.name}
</button>
))}
</div>
</aside>
<section className="tool-main">
<div className="tool-top">
<div>
<h3>{toolName || '请选择工具'}</h3>
<p>{toolCatalog.find((tool) => tool.name === toolName)?.description || '暂无描述'}</p>
</div>
<div className="actions">
<button type="button" onClick={() => void handleRunTool()} disabled={!toolName || toolRunning}>
{toolRunning ? <Loader2 className="spin" size={14} /> : <Wrench size={14} />}
</button>
<button type="button" onClick={() => void aiApi.cancelToolTest({})}>
<CircleStop size={14} />
</button>
</div>
</div>
<textarea
className="tool-args"
value={toolArgsText}
onChange={(event) => setToolArgsText(event.target.value)}
placeholder='{"keyword":"买车","limit":10}'
/>
<pre className="tool-output">{toolOutput || '执行结果会显示在这里(超长内容可滚动查看)'}</pre>
</section>
</div>
)}
{errorText ? <div className="global-error">{errorText}</div> : null}
</div>
)
}
export default AiAnalysisPage

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
import { create } from 'zustand'
import type { AgentStreamChunk } from '../types/electron'
interface ConversationRuntimeState {
draft: string
chunks: AgentStreamChunk[]
running: boolean
updatedAt: number
}
interface AiRuntimeStoreState {
activeRunId: string
states: Record<string, ConversationRuntimeState>
startRun: (conversationId: string, runId: string) => void
appendChunk: (conversationId: string, chunk: AgentStreamChunk) => void
finishRun: (conversationId: string) => void
clearConversation: (conversationId: string) => void
}
function nextConversationState(previous?: ConversationRuntimeState): ConversationRuntimeState {
return previous || {
draft: '',
chunks: [],
running: false,
updatedAt: Date.now()
}
}
export const useAiRuntimeStore = create<AiRuntimeStoreState>((set) => ({
activeRunId: '',
states: {},
startRun: (conversationId, runId) => set((state) => {
const prev = nextConversationState(state.states[conversationId])
return {
activeRunId: runId,
states: {
...state.states,
[conversationId]: {
...prev,
draft: '',
chunks: [],
running: true,
updatedAt: Date.now()
}
}
}
}),
appendChunk: (conversationId, chunk) => set((state) => {
const prev = nextConversationState(state.states[conversationId])
const nextDraft = chunk.type === 'content'
? `${prev.draft}${chunk.content || ''}`
: prev.draft
return {
states: {
...state.states,
[conversationId]: {
...prev,
draft: nextDraft,
chunks: [...prev.chunks, chunk].slice(-300),
updatedAt: Date.now(),
running: chunk.type === 'done' || chunk.isFinished ? false : prev.running
}
}
}
}),
finishRun: (conversationId) => set((state) => {
const prev = state.states[conversationId]
if (!prev) return state
return {
activeRunId: '',
states: {
...state.states,
[conversationId]: {
...prev,
draft: '',
running: false,
updatedAt: Date.now()
}
}
}
}),
clearConversation: (conversationId) => set((state) => {
const next = { ...state.states }
delete next[conversationId]
return { states: next }
})
}))

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

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

View File

@@ -7,6 +7,37 @@ export interface SessionChatWindowOpenOptions {
initialContactType?: ContactInfo['type'] initialContactType?: ContactInfo['type']
} }
export interface TokenUsage {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
export interface AgentRuntimeStatus {
phase: 'idle' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'error' | 'aborted'
round?: number
currentTool?: string
toolsUsed?: number
updatedAt: number
totalUsage?: TokenUsage
}
export interface AgentStreamChunk {
runId: string
conversationId?: string
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error'
content?: string
thinkTag?: string
thinkDurationMs?: number
toolName?: string
toolParams?: Record<string, unknown>
toolResult?: unknown
error?: string
isFinished?: boolean
usage?: TokenUsage
status?: AgentRuntimeStatus
}
export interface ElectronAPI { export interface ElectronAPI {
window: { window: {
minimize: () => void minimize: () => void
@@ -482,6 +513,32 @@ export interface ElectronAPI {
filePath?: string filePath?: string
error?: string error?: string
}> }>
getSchema: (payload?: { sessionId?: string }) => Promise<{
success: boolean
schema?: {
generatedAt: number
sources: Array<{
kind: 'message' | 'contact' | 'biz'
path: string | null
label: string
tables: Array<{ name: string; columns: string[] }>
}>
}
schemaText?: string
error?: string
}>
executeSQL: (payload: {
kind: 'message' | 'contact' | 'biz'
path?: string | null
sql: string
limit?: number
}) => Promise<{
success: boolean
rows?: Record<string, unknown>[]
columns?: string[]
total?: number
error?: string
}>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
} }
biz: { biz: {
@@ -1093,6 +1150,245 @@ export interface ElectronAPI {
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => Promise<{ success: boolean; message: string; insight?: string }> }) => Promise<{ success: boolean; message: string; insight?: string }>
} }
aiApi: {
listConversations: (payload?: { page?: number; pageSize?: number }) => Promise<{
success: boolean
conversations?: Array<{
conversationId: string
title: string
createdAt: number
updatedAt: number
lastMessageAt: number
}>
error?: string
}>
createConversation: (payload?: { title?: string }) => Promise<{
success: boolean
conversationId?: string
error?: string
}>
renameConversation: (payload: { conversationId: string; title: string }) => Promise<{ success: boolean; error?: string }>
deleteConversation: (conversationId: string) => Promise<{ success: boolean; error?: string }>
listMessages: (payload: { conversationId: string; limit?: number }) => Promise<{
success: boolean
messages?: Array<{
messageId: string
conversationId: string
role: 'user' | 'assistant' | 'system' | 'tool' | string
content: string
intentType: string
components: any[]
toolTrace: any[]
usage: Record<string, unknown>
error: string
parentMessageId: string
createdAt: number
}>
error?: string
}>
exportConversation: (payload: { conversationId: string }) => Promise<{
success: boolean
conversation?: { conversationId: string; title: string; updatedAt: number }
markdown?: string
error?: string
}>
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 }
}) => Promise<{ success: boolean; runId: string }>
abort: (payload: { runId?: string; conversationId?: string }) => Promise<{ success: boolean }>
onStream: (callback: (payload: AgentStreamChunk) => void) => () => void
}
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 }> }>
}
aiAnalysis: {
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
}>
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
}>
sendMessage: (payload: {
conversationId: string
userInput: string
options?: {
parentMessageId?: string
persistUserMessage?: boolean
assistantId?: string
activeSkillId?: string
chatScope?: 'group' | 'private'
}
}) => Promise<{
success: boolean
result?: {
conversationId: string
messageId: string
assistantText: string
components: any[]
toolTrace: any[]
usage?: {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
error?: string
createdAt: number
}
error?: string
}>
retryMessage: (payload: { conversationId: string; userMessageId?: string }) => Promise<{
success: boolean
result?: {
conversationId: string
messageId: string
assistantText: string
components: any[]
toolTrace: any[]
usage?: {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
error?: string
createdAt: number
}
error?: string
}>
abortRun: (payload: { runId?: string; conversationId?: string }) => Promise<{ success: boolean }>
onRunEvent: (callback: (payload: {
runId: string
conversationId: string
stage: string
ts: number
message: string
intent?: string
round?: number
toolName?: string
status?: string
durationMs?: number
data?: Record<string, unknown>
}) => void) => () => void
}
} }
export interface ExportOptions { export interface ExportOptions {
@@ -1149,6 +1445,11 @@ export interface WxidInfo {
declare global { declare global {
interface Window { interface Window {
electronAPI: ElectronAPI electronAPI: ElectronAPI
aiApi: ElectronAPI['aiApi']
agentApi: ElectronAPI['agentApi']
assistantApi: ElectronAPI['assistantApi']
skillApi: ElectronAPI['skillApi']
llmApi: ElectronAPI['llmApi']
} }
// Electron 类型声明 // Electron 类型声明