435 lines
12 KiB
JavaScript
435 lines
12 KiB
JavaScript
import Config from './config.js'
|
||
import http from './http.js'
|
||
import store from '@/store/index.js'
|
||
|
||
let currentConversationId = null;
|
||
const CONVERSATION_KEY = 'ai_conversation_id';
|
||
|
||
// 初始化时从本地读取会话ID
|
||
try {
|
||
const saved = uni.getStorageSync(CONVERSATION_KEY);
|
||
if (saved) {
|
||
currentConversationId = saved;
|
||
}
|
||
} catch (e) {
|
||
console.warn('读取会话ID失败:', e);
|
||
}
|
||
|
||
export default {
|
||
/**
|
||
* 发送普通消息(blocking 模式)
|
||
*/
|
||
async sendMessage(message, options = {}) {
|
||
try {
|
||
const aiConfig = store.getters.globalAIKefuConfig;
|
||
const params = {
|
||
url: '/api/kefu/chat',
|
||
data: {
|
||
user_id: store.state.memberInfo?.id || 'anonymous',
|
||
stream: false,
|
||
inputs: {},
|
||
query: message,
|
||
response_mode: 'blocking', // 强制 blocking
|
||
user: store.state.memberInfo?.id || 'anonymous',
|
||
conversation_id: this.getConversationId() || ''
|
||
},
|
||
header: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
};
|
||
|
||
console.log('Sending blocking request to:', params.url);
|
||
console.log('Request data:', params.data);
|
||
|
||
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('Dify API请求失败:', error);
|
||
throw new Error('AI服务暂时不可用,请稍后重试');
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 流式消息入口(自动适配平台)
|
||
*/
|
||
async sendStreamMessage(message, onChunk, onComplete) {
|
||
// #ifdef MP-WEIXIN
|
||
// 微信小程序:降级为普通请求 + 前端打字模拟
|
||
try {
|
||
const result = await this.sendMessage(message);
|
||
const content = result.content || '';
|
||
const conversationId = result.conversationId || '';
|
||
|
||
// 保存会话ID(确保连续对话)
|
||
if (conversationId) {
|
||
this.setConversationId(conversationId);
|
||
}
|
||
|
||
// 模拟打字效果
|
||
let index = 0;
|
||
const chunkSize = 2; // 每次显示2个字符
|
||
return new Promise((resolve) => {
|
||
const timer = setInterval(() => {
|
||
if (index < content.length) {
|
||
const chunk = content.substring(index, index + chunkSize);
|
||
index += chunkSize;
|
||
if (onChunk) onChunk(chunk);
|
||
} else {
|
||
clearInterval(timer);
|
||
if (onComplete) {
|
||
onComplete({
|
||
content: content,
|
||
conversation_id: conversationId
|
||
});
|
||
}
|
||
resolve({ content, conversation_id: conversationId });
|
||
}
|
||
}, 80); // 打字速度:80ms/次
|
||
});
|
||
} catch (error) {
|
||
console.error('小程序流式消息降级失败:', error);
|
||
if (onComplete) {
|
||
onComplete({ error: error.message || '发送失败' });
|
||
}
|
||
throw error;
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
// H5:使用真实流式(EventSource / Fetch)
|
||
return this.sendHttpStream(message, onChunk, onComplete);
|
||
// #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: store.state.uniacid || '1',
|
||
inputs: {},
|
||
query: message,
|
||
response_mode: 'streaming',
|
||
user_id: store.state.memberInfo?.id || 'anonymous',
|
||
conversation_id: this.getConversationId() || ''
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
if (!response.body) {
|
||
throw new Error('响应体不可用');
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder('utf-8');
|
||
let buffer = '';
|
||
let content = '';
|
||
let conversationId = '';
|
||
|
||
function processBuffer(buf, callback) {
|
||
const lines = buf.split('\n');
|
||
buf = lines.pop() || '';
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith('data:')) {
|
||
const jsonStr = trimmed.slice(5).trim();
|
||
if (jsonStr) {
|
||
try {
|
||
const data = JSON.parse(jsonStr);
|
||
if (data.event === 'message') {
|
||
const text = data.answer || data.text || '';
|
||
content += text;
|
||
callback(text);
|
||
}
|
||
if (data.conversation_id) {
|
||
conversationId = data.conversation_id;
|
||
}
|
||
if (data.event === 'message_end') {
|
||
// 可选:提前完成
|
||
}
|
||
} catch (e) {
|
||
console.warn('解析流数据失败:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
buffer = processBuffer(buffer, (chunk) => {
|
||
if (onChunk) onChunk(chunk);
|
||
});
|
||
}
|
||
|
||
if (onComplete) {
|
||
onComplete({
|
||
content,
|
||
conversation_id: conversationId
|
||
});
|
||
}
|
||
return { content, conversation_id: conversationId };
|
||
} catch (error) {
|
||
console.error('H5 流式请求失败:', error);
|
||
throw error;
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// 理论上不会执行到这里,但防止 fallback
|
||
return this.sendStreamMessage(message, onChunk, onComplete);
|
||
// #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: store.state.uniacid,
|
||
user_id: store.state.memberInfo?.id || 'anonymous',
|
||
member_id: store.state.memberInfo?.id || '',
|
||
},
|
||
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 clearConversation(conversationId) {
|
||
try {
|
||
const response = await http.sendRequest({
|
||
url: '/api/kefu/clear-conversation',
|
||
data: { conversation_id: conversationId },
|
||
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)是必填参数');
|
||
}
|
||
const requestData = {
|
||
uniacid: params.uniacid || store.state.uniacid || '1',
|
||
conversation_id: params.conversation_id,
|
||
user_id: params.user_id || store.state.memberInfo?.id || 'anonymous',
|
||
limit: params.limit || 20,
|
||
offset: params.offset || 0,
|
||
member_id: params.member_id || store.state.memberInfo?.id || '',
|
||
token: params.token || ''
|
||
};
|
||
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 params = new URLSearchParams({
|
||
uniacid: store.state.uniacid || '1',
|
||
user_id: store.state.memberInfo?.id || '123456',
|
||
query: message,
|
||
conversation_id: conversationId || '',
|
||
stream: 'true'
|
||
});
|
||
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
|
||
}
|
||
};
|