1344 lines
47 KiB
JavaScript
1344 lines
47 KiB
JavaScript
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_<agentId> (只按 agent 隔离, 跨用户/跨 uniacid 串会话)
|
||
// 新 key: ai_conv_<uniacid>_<userId>_<agentId> (按 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) || [],
|
||
...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 || [],
|
||
...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) || [] });
|
||
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) || [],
|
||
...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<boolean>}
|
||
*/
|
||
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();
|
||
},
|
||
}; |