Files
lucky_shop/common/js/ai-service.js
ZF sun f36f9ce992 fix: AI智能客服首次使用显示fallbackWelcome的问题
1. 将 config.welcome 改为 config.welcome_info 匹配后端返回字段
2. 修复语言包路径错误:将 () 从 data() 移到 onLoad() 中
   - 原因:data() 执行时 getCurrentPages() 返回的是上一个页面
3. 添加调试日志用于排查 API 返回值
2026-05-26 15:02:35 +08:00

715 lines
20 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;
export const CONVERSATION_KEY = 'ai_conversation_id';
/**
* 获取域名配置参数(避免重复代码)
*/
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;
}
// #ifdef MP-WEIXIN
// ================================
// 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('WebSocket 域名配置无效'));
return;
}
var wssUrl = 'wss://' + domain + '/ws/aikefu';
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',
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('WebSocket 认证失败'));
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('WebSocket 连接失败'));
reject(new Error('WebSocket 连接失败'));
});
wsSocketTask.onClose(function() {
console.warn('WebSocket 连接已关闭');
var prevReject = wsMessageReject;
resetWsState();
resetWsMessageState();
if (prevReject) prevReject(new Error('WebSocket 连接意外关闭'));
});
});
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
},
/**
* 发送普通消息(blocking 模式)
*/
async sendMessage(message, options = {}) {
try {
const params = {
url: '/api/kefu/chat',
data: {
user_id: getUserId(),
stream: false,
inputs: {},
query: message,
response_mode: 'blocking', // 强制 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('AI服务暂时不可用,请稍后重试');
}
},
/**
* 流式消息入口(自动适配平台)
* 微信小程序:复用长连接 WebSocket
*/
async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP-WEIXIN
// 先确保连接已就绪
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',
uniacid: this.getUniacid(),
query: message,
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
})
});
});
// #endif
},
/**
* HTTP 流式请求(仅 H5 使用)
*/
async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5
try {
const url = Config.baseUrl + `/api/kefu/chat`;
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',
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
})
});
if (!response.ok || !response.body) {
throw new Error('无效响应');
}
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 || '';
// 兼容嵌套 data.data 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
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);
}
}
}
}
// 处理最后残留的 buffer(如果有)
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 || 'AI服务返回错误');
}
},
/**
* 获取当前会话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);
}
},
/**
* 生成新会话(如需)
*/
async generateConversationId() {
const params = {
url: '/api/kefu/createConversation',
data: {
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);
},
/**
* 获取服务状态
*/
async getServiceStatus() {
try {
const response = await http.sendRequest({
url: '/api/kefu/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 ? '服务正常' : '服务异常'
};
} catch (error) {
return {
available: false,
reason: '服务检查失败'
};
}
},
/**
* 获取服务配置信息
*/
async getServiceConfig() {
try {
const params = {
url: '/api/kefu/info',
data: {
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;
}
},
/**
* 清除会话历史(调用后端接口)
*/
async clearConversation(conversationId) {
try {
const response = await http.sendRequest({
url: '/api/kefu/clear-conversation',
data: {
conversation_id: conversationId,
...getDomainParams()
},
async: false
});
return response.success;
} catch (error) {
console.error('清除会话失败:', error);
return false;
}
},
/**
* 获取会话历史
*/
async getConversationHistory(params = {}) {
try {
if (!params.conversation_id) {
throw new Error('会话ID(conversation_id)是必填参数');
}
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
const requestData = {
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: '/api/kefu/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 || '获取会话历史失败');
}
} catch (error) {
console.error('获取会话历史失败:', error);
throw new Error(error.message || '获取会话历史时发生错误,请稍后重试');
}
},
// ================================
// 以下方法仅用于 H5,小程序无效
// ================================
/**
* [H5 Only] EventSource 流式聊天(备用)
*/
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
if (window.currentEventSource) {
window.currentEventSource.close();
}
const paramsObj = {
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);
const url = `${Config.baseUrl}/api/kefu/chat?${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: '连接失败' });
window.currentEventSource = null;
};
return eventSource;
} catch (error) {
console.error('创建EventSource失败:', error);
if (onError) onError({ error: error.message });
return null;
}
// #endif
// #ifdef MP-WEIXIN
console.warn('chatWithEventSource 不支持微信小程序');
return null;
// #endif
},
/**
* [H5 Only] Fetch 流式聊天(备用)
*/
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
return this.sendHttpStream(message, onMessage, (res) => {
onComplete?.({ message: res.content, conversation_id: res.conversation_id });
});
// #endif
// #ifdef MP-WEIXIN
console.warn('chatWithFetchStream 不支持微信小程序');
return null;
// #endif
},
// #ifdef MP-WEIXIN
/**
* [微信小程序] 断开 WebSocket 长连接(页面离开时调用)
*/
disconnectWebSocket() {
disconnectWebSocket();
},
// #endif
// #ifdef H5
disconnectWebSocket() {
// H5 无 WebSocket 长连接,无需操作
},
// #endif
};