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>
This commit is contained in:
2026-08-13 18:30:00 +08:00
parent 8a95b0310d
commit ffa170ff13

View File

@@ -8,11 +8,52 @@ let currentConversationId = null;
// ====== AI Agent Generalization (customer service is the first agent) ====== // ====== AI Agent Generalization (customer service is the first agent) ======
// setAgentId updates currentAgentId; default is agent_aikefu. // setAgentId updates currentAgentId; default is agent_aikefu.
// Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically. // 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'; export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID; let currentAgentId = DEFAULT_AGENT_ID;
export const CONVERSATION_KEY = 'ai_conversation_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);
}
/** /**
* 获取域名配置参数(避免重复代码) * 获取域名配置参数(避免重复代码)
*/ */
@@ -42,12 +83,12 @@ const GUEST_ID_KEY = 'ai_guest_user_id';
function getUserId() { function getUserId() {
const memberId = store.state.memberInfo?.id; const memberId = store.state.memberInfo?.id;
if (memberId) return memberId; if (memberId) return memberId;
let guestId; let guestId;
try { try {
guestId = uni.getStorageSync(GUEST_ID_KEY); guestId = uni.getStorageSync(GUEST_ID_KEY);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
if (!guestId) { if (!guestId) {
guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
try { try {
@@ -112,7 +153,9 @@ function ensureWebSocketConnected() {
return; return;
} }
var wssUrl = 'wss://' + domain + '/ws/aikefu'; // PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
var wsPath = resolveWsPath();
var wssUrl = 'wss://' + domain + wsPath;
wsConnected = false; wsConnected = false;
wsAuthenticated = false; wsAuthenticated = false;
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} }); wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
@@ -255,20 +298,35 @@ export default {
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid 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 模式) * 发送普通消息(blocking 模式)
* PR-7: URL 按 agent_id 路由
*/ */
async sendMessage(message, options = {}) { async sendMessage(message, options = {}) {
try { try {
const params = { const params = {
url: '/api/kefu/chat', url: resolveApiUrl('chat'),
data: { data: {
agent_id: getAgentId(), agent_id: getAgentId(),
user_id: getUserId(), user_id: getUserId(),
stream: false, stream: false,
inputs: {}, inputs: {},
query: message, query: message,
response_mode: 'blocking', // 强制 blocking response_mode: 'blocking',
user: getUserId(), user: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
...getDomainParams() ...getDomainParams()
@@ -291,7 +349,6 @@ export default {
} catch (error) { } catch (error) {
console.error('[AI Service] AiChat API请求失败,详细错误:', error); console.error('[AI Service] AiChat API请求失败,详细错误:', error);
console.error('[AI Service] 错误堆栈:', error.stack); console.error('[AI Service] 错误堆栈:', error.stack);
// 打印更多信息帮助调试
console.error('[AI Service] store 状态:', { console.error('[AI Service] store 状态:', {
memberInfo: store.state.memberInfo, memberInfo: store.state.memberInfo,
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
@@ -307,7 +364,6 @@ export default {
*/ */
async sendStreamMessage(message, onChunk, onComplete) { async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP // #ifdef MP
// 先确保连接已就绪
try { try {
await ensureWebSocketConnected(); await ensureWebSocketConnected();
} catch (e) { } catch (e) {
@@ -315,7 +371,6 @@ export default {
throw e; throw e;
} }
// 设置当前消息回调
wsMessageContent = ''; wsMessageContent = '';
wsMessageActive = true; wsMessageActive = true;
wsMessageOnChunk = onChunk || null; wsMessageOnChunk = onChunk || null;
@@ -339,13 +394,16 @@ export default {
}); });
// #endif // #endif
}, },
/** /**
* HTTP 流式请求(仅 H5 使用) * HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/ */
async sendHttpStream(message, onChunk, onComplete) { async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5 // #ifdef H5
try { try {
const url = Config.baseUrl + `/api/kefu/chat`; var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -358,6 +416,7 @@ export default {
inputs: {}, inputs: {},
query: message, query: message,
response_mode: 'streaming', response_mode: 'streaming',
agent_id: getAgentId(),
user_id: getUserId(), user_id: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
...getDomainParams() ...getDomainParams()
@@ -380,9 +439,8 @@ export default {
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
// 按行分割,保留不完整的最后一行
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 未完成的行留到下次 buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -393,14 +451,12 @@ export default {
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
if (data.event === 'message') { if (data.event === 'message') {
var text = data.answer || data.text || ''; var text = data.answer || data.text || '';
// 兼容嵌套 data.data 格式
if (!text && data.data) { if (!text && data.data) {
text = data.data.content || data.data.answer || ''; text = data.data.content || data.data.answer || '';
} }
content += text; content += text;
if (onChunk) onChunk(text); if (onChunk) onChunk(text);
} }
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) { if (cid) {
conversationId = cid; conversationId = cid;
@@ -416,7 +472,6 @@ export default {
} }
} }
// 处理最后残留的 buffer(如果有)
if (buffer.trim().startsWith('data:')) { if (buffer.trim().startsWith('data:')) {
try { try {
const jsonStr = buffer.trim().slice(5); const jsonStr = buffer.trim().slice(5);
@@ -503,10 +558,11 @@ export default {
/** /**
* 生成新会话(如需) * 生成新会话(如需)
* PR-7: URL 按 agent_id 路由
*/ */
async generateConversationId() { async generateConversationId() {
const params = { const params = {
url: '/api/kefu/createConversation', url: resolveApiUrl('createConversation'),
data: { data: {
agent_id: getAgentId(), agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
@@ -528,11 +584,12 @@ export default {
/** /**
* 获取服务状态 * 获取服务状态
* PR-7: URL 按 agent_id 路由
*/ */
async getServiceStatus() { async getServiceStatus() {
try { try {
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/health', url: resolveApiUrl('health'),
async: false, async: false,
data: {} data: {}
}); });
@@ -552,11 +609,12 @@ export default {
/** /**
* 获取服务配置信息 * 获取服务配置信息
* PR-7: URL 按 agent_id 路由
*/ */
async getServiceConfig() { async getServiceConfig() {
try { try {
const params = { const params = {
url: '/api/kefu/info', url: resolveApiUrl('info'),
data: { data: {
agent_id: getAgentId(), agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
@@ -568,7 +626,7 @@ export default {
header: { header: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}; };
const response = await http.sendRequest({ const response = await http.sendRequest({
...params, ...params,
async: false, async: false,
@@ -583,11 +641,12 @@ export default {
/** /**
* 清除会话历史(调用后端接口) * 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/ */
async clearConversation(conversationId) { async clearConversation(conversationId) {
try { try {
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/clear-conversation', url: resolveApiUrl('clear-conversation'),
data: { data: {
agent_id: getAgentId(), agent_id: getAgentId(),
conversation_id: conversationId, conversation_id: conversationId,
@@ -604,6 +663,7 @@ export default {
/** /**
* 获取会话历史 * 获取会话历史
* PR-7: URL 按 agent_id 路由
*/ */
async getConversationHistory(params = {}) { async getConversationHistory(params = {}) {
try { try {
@@ -623,7 +683,7 @@ export default {
...getDomainParams() ...getDomainParams()
}; };
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/getHistory', url: resolveApiUrl('getHistory'),
data: requestData, data: requestData,
header: { header: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -653,6 +713,7 @@ export default {
/** /**
* [H5 Only] EventSource 流式聊天(备用) * [H5 Only] EventSource 流式聊天(备用)
* PR-7: URL 按 agent_id 路由
*/ */
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) { chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5 // #ifdef H5
@@ -671,7 +732,8 @@ export default {
...getDomainParams() ...getDomainParams()
}; };
const params = new URLSearchParams(paramsObj); const params = new URLSearchParams(paramsObj);
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`; var chatUrl = resolveApiUrl('chat');
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
try { try {
const eventSource = new EventSource(url); const eventSource = new EventSource(url);
window.currentEventSource = eventSource; window.currentEventSource = eventSource;
@@ -716,7 +778,6 @@ export default {
*/ */
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) { async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5 // #ifdef H5
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
return this.sendHttpStream(message, onMessage, (res) => { return this.sendHttpStream(message, onMessage, (res) => {
onComplete?.({ message: res.content, conversation_id: res.conversation_id }); onComplete?.({ message: res.content, conversation_id: res.conversation_id });
}); });
@@ -743,10 +804,16 @@ export default {
/** /**
* Set current AI Agent (default aikefu; link system passes agent_id). * Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
* @param {string} agentId * @param {string} agentId
*/ */
setAgentId(agentId) { setAgentId(agentId) {
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId); setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
}, },
/** /**
@@ -756,4 +823,4 @@ export default {
getAgentId() { getAgentId() {
return getAgentId(); return getAgentId();
}, },
}; };