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 路由 // 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口 // /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。 export const DEFAULT_AGENT_ID = 'agent_aikefu'; let currentAgentId = DEFAULT_AGENT_ID; // PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离 // 老 key: ai_conversation_id_ (只按 agent 隔离, 跨用户/跨 uniacid 串会话) // 新 key: ai_conv___ (按 uniacid × userId × agent 三维隔离) // 一次性迁移: 读老 key → 新 key 为空 → 写入新 key 后保留老 key (1 版本后清理) export const CONVERSATION_KEY_PREFIX = 'ai_conv_'; const LEGACY_KEY_PREFIX = 'ai_conversation_id_'; export function getConversationKey(agentId) { let siteId = 0; let userId = 'anon'; try { siteId = uni.getStorageSync('uniacid') || Config.uniacid || 0; } catch (e) { /* ignore */ } try { userId = getUserId() || 'anon'; } catch (e) { /* ignore */ } return CONVERSATION_KEY_PREFIX + siteId + '_' + userId + '_' + (agentId || DEFAULT_AGENT_ID); } // 一次性老 key 迁移: 新 key 为空且老 key 有值 → 写入新 key, 保留老 key 1 版本兜底 function migrateLegacyConversationKey(agentId) { try { const legacyKey = LEGACY_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID); const legacy = uni.getStorageSync(legacyKey); const newKey = getConversationKey(agentId); if (legacy && !uni.getStorageSync(newKey)) { uni.setStorageSync(newKey, legacy); } } catch (e) { /* ignore */ } } // 兼容旧代码: 单 key 别名(指向默认 agent) — 给老代码引用,值是动态计算的 export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID); // 统一 SSE/WS 事件分发器 (透出所有事件类型) let eventHandlers = { onEvent: null }; function dispatchEvent(onEvent, event, data) { if (typeof onEvent === 'function') { try { onEvent(event, data); } catch (e) { console.error('[AI Service] onEvent handler threw:', e); } } } /** * 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(按当前 agent 隔离) // PR-MULTI-TURN-KEY-BINDING (2026-09-18): 先迁移老 key 再读新 key // H5 时序坑: 模块顶层同步执行时 uni.getStorageSync 仍返回空字符串 // (uniapp H5 runtime shim 在 webpack bundle 之后才注入),延迟 1 个 tick // 让 uni runtime 就绪后再读 — 实测 100ms 后能读到值 try { setTimeout(() => { try { migrateLegacyConversationKey(currentAgentId); const saved = uni.getStorageSync(getConversationKey(currentAgentId)); if (saved) { currentConversationId = saved; } } catch (e) { console.warn('读取会话ID失败:', e); } }, 0); } 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; } /** * 递归在 info 响应中查找配置 uuid (媒体上传必填)。 * 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。 */ function findConfigUuid(obj, depth) { if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return ''; var keys; try { keys = Object.keys(obj); } catch (e) { return ''; } for (var i = 0; i < keys.length; i++) { var k = keys[i]; if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]); } for (var j = 0; j < keys.length; j++) { var v = obj[keys[j]]; if (v && typeof v === 'object') { var found = findConfigUuid(v, (depth || 0) + 1); if (found) return found; } } return ''; } /** * 获取当前 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; // 优先走 onEvent 事件分发(透出 thought/tool_call/question 等) var onEvt = eventHandlers.onEvent; switch (data.event) { case 'message': { var text = data.answer || data.content || data.text || ''; // 兼容嵌套 data.data.content 格式 if (!text && data.data) { text = data.data.content || data.data.answer || ''; } // B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识 if (text && text.startsWith('> ')) { dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id }); break; } wsMessageContent += text; if (wsMessageOnChunk) wsMessageOnChunk(text); dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id }); break; } case 'thought': case 'thinking': case 'reasoning': { // 兼容字段: thought / thinking / reasoning / content / answer var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || ''; console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id); dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id }); break; } case 'tool_call': dispatchEvent(onEvt, 'tool_call', data.tool_call || {}); break; case 'question': dispatchEvent(onEvt, 'question', { pending_question: data.pending_question || (data.data && data.data.pending_question), message_id: data.message_id, conversation_id: data.conversation_id }); break; case 'message_end': case 'done': { // 兼容两种结束信号:message_end(流结束)或 done(服务端完成) 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(按当前 agent 隔离) if (conversationId) { if (conversationId !== currentConversationId) { currentConversationId = conversationId; try { uni.setStorageSync(getConversationKey(currentAgentId), conversationId); console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); } catch (e) { console.error('保存会话ID失败:', e); } } } var content = wsMessageContent; var convId = conversationId; // 重置消息状态,但不断开连接 var endPayload = { conversation_id: convId, message_id: data.message_id, content: data.content || content, thoughts: data.thoughts || '', tool_calls: data.tool_calls || [], tokens: data.tokens || null, phase: data.phase || 'completed', stop_reason: data.stop_reason || '', effective_model: data.effective_model || '' }; wsMessageActive = false; wsMessageContent = ''; dispatchEvent(onEvt, 'message_end', endPayload); if (wsMessageOnComplete) wsMessageOnComplete(endPayload); if (wsMessageResolve) wsMessageResolve(endPayload); break; } case 'error': dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' }); break; case 'ping': // keep-alive, 忽略 break; default: // 兜底: 老格式 data.data.content if (data.data && data.data.content) { var defText = data.data.content; wsMessageContent += defText; if (wsMessageOnChunk) wsMessageOnChunk(defText); dispatchEvent(onEvt, 'message', { content: defText }); } 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 // PR-FIX (2026-09-20): getServiceConfig 短 TTL 缓存 // 服务配置(含欢迎语)变更频率极低, 每次进聊天页都打 /api/kefu/info 纯属浪费; // TTL 60s + 失败不缓存, 配置保存后最多延迟 1 分钟生效 var _serviceConfigCache = null; var _serviceConfigCacheAt = 0; var SERVICE_CONFIG_CACHE_TTL = 60 * 1000; function resetServiceConfigCache() { _serviceConfigCache = null; _serviceConfigCacheAt = 0; } // PR-CHAT-UI-CONFIG (2026-09-20): chat_ui_config 前端 fallback 默认值 // 与后端 addon/aikefu/model/Config.php::DEFAULT_UI_CONFIG 字段一一对应 // 改任何一项必须同步后端常量, 否则前后端默认分叉 // // PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20): // ui_available_models 必须完全来自后端 (Config.php::DEFAULT_UI_CONFIG), // 前端不维护实际清单。仅保留 'Auto' 兜底: 后端 /info 调用失败 / 返回空数组时, // 模型选择器仍可显示 Auto。 // 数据源 (后端维护): https://docs.openclaw.ai/providers // 商家可在后台自由增删任意自定义模型名 (自部署/微调), 仅 trim + 去空 + 去重 + 单条 ≤30 字符 + 总数 ≤100。 const CHAT_UI_DEFAULTS = { ui_show_thinking_process: true, ui_enable_attachment_types: ['image', 'file', 'audio', 'video'], ui_input_max_length: 500, ui_available_models: ['Auto'], ui_enable_feedback: true, // PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 ui_assistant_name: '', // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 ui_show_assistant_name: true, ui_show_assistant_avatar: true, ui_assistant_avatar: '', ui_show_message_time: true, ui_time_format: 'auto', ui_time_humanize: false }; // chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步); // 不在 _serviceConfigCache 里读是因为 chat 组件可能在 getServiceConfig 还没返回前就调它 var _chatUiConfigCache = null; var _chatUiConfigCacheAt = 0; function mergeChatUiConfig(remote) { // remote 是 /info 响应里的 chat_ui_config 对象, 缺字段用 defaults 兜底 // 注意: 不深拷贝数组 → 后端返回的就是最终数组, 不再覆盖 var merged = {}; Object.keys(CHAT_UI_DEFAULTS).forEach(function(k) { if (remote && Object.prototype.hasOwnProperty.call(remote, k) && remote[k] !== null && remote[k] !== undefined) { merged[k] = remote[k]; } else { merged[k] = CHAT_UI_DEFAULTS[k]; } }); return merged; } 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() || '', attachments: (options && options.attachments) || [], // PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文 // 给 WbAgentOsClient 提供 timestamp/timezone/location/page/metadata 等基础元素 context: (options && options.context) || null, ...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, onEvent, onComplete, opts = {}) { // #ifdef MP try { await ensureWebSocketConnected(); } catch (e) { // WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署) // 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口 console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message); return this._fallbackToHttp(message, onEvent, onComplete, opts); } wsMessageContent = ''; wsMessageActive = true; eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null; // onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null); wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null); return new Promise((resolve, reject) => { wsMessageResolve = resolve; wsMessageReject = reject; try { wsSocketTask.send({ data: JSON.stringify({ action: 'chat', agent_id: getAgentId(), uniacid: this.getUniacid(), query: message, user_id: getUserId(), conversation_id: this.getConversationId() || '', attachments: opts.attachments || [], // PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文 context: (opts && opts.context) || null, ...getDomainParams() }) }); } catch (sendErr) { console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message); // 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡 this._fallbackToHttp(message, onEvent, onComplete, opts) .then((res) => resolve(res)) .catch((err) => reject(err)); } }); // #endif }, /** * [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复 * 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名 */ async _fallbackToHttp(message, onEvent, onComplete, opts = {}) { try { const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [], // PR-BASIC-CONTEXT-TO-WB (2026-09-28): MP HTTP 降级路径同步传递 context context: (opts && opts.context) || null }); const content = result && result.content ? result.content : ''; // 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)" const fallbackPayload = { content, conversation_id: result.conversationId, thoughts: result.thoughts || '', tokens: result.tokens || null }; if (content || result.thoughts) { if (typeof onEvent === 'function') { if (result.thoughts) onEvent('thought', { content: result.thoughts }); onEvent('message', { content }); onEvent('message_end', fallbackPayload); } else { if (onComplete) onComplete(fallbackPayload); } return fallbackPayload; } const emptyErr = new Error(lang.lang('aiService.serviceUnavailable')); if (onComplete) onComplete({ error: emptyErr.message }); throw emptyErr; } catch (err) { console.error('[AI Service] MP HTTP 降级请求也失败:', err); if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') }); throw err; } }, /** * HTTP 流式请求(仅 H5 使用) * PR-7: URL 按 agent_id 路由 */ async sendHttpStream(message, onEvent, onComplete, opts = {}) { // #ifdef H5 try { var chatUrl = resolveApiUrl('chat'); // PR-AI-CHAT-URL-NORMALIZE (2026-09-19): Config.baseUrl 文档约定以 '/' 结尾 (config.js:25), // resolveApiUrl 返 '/api/kefu/chat' 也以 '/' 开头 → 直接拼接会出 '//api/kefu/chat' (双斜杠)。 // nginx 实际会 normalize, 但 console 会脏、第三方抓包看不懂, 单点 normalize 消除连续 //。 // 只动 URL 拼接本身, 不改 baseUrl 约定 (整个项目所有 API 都依赖这个约定)。 var url = (Config.baseUrl + chatUrl).replace(/([^:])\/\//g, '$1/'); 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() || '', attachments: (opts && opts.attachments) || [], // PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文 context: (opts && opts.context) || null, ...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 = ''; const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); }; 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:')) continue; const jsonStr = trimmed.slice(5).trim(); if (!jsonStr || jsonStr === '[DONE]') continue; try { const data = JSON.parse(jsonStr); // PR-SSE-ERROR-PASSTHROUGH (2026-09-19): blocking 错误响应 (aikefu Kefu.php:558) // 或 aiagent Chat.php catch 块会在 stream 模式下也返 {"code":-1,"message":"..."} // 这种响应没 event 字段, 不应进 switch 的 default (会污染 console) if (data && typeof data === 'object' && !Array.isArray(data) && data.code !== undefined && data.code !== 0 && !data.event) { console.warn('[AI SSE] 后端错误响应:', data.code, data.message); fire('error', { code: data.code, message: data.message || '请求失败' }); break; } // 空数组是 PHP json_encode([]) 的产物 (如 attachments 或 history 字段), // 不是事件, 静默跳过 if (Array.isArray(data)) { continue; } switch (data.event) { case 'message': { var text = data.answer || data.text || data.content || ''; if (!text && data.data) text = data.data.content || data.data.answer || ''; // B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识 if (text && text.startsWith('> ')) { fire('thought', { content: text, message_id: data.message_id }); break; } content += text; fire('message', { content: text, message_id: data.message_id }); break; } case 'thought': case 'thinking': case 'reasoning': { // 兼容字段: thought / thinking / reasoning / content / answer var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || ''; fire('thought', { content: thoughtText2, message_id: data.message_id }); break; } case 'tool_call': fire('tool_call', data.tool_call || {}); break; case 'question': fire('question', { pending_question: data.pending_question || (data.data && data.data.pending_question), message_id: data.message_id, conversation_id: data.conversation_id }); break; case 'message_end': case 'done': { const cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; if (cid) conversationId = cid; console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid); fire('message_end', { conversation_id: cid, message_id: data.message_id, content: data.content || content, thoughts: data.thoughts || '', tool_calls: data.tool_calls || [], tokens: data.tokens || null, phase: data.phase || 'completed', stop_reason: data.stop_reason || '', effective_model: data.effective_model || '' }); break; } case 'error': fire('error', { code: data.code || -1, message: data.message || 'unknown' }); break; default: // B 路 Dify 把思考塞 answer 里 (> 前缀) if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) { fire('thought', { content: data.answer }); } else if (data.event === 'ping') { // keep-alive } else { console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200)); } } } catch (e) { console.warn('解析失败:', e, line); } } } 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, thoughts: response.data?.thoughts || '', tokens: response.data?.tokens || null }; } else { throw new Error(response.message || lang.lang('aiService.serviceResponseError')); } }, /** * 获取当前会话ID */ getConversationId() { return currentConversationId; }, /** * 设置并持久化会话ID * PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key */ setConversationId(id) { if (id && id !== currentConversationId) { currentConversationId = id; try { migrateLegacyConversationKey(currentAgentId); uni.setStorageSync(getConversationKey(currentAgentId), id); } catch (e) { console.error('保存会话ID失败:', e); } } }, /** * PR-MULTI-TURN-KEY-BINDING (2026-09-18): 用户切换钩子 * App.vue 在 setMemberInfo 之后调用,触发清旧 user key + 重置 currentConversationId * 避免"用户 A 退出 → 用户 B 登录 → 直接复用 A 的会话 ID" 串会话 bug * * 调用时机: * - App.vue onLaunch 时如果检测到 memberInfo 恢复 → 调一次 * - 任何 setMemberInfo 提交后调一次 * 行为: * - 比对新老 user_id,不同时移除"旧 user"维度的 storage key * - 重置 currentConversationId 为 null (强制下一轮 chat 用新 user 的会话或新建) * - 记录 last_user_id 用于下次对比 */ onUserChange() { try { const newUid = String(getUserId() || 'anon'); const oldUid = uni.getStorageSync('ai_last_user_id'); if (oldUid && oldUid !== newUid) { // 清旧用户 key 维度 (按当前 agent 即可,下次 setAgentId 会重新计算) const oldKey = CONVERSATION_KEY_PREFIX + (uni.getStorageSync('uniacid') || Config.uniacid || 0) + '_' + oldUid + '_' + (currentAgentId || DEFAULT_AGENT_ID); try { uni.removeStorageSync(oldKey); } catch (e) { /* ignore */ } } uni.setStorageSync('ai_last_user_id', newUid); currentConversationId = null; } catch (e) { console.warn('[ai-service] onUserChange failed:', e && e.message); } }, /** * 清除会话ID */ clearConversationId() { currentConversationId = null; try { uni.removeStorageSync(getConversationKey(currentAgentId)); } 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() { // PR-FIX (2026-09-20): 命中缓存直接返回 const nowTs = Date.now(); if (_serviceConfigCache && (nowTs - _serviceConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) { console.log('[AI Service] getServiceConfig 命中缓存 (age=' + Math.round((nowTs - _serviceConfigCacheAt) / 1000) + 's)'); return _serviceConfigCache; } 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)); // 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。 const cfg = response && response.data; const uuid = findConfigUuid(cfg, 0); if (uuid) { uni.setStorageSync('current_config_uuid', uuid); console.log('[AI Service] 已缓存 config_uuid:', uuid); } else { console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg)); } // PR-FIX (2026-09-20): 成功响应写缓存 (失败不缓存, 下次重试) _serviceConfigCache = response.data; _serviceConfigCacheAt = Date.now(); return response.data; } catch (error) { console.error('[AI Service] getServiceConfig error:', error); return null; } }, /** * PR-FIX (2026-09-20): 清除服务配置缓存 * 供配置变更后主动调用 (目前 60s TTL 已足够, 预留显式失效入口) */ clearServiceConfigCache() { resetServiceConfigCache(); _chatUiConfigCache = null; _chatUiConfigCacheAt = 0; }, /** * PR-CHAT-UI-CONFIG (2026-09-20): 获取前端 UI 行为开关 * 5 个 P0 开关由后端 Config::getChatUiConfig() 下发, 字段缺失用前端 CHAT_UI_DEFAULTS 兜底 * 缓存策略: 60s TTL, 失败不缓存 (下次重试), 与 getServiceConfig 共享 /info 调用 * 入参: force=true 跳过缓存 (用于用户切换站点/账号后强制刷新) */ async getChatUiConfig(force) { const nowTs = Date.now(); if (!force && _chatUiConfigCache && (nowTs - _chatUiConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) { return _chatUiConfigCache; } try { // 复用 getServiceConfig() 触发 /info; 它的缓存会带回 chat_ui_config 字段 // 若 service_config 缓存未命中, getServiceConfig() 内部会发请求并填充 _serviceConfigCache const serviceData = await this.getServiceConfig(); const remote = (serviceData && serviceData.chat_ui_config) || null; const merged = mergeChatUiConfig(remote); _chatUiConfigCache = merged; _chatUiConfigCacheAt = Date.now(); // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时 // getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁) try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ } return merged; } catch (e) { console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e); // 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试 return Object.assign({}, CHAT_UI_DEFAULTS); } }, /** * PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存) * 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用, * 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。 * 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过) * 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新 */ getChatUiConfigSync() { try { const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG'); if (cached && typeof cached === 'object' && Object.keys(cached).length) { return mergeChatUiConfig(cached); } } catch (e) { /* 存储读取失败按无缓存处理 */ } return null; }, /** * 清除会话历史(调用后端接口) * PR-7: URL 按 agent_id 路由 */ async clearConversation(conversationId) { try { // PR-AI-CLEAR-CONV-FIX (2026-09-19): URL 必须用 camelCase // ThinkPHP 后端方法名 = clearConversation, URL 不做 kebab→camel 转换 // 之前 'clear-conversation' 拼出 /api/kefu/clear-conversation → 404 method not exists // 改 'clearConversation' → /api/kefu/clearConversation → 200 const response = await http.sendRequest({ url: resolveApiUrl('clearConversation'), data: { agent_id: getAgentId(), conversation_id: conversationId, ...getDomainParams() }, async: false }); return response.success; } catch (error) { console.error('清除会话失败:', error); return false; } }, /** * 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项 * @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers } * @returns {Promise} */ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) { // #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY try { await ensureWebSocketConnected(); } catch (e) { console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message); return false; } wsSocketTask.send({ data: JSON.stringify({ action: 'elicit_response', conversation_id, message_id, tool_call_id, rpc_id, answers, uniacid: this.getUniacid(), token: store.state.token || 'test_token' }) }); return true; // #endif // #ifdef H5 // PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点 // 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response // (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/... // 会拆出 module=api, controller=kefu/aiagent, action=chat → 命中 chat() 而非 elicitResponse() // (PR-WB-MP-MEDIA 注释里记录的同款 thinkphp 路由陷阱) // 必须用 resolveAddonName() 拿 addon 全名直接拼 URL try { const addon = resolveAddonName(currentAgentId); // aikefu / aiagent const url = Config.baseUrl + '/' + addon + '/api/chat/elicit-response'; const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Token': store.state.token || 'test_token' }, body: JSON.stringify({ conversation_id, message_id, tool_call_id, rpc_id, answers, uniacid: this.getUniacid(), user_id: getUserId(), token: store.state.token || 'test_token' }) }); const data = await resp.json(); return !!(data && (data.code === 0 || data.success)); } catch (e) { console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message); return false; } // #endif }, /** * 获取会话历史 * 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 重连 (路径变了) * * PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时 * 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑" * - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对) * - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话" * * @param {string} agentId * @param {object} opts { silent: bool } true = 不回调, 默认 false */ setAgentId(agentId, opts) { var oldAgentId = currentAgentId; var oldAddon = resolveAddonName(currentAgentId); setAgentId(agentId); var newAddon = resolveAddonName(currentAgentId); if (oldAddon !== newAddon) { try { disconnectWebSocket(); } catch (e) { /* ignore */ } } // PR-AGENT-LIFECYCLE-CLEANUP: agent 字符串真变化 (A → B) → 清 conv_id 缓存 // 不变化 (aikefu → aikefu 同 string) → 保留缓存, 让用户继续之前的多轮对话 var agentChanged = oldAgentId && oldAgentId !== agentId; currentConversationId = null; try { const saved = uni.getStorageSync(getConversationKey(currentAgentId)); if (saved && !agentChanged) currentConversationId = saved; else if (saved && agentChanged) { // agent 变了, 把老 agent 的 conv_id 也清掉, 避免老 user 换个 product page 又用回 try { const oldKey = getConversationKey(oldAgentId); if (oldKey) uni.removeStorageSync(oldKey); } catch (e) {} } } catch (e) { /* ignore */ } }, /** * 获取当前 agent 的会话存储 key (per-agent 隔离) */ getConversationKey(agentId) { return getConversationKey(agentId); }, /** * 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选) */ getAllConversations() { const out = {}; try { const info = uni.getStorageInfoSync(); (info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0) .forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); }); } catch (e) { /* ignore */ } return out; }, /** * 获取当前 config_uuid (媒体上传路由用) * 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid) */ getCurrentConfigUuid() { return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || ''; }, /** * 获取用户 ID (媒体上传字段用) */ getUserId() { return getUserId(); }, /** * Get current AI Agent. * @returns {string} */ getAgentId() { return getAgentId(); }, };