Files
lucky_shop/common/js/ai-service.js
ZF sun ffa170ff13 feat(ai): PR-7 前端 ai-service 按 agent_id 路由 endpoint
背景:
- 后端 aikefu 和 ai-agent 两个独立 addon (PR-6 v2.0)
- 前端 ai-chat 之前所有 API endpoint 都 hardcode 到 /api/kefu/* + /ws/aikefu
- 5 个 agent (智能客服默认 + 4 个 AI智能体) 实际上无法被前端独立路由

改动:
- 新增 resolveAddonName(agentId) -> 'aikefu' / 'aiagent'
- 新增 resolveApiUrl(apiName) -> '/api/kefu/X' / '/api/aiagent/X'
- 新增 resolveWsPath() -> '/ws/aikefu' / '/ws/aiagent'

- 全量替换 sendMessage / sendHttpStream / sendStreamMessage / generateConversationId /
  getServiceStatus / getServiceConfig / clearConversation / getConversationHistory /
  chatWithEventSource / WebSocket connect URL:
  全部用 resolveApiUrl / resolveWsPath 动态计算

- setAgentId 跨 addon 切换时自动 disconnectWebSocket (路径变了)
- 新增 getAddonName() / getApiPrefix() 测试辅助方法

后端配套 PR-8+9:
- /api/aiagent/api/Chat/chat (HTTP, 含流式 SSE)
- /api/aiagent/api/Chat/{info,health,getHistory,clearConversation,createConversation}
- /ws/aiagent WebSocket 端点 (从 aikefu WebSocket 复制改名)

路由规则:
- agent_aikefu -> aikefu addon (老路径, 默认智能客服)
- 其他 agent (knowledge / quick_reply / sales / hr) -> aiagent addon (新路径)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:30:00 +08:00

826 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
},
};