1060 lines
34 KiB
JavaScript
1060 lines
34 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;
|
||
|
||
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存
|
||
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_';
|
||
export function getConversationKey(agentId) {
|
||
return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
|
||
}
|
||
// 兼容旧代码: 单 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 隔离)
|
||
try {
|
||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||
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;
|
||
|
||
// 优先走 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
|
||
|
||
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');
|
||
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() || '',
|
||
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);
|
||
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
|
||
*/
|
||
setConversationId(id) {
|
||
if (id && id !== currentConversationId) {
|
||
currentConversationId = id;
|
||
try {
|
||
uni.setStorageSync(getConversationKey(currentAgentId), id);
|
||
} catch (e) {
|
||
console.error('保存会话ID失败:', e);
|
||
}
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 清除会话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() {
|
||
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 (后端校验 config_uid/config_uuid 不能为空)。
|
||
// 此前从未写入过该缓存, 导致上传表单里 config_uuid 恒为空。
|
||
// 这里从 info 响应中提取并持久化, 兼容多种字段名。
|
||
const cfg = response && response.data;
|
||
if (cfg && typeof cfg === 'object') {
|
||
const uuid = cfg.config_uuid || cfg.config_uid || cfg.uuid || (cfg.info && (cfg.info.config_uuid || cfg.info.uuid)) || '';
|
||
if (uuid) uni.setStorageSync('current_config_uuid', uuid);
|
||
}
|
||
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;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 向 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
|
||
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点
|
||
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底
|
||
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底');
|
||
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 重连 (路径变了)
|
||
* @param {string} agentId
|
||
*/
|
||
setAgentId(agentId) {
|
||
var oldAddon = resolveAddonName(currentAgentId);
|
||
setAgentId(agentId);
|
||
var newAddon = resolveAddonName(currentAgentId);
|
||
if (oldAddon !== newAddon) {
|
||
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
|
||
}
|
||
// 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离)
|
||
currentConversationId = null;
|
||
try {
|
||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||
if (saved) currentConversationId = saved;
|
||
} 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 (媒体上传路由用)
|
||
*/
|
||
getCurrentConfigUuid() {
|
||
return uni.getStorageSync('current_config_uuid') || '';
|
||
},
|
||
|
||
/**
|
||
* 获取用户 ID (媒体上传字段用)
|
||
*/
|
||
getUserId() {
|
||
return getUserId();
|
||
},
|
||
|
||
/**
|
||
* Get current AI Agent.
|
||
* @returns {string}
|
||
*/
|
||
getAgentId() {
|
||
return getAgentId();
|
||
},
|
||
}; |