import Config from './config.js' import http from './http.js' import store from '@/store/index.js' import lang from './lang.js' let currentConversationId = null; // ====== AI Agent Generalization (customer service is the first agent) ====== // setAgentId updates currentAgentId; default is agent_aikefu. // Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically. // // PR-7: 按 agent_id 路由 endpoint // - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu) // - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent) // - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由 export const DEFAULT_AGENT_ID = 'agent_aikefu'; let currentAgentId = DEFAULT_AGENT_ID; export const CONVERSATION_KEY = 'ai_conversation_id'; /** * PR-7: agent_id -> 后端 addon 路由映射 * * 规则: * - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*) * - 其他 agent -> aiagent addon (新路径 /api/aiagent/*) * * 后端两个 addon 完全独立: * - aikefu: lucky_aikefu_config / conversation / message * - aiagent: lucky_ai_agent_config / conversation / message */ function resolveAddonName(agentId) { if (!agentId || agentId === 'agent_aikefu') return 'aikefu'; return 'aiagent'; } /** * PR-7: agent_id -> API endpoint URL * agent_aikefu -> '/api/kefu/...' (兼容老路径) * 其他 -> '/api/aiagent/...' (ai-agent 新路径) */ function resolveApiUrl(apiName) { var addon = resolveAddonName(currentAgentId); if (addon === 'aikefu') { return '/api/kefu/' + apiName; } return '/api/aiagent/' + apiName; } /** * PR-7: WebSocket URL 路径(/ws/{addonName}) */ function resolveWsPath() { return '/ws/' + resolveAddonName(currentAgentId); } /** * 获取域名配置参数(避免重复代码) */ function getDomainParams() { return { imgDomain: Config.imgDomain, h5Domain: Config.h5Domain, baseUrl: Config.baseUrl }; } // 初始化时从本地读取会话ID try { const saved = uni.getStorageSync(CONVERSATION_KEY); if (saved) { currentConversationId = saved; } } catch (e) { console.warn('读取会话ID失败:', e); } const GUEST_ID_KEY = 'ai_guest_user_id'; /** * 获取用户ID:已登录返回 memberId,未登录生成访客唯一标识并持久化 */ function getUserId() { const memberId = store.state.memberInfo?.id; if (memberId) return memberId; let guestId; try { guestId = uni.getStorageSync(GUEST_ID_KEY); } catch (e) { /* ignore */ } if (!guestId) { guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); try { uni.setStorageSync(GUEST_ID_KEY, guestId); } catch (e) { /* ignore */ } } return guestId; } /** * 获取当前 AI Agent。 */ function getAgentId() { return currentAgentId || DEFAULT_AGENT_ID; } /** * 设置当前 AI Agent。 */ function setAgentId(agentId) { if (agentId && typeof agentId === 'string') currentAgentId = agentId; } // #ifdef MP // ================================ // WebSocket 长连接管理(微信小程序) // ================================ let wsSocketTask = null; let wsAuthenticated = false; let wsConnected = false; let wsConnectionPromise = null; function getWsDomain() { var match = Config.baseUrl.match(/^(?:https?:\/\/|wss?:\/\/)([^\/\s:?#]+(?::\d+)?)/); return match ? match[1] : ''; } function resetWsState() { wsSocketTask = null; wsAuthenticated = false; wsConnected = false; wsConnectionPromise = null; } /** * 确保 WebSocket 已连接并认证,返回一个 Promise * 连接建立后保持不断开,后续可复用 */ function ensureWebSocketConnected() { if (wsConnected && wsAuthenticated) { return Promise.resolve(); } if (wsConnectionPromise) { return wsConnectionPromise; } wsConnectionPromise = new Promise(function(resolve, reject) { var domain = getWsDomain(); if (!domain) { wsConnectionPromise = null; reject(new Error(lang.lang('aiService.wsDomainInvalid'))); return; } // PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent var wsPath = resolveWsPath(); var wssUrl = 'wss://' + domain + wsPath; wsConnected = false; wsAuthenticated = false; wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} }); // 获取认证参数(独立函数,不使用 this) var _uniacid = uni.getStorageSync('uniacid') || Config.uniacid; var _token = store.state.token || 'test_token'; var _userId = getUserId(); wsSocketTask.onOpen(function() { wsConnected = true; wsSocketTask.send({ data: JSON.stringify({ action: 'auth', agent_id: getAgentId(), uniacid: _uniacid, token: _token, user_id: _userId }) }); }); wsSocketTask.onMessage(function(res) { try { var data = JSON.parse(res.data); // Auth 响应 if (data.type === 'auth_success') { wsAuthenticated = true; resolve(); return; } if (data.type === 'auth_failed') { wsAuthenticated = false; wsConnectionPromise = null; reject(new Error(lang.lang('aiService.wsAuthFailed'))); return; } // 以下为聊天消息响应,只在有活跃消息时处理 if (!wsMessageActive) return; if (data.event === 'message') { var text = data.answer || data.content || data.text || ''; // 兼容嵌套 data.data.content 格式 if (!text && data.data) { text = data.data.content || data.data.answer || ''; } wsMessageContent += text; if (wsMessageOnChunk) wsMessageOnChunk(text); } else if (data.event === 'message_end' || data.event === 'done') { // 兼容两种结束信号:message_end(流结束)或 done(服务端完成) // 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中 var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId); // 保存会话ID(独立函数,不使用 this) if (conversationId) { if (conversationId !== currentConversationId) { currentConversationId = conversationId; try { uni.setStorageSync(CONVERSATION_KEY, conversationId); console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); } catch (e) { console.error('保存会话ID失败:', e); } } } var content = wsMessageContent; var convId = conversationId; // 重置消息状态,但不断开连接 wsMessageActive = false; wsMessageContent = ''; if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId }); if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId }); } else { // 兜底日志:打印所有未识别的消息类型 console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); } } catch (e) { console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data); } }); wsSocketTask.onError(function(err) { console.error('WebSocket 连接失败:', err); var prevReject = wsMessageReject; resetWsState(); resetWsMessageState(); if (prevReject) prevReject(new Error(lang.lang('aiService.wsConnectionFailed'))); reject(new Error(lang.lang('aiService.wsConnectionFailed'))); }); wsSocketTask.onClose(function() { console.warn('WebSocket 连接已关闭'); var prevReject = wsMessageReject; resetWsState(); resetWsMessageState(); if (prevReject) prevReject(new Error(lang.lang('aiService.wsConnectionClosed'))); }); }); return wsConnectionPromise; } // 当前消息的回调状态(只支持串行) var wsMessageResolve = null; var wsMessageReject = null; var wsMessageOnChunk = null; var wsMessageOnComplete = null; var wsMessageContent = ''; var wsMessageActive = false; function resetWsMessageState() { wsMessageResolve = null; wsMessageReject = null; wsMessageOnChunk = null; wsMessageOnComplete = null; wsMessageContent = ''; wsMessageActive = false; } /** * 断开 WebSocket 长连接 */ function disconnectWebSocket() { if (wsSocketTask) { try { wsSocketTask.close(); } catch (e) { /* ignore */ } } resetWsState(); resetWsMessageState(); } // #endif export default { /** * 获取uniacid * @returns {number} uniacid */ getUniacid() { return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid }, /** * PR-7: 获取当前 agent 对应的 addon 名称 (aikefu / aiagent) */ getAddonName() { return resolveAddonName(currentAgentId); }, /** * PR-7: 获取当前 agent 对应的 API endpoint 前缀 */ getApiPrefix() { return resolveAddonName(currentAgentId) === 'aikefu' ? '/api/kefu' : '/api/aiagent'; }, /** * 发送普通消息(blocking 模式) * PR-7: URL 按 agent_id 路由 */ async sendMessage(message, options = {}) { try { const params = { url: resolveApiUrl('chat'), data: { agent_id: getAgentId(), user_id: getUserId(), stream: false, inputs: {}, query: message, response_mode: 'blocking', user: getUserId(), conversation_id: this.getConversationId() || '', ...getDomainParams() }, header: { 'Content-Type': 'application/json' } }; const response = await http.sendRequest({ ...params, async: false }); const result = this.handleResponse(response); if (result.conversationId) { this.setConversationId(result.conversationId); } return result; } catch (error) { console.error('[AI Service] AiChat API请求失败,详细错误:', error); console.error('[AI Service] 错误堆栈:', error.stack); console.error('[AI Service] store 状态:', { memberInfo: store.state.memberInfo, uniacid: this.getUniacid(), token: store.state.token }); throw new Error(lang.lang('aiService.serviceUnavailable')); } }, /** * 流式消息入口(自动适配平台) * 微信小程序:复用长连接 WebSocket */ async sendStreamMessage(message, onChunk, onComplete) { // #ifdef MP try { await ensureWebSocketConnected(); } catch (e) { if (onComplete) onComplete({ error: e.message }); throw e; } wsMessageContent = ''; wsMessageActive = true; wsMessageOnChunk = onChunk || null; wsMessageOnComplete = onComplete || null; return new Promise((resolve, reject) => { wsMessageResolve = resolve; wsMessageReject = reject; wsSocketTask.send({ data: JSON.stringify({ action: 'chat', agent_id: getAgentId(), uniacid: this.getUniacid(), query: message, user_id: getUserId(), conversation_id: this.getConversationId() || '', ...getDomainParams() }) }); }); // #endif }, /** * HTTP 流式请求(仅 H5 使用) * PR-7: URL 按 agent_id 路由 */ async sendHttpStream(message, onChunk, onComplete) { // #ifdef H5 try { var chatUrl = resolveApiUrl('chat'); var url = Config.baseUrl + chatUrl; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', }, body: JSON.stringify({ uniacid: this.getUniacid(), token: store.state.token || 'test_token', inputs: {}, query: message, response_mode: 'streaming', agent_id: getAgentId(), user_id: getUserId(), conversation_id: this.getConversationId() || '', ...getDomainParams() }) }); if (!response.ok || !response.body) { throw new Error(lang.lang('aiService.invalidResponse')); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let content = ''; let conversationId = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('data:')) { try { const jsonStr = trimmed.slice(5).trim(); if (jsonStr && jsonStr !== '[DONE]') { const data = JSON.parse(jsonStr); if (data.event === 'message') { var text = data.answer || data.text || ''; if (!text && data.data) { text = data.data.content || data.data.answer || ''; } content += text; if (onChunk) onChunk(text); } var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; if (cid) { conversationId = cid; } if (data.event === 'message_end' || data.event === 'done') { console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid); } } } catch (e) { console.warn('解析失败:', e, line); } } } } if (buffer.trim().startsWith('data:')) { try { const jsonStr = buffer.trim().slice(5); if (jsonStr) { const data = JSON.parse(jsonStr); if (data.event === 'message') { var text = data.answer || data.text || ''; if (!text && data.data) { text = data.data.content || data.data.answer || ''; } content += text; if (onChunk) onChunk(text); } var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; if (cid) { conversationId = cid; } } } catch (e) { console.warn('最后 buffer 解析失败:', e); } } console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length); if (onComplete) { onComplete({ content, conversation_id: conversationId }); } return { content, conversation_id: conversationId }; } catch (error) { console.error('H5 流式请求失败:', error); throw error; } // #endif }, /** * 处理API响应(统一格式) */ handleResponse(response) { if (response.code === 0 || response.success) { return { success: true, content: response.data?.answer || response.data?.content || response.data?.reply || response.data, conversationId: response.data?.conversation_id, messageId: response.data?.message_id }; } else { throw new Error(response.message || lang.lang('aiService.serviceResponseError')); } }, /** * 获取当前会话ID */ getConversationId() { return currentConversationId; }, /** * 设置并持久化会话ID */ setConversationId(id) { if (id && id !== currentConversationId) { currentConversationId = id; try { uni.setStorageSync(CONVERSATION_KEY, id); } catch (e) { console.error('保存会话ID失败:', e); } } }, /** * 清除会话ID */ clearConversationId() { currentConversationId = null; try { uni.removeStorageSync(CONVERSATION_KEY); } catch (e) { console.error('清除会话ID失败:', e); } }, /** * 生成新会话(如需) * PR-7: URL 按 agent_id 路由 */ async generateConversationId() { const params = { url: resolveApiUrl('createConversation'), data: { agent_id: getAgentId(), uniacid: this.getUniacid(), token: store.state.token || 'test_token', user_id: getUserId(), member_id: getUserId(), ...getDomainParams() }, header: { 'Content-Type': 'application/json' } }; const response = await http.sendRequest({ ...params, async: false }); return this.handleResponse(response); }, /** * 获取服务状态 * PR-7: URL 按 agent_id 路由 */ async getServiceStatus() { try { const response = await http.sendRequest({ url: resolveApiUrl('health'), async: false, data: {} }); const available = [response?.data?.status, response?.data?.components?.ai_service_config?.status] .filter(item => item === 'healthy').length > 0; return { available, reason: available ? lang.lang('aiService.statusNormal') : lang.lang('aiService.statusAbnormal') }; } catch (error) { return { available: false, reason: lang.lang('aiService.statusCheckFailed') }; } }, /** * 获取服务配置信息 * PR-7: URL 按 agent_id 路由 */ async getServiceConfig() { try { const params = { url: resolveApiUrl('info'), data: { agent_id: getAgentId(), uniacid: this.getUniacid(), token: store.state.token || 'test_token', user_id: getUserId(), member_id: getUserId(), locale: lang.getCurrentLocale() }, header: { 'Content-Type': 'application/json' } }; const response = await http.sendRequest({ ...params, async: false, }); console.log('[AI Service] getServiceConfig response:', JSON.stringify(response)); return response.data; } catch (error) { console.error('[AI Service] getServiceConfig error:', error); return null; } }, /** * 清除会话历史(调用后端接口) * PR-7: URL 按 agent_id 路由 */ async clearConversation(conversationId) { try { const response = await http.sendRequest({ url: resolveApiUrl('clear-conversation'), data: { agent_id: getAgentId(), conversation_id: conversationId, ...getDomainParams() }, async: false }); return response.success; } catch (error) { console.error('清除会话失败:', error); return false; } }, /** * 获取会话历史 * PR-7: URL 按 agent_id 路由 */ async getConversationHistory(params = {}) { try { if (!params.conversation_id) { throw new Error(lang.lang('aiService.conversationIdRequired')); } console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit); const requestData = { agent_id: params.agent_id || getAgentId(), uniacid: params.uniacid || this.getUniacid(), conversation_id: params.conversation_id, user_id: params.user_id || getUserId(), limit: params.limit || 20, offset: params.offset || 0, member_id: params.member_id || getUserId(), token: params.token || '', ...getDomainParams() }; const response = await http.sendRequest({ url: resolveApiUrl('getHistory'), data: requestData, header: { 'Content-Type': 'application/json' }, async: false }); if (response.code === 0 || response.success) { return { success: true, data: response.data, messages: response.data?.messages || [], total: response.data?.total || 0, page_info: response.data?.page_info || { limit: 20, offset: 0 } }; } else { throw new Error(response.message || lang.lang('aiService.getHistoryFailed')); } } catch (error) { console.error('获取会话历史失败:', error); throw new Error(error.message || lang.lang('aiService.getHistoryError')); } }, // ================================ // 以下方法仅用于 H5,小程序无效 // ================================ /** * [H5 Only] EventSource 流式聊天(备用) * PR-7: URL 按 agent_id 路由 */ chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) { // #ifdef H5 if (window.currentEventSource) { window.currentEventSource.close(); } const paramsObj = { agent_id: getAgentId(), uniacid: this.getUniacid(), user_id: getUserId(), member_id: getUserId(), token: store.state.token || 'test_token', query: message, conversation_id: conversationId || '', stream: 'true', ...getDomainParams() }; const params = new URLSearchParams(paramsObj); var chatUrl = resolveApiUrl('chat'); const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`; try { const eventSource = new EventSource(url); window.currentEventSource = eventSource; let aiMessage = ''; eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.event === 'message') { aiMessage += data.answer || ''; if (onMessage) onMessage(data.answer || ''); } if (data.event === 'message_end') { if (onComplete) onComplete({ conversation_id: data.conversation_id, message: aiMessage }); } } catch (error) { console.error('解析消息失败:', error); } }; eventSource.onerror = (error) => { console.error('EventSource错误:', error); if (onError) onError({ error: lang.lang('aiService.connectionFailed') }); window.currentEventSource = null; }; return eventSource; } catch (error) { console.error('创建EventSource失败:', error); if (onError) onError({ error: error.message }); return null; } // #endif // #ifdef MP console.warn('chatWithEventSource 不支持微信小程序'); return null; // #endif }, /** * [H5 Only] Fetch 流式聊天(备用) */ async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) { // #ifdef H5 return this.sendHttpStream(message, onMessage, (res) => { onComplete?.({ message: res.content, conversation_id: res.conversation_id }); }); // #endif // #ifdef MP console.warn('chatWithFetchStream 不支持微信小程序'); return null; // #endif }, // #ifdef MP /** * [微信小程序] 断开 WebSocket 长连接(页面离开时调用) */ disconnectWebSocket() { disconnectWebSocket(); }, // #endif // #ifdef H5 disconnectWebSocket() { // H5 无 WebSocket 长连接,无需操作 }, // #endif /** * Set current AI Agent (default aikefu; link system passes agent_id). * PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了) * @param {string} agentId */ setAgentId(agentId) { var oldAddon = resolveAddonName(currentAgentId); setAgentId(agentId); var newAddon = resolveAddonName(currentAgentId); if (oldAddon !== newAddon) { try { disconnectWebSocket(); } catch (e) { /* ignore */ } } }, /** * Get current AI Agent. * @returns {string} */ getAgentId() { return getAgentId(); }, };