Files
lucky_shop/common/js/ai-service.js

1355 lines
48 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 路由
// 注意: 媒体上传端点按 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;
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离
// 老 key: ai_conversation_id_<agentId> (只按 agent 隔离, 跨用户/跨 uniacid 串会话)
// 新 key: ai_conv_<uniacid>_<userId>_<agentId> (按 uniacid × userId × agent 三维隔离)
// 一次性迁移: 读老 key → 新 key 为空 → 写入新 key 后保留老 key (1 版本后清理)
export const CONVERSATION_KEY_PREFIX = 'ai_conv_';
const LEGACY_KEY_PREFIX = 'ai_conversation_id_';
export function getConversationKey(agentId) {
let siteId = 0;
let userId = 'anon';
try {
siteId = uni.getStorageSync('uniacid') || Config.uniacid || 0;
} catch (e) { /* ignore */ }
try {
userId = getUserId() || 'anon';
} catch (e) { /* ignore */ }
return CONVERSATION_KEY_PREFIX + siteId + '_' + userId + '_' + (agentId || DEFAULT_AGENT_ID);
}
// 一次性老 key 迁移: 新 key 为空且老 key 有值 → 写入新 key, 保留老 key 1 版本兜底
function migrateLegacyConversationKey(agentId) {
try {
const legacyKey = LEGACY_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
const legacy = uni.getStorageSync(legacyKey);
const newKey = getConversationKey(agentId);
if (legacy && !uni.getStorageSync(newKey)) {
uni.setStorageSync(newKey, legacy);
}
} catch (e) { /* ignore */ }
}
// 兼容旧代码: 单 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 隔离)
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 先迁移老 key 再读新 key
// H5 时序坑: 模块顶层同步执行时 uni.getStorageSync 仍返回空字符串
// (uniapp H5 runtime shim 在 webpack bundle 之后才注入),延迟 1 个 tick
// 让 uni runtime 就绪后再读 — 实测 100ms 后能读到值
try {
setTimeout(() => {
try {
migrateLegacyConversationKey(currentAgentId);
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) {
currentConversationId = saved;
}
} catch (e) {
console.warn('读取会话ID失败:', e);
}
}, 0);
} 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;
}
/**
* 递归在 info 响应中查找配置 uuid (媒体上传必填)。
* 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。
*/
function findConfigUuid(obj, depth) {
if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return '';
var keys;
try { keys = Object.keys(obj); } catch (e) { return ''; }
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]);
}
for (var j = 0; j < keys.length; j++) {
var v = obj[keys[j]];
if (v && typeof v === 'object') {
var found = findConfigUuid(v, (depth || 0) + 1);
if (found) return found;
}
}
return '';
}
/**
* 获取当前 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
// PR-FIX (2026-09-20): getServiceConfig 短 TTL 缓存
// 服务配置(含欢迎语)变更频率极低, 每次进聊天页都打 /api/kefu/info 纯属浪费;
// TTL 60s + 失败不缓存, 配置保存后最多延迟 1 分钟生效
var _serviceConfigCache = null;
var _serviceConfigCacheAt = 0;
var SERVICE_CONFIG_CACHE_TTL = 60 * 1000;
function resetServiceConfigCache() {
_serviceConfigCache = null;
_serviceConfigCacheAt = 0;
}
// PR-CHAT-UI-CONFIG (2026-09-20): chat_ui_config 前端 fallback 默认值
// 与后端 addon/aikefu/model/Config.php::DEFAULT_UI_CONFIG 字段一一对应
// 改任何一项必须同步后端常量, 否则前后端默认分叉
//
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20):
// ui_available_models 必须完全来自后端 (Config.php::DEFAULT_UI_CONFIG),
// 前端不维护实际清单。仅保留 'Auto' 兜底: 后端 /info 调用失败 / 返回空数组时,
// 模型选择器仍可显示 Auto。
// 数据源 (后端维护): https://docs.openclaw.ai/providers
// 商家可在后台自由增删任意自定义模型名 (自部署/微调), 仅 trim + 去空 + 去重 + 单条 ≤30 字符 + 总数 ≤100。
const CHAT_UI_DEFAULTS = {
ui_show_thinking_process: true,
ui_enable_attachment_types: ['image', 'file', 'audio', 'video'],
ui_input_max_length: 500,
ui_available_models: ['Auto'],
ui_enable_feedback: true,
// PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_assistant_name: '',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_show_assistant_name: true,
ui_show_assistant_avatar: true,
ui_assistant_avatar: '',
ui_show_message_time: true,
ui_time_format: 'auto',
ui_time_humanize: false
};
// chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步);
// 不在 _serviceConfigCache 里读是因为 chat 组件可能在 getServiceConfig 还没返回前就调它
var _chatUiConfigCache = null;
var _chatUiConfigCacheAt = 0;
function mergeChatUiConfig(remote) {
// remote 是 /info 响应里的 chat_ui_config 对象, 缺字段用 defaults 兜底
// 注意: 不深拷贝数组 → 后端返回的就是最终数组, 不再覆盖
var merged = {};
Object.keys(CHAT_UI_DEFAULTS).forEach(function(k) {
if (remote && Object.prototype.hasOwnProperty.call(remote, k) && remote[k] !== null && remote[k] !== undefined) {
merged[k] = remote[k];
} else {
merged[k] = CHAT_UI_DEFAULTS[k];
}
});
return merged;
}
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) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
// 给 WbAgentOsClient 提供 timestamp/timezone/location/page/metadata 等基础元素
context: (options && options.context) || null,
...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 || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
context: (opts && opts.context) || null,
...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) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): MP HTTP 降级路径同步传递 context
context: (opts && opts.context) || null
});
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');
// PR-AI-CHAT-URL-NORMALIZE (2026-09-19): Config.baseUrl 文档约定以 '/' 结尾 (config.js:25),
// resolveApiUrl 返 '/api/kefu/chat' 也以 '/' 开头 → 直接拼接会出 '//api/kefu/chat' (双斜杠)。
// nginx 实际会 normalize, 但 console 会脏、第三方抓包看不懂, 单点 normalize 消除连续 //。
// 只动 URL 拼接本身, 不改 baseUrl 约定 (整个项目所有 API 都依赖这个约定)。
var url = (Config.baseUrl + chatUrl).replace(/([^:])\/\//g, '$1/');
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) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
context: (opts && opts.context) || null,
...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);
// PR-SSE-ERROR-PASSTHROUGH (2026-09-19): blocking 错误响应 (aikefu Kefu.php:558)
// 或 aiagent Chat.php catch 块会在 stream 模式下也返 {"code":-1,"message":"..."}
// 这种响应没 event 字段, 不应进 switch 的 default (会污染 console)
if (data && typeof data === 'object' && !Array.isArray(data) &&
data.code !== undefined && data.code !== 0 && !data.event) {
console.warn('[AI SSE] 后端错误响应:', data.code, data.message);
fire('error', { code: data.code, message: data.message || '请求失败' });
break;
}
// 空数组是 PHP json_encode([]) 的产物 (如 attachments 或 history 字段),
// 不是事件, 静默跳过
if (Array.isArray(data)) {
continue;
}
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
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key
*/
setConversationId(id) {
if (id && id !== currentConversationId) {
currentConversationId = id;
try {
migrateLegacyConversationKey(currentAgentId);
uni.setStorageSync(getConversationKey(currentAgentId), id);
} catch (e) {
console.error('保存会话ID失败:', e);
}
}
},
/**
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 用户切换钩子
* App.vue 在 setMemberInfo 之后调用,触发清旧 user key + 重置 currentConversationId
* 避免"用户 A 退出 → 用户 B 登录 → 直接复用 A 的会话 ID" 串会话 bug
*
* 调用时机:
* - App.vue onLaunch 时如果检测到 memberInfo 恢复 → 调一次
* - 任何 setMemberInfo 提交后调一次
* 行为:
* - 比对新老 user_id,不同时移除"旧 user"维度的 storage key
* - 重置 currentConversationId 为 null (强制下一轮 chat 用新 user 的会话或新建)
* - 记录 last_user_id 用于下次对比
*/
onUserChange() {
try {
const newUid = String(getUserId() || 'anon');
const oldUid = uni.getStorageSync('ai_last_user_id');
if (oldUid && oldUid !== newUid) {
// 清旧用户 key 维度 (按当前 agent 即可,下次 setAgentId 会重新计算)
const oldKey = CONVERSATION_KEY_PREFIX + (uni.getStorageSync('uniacid') || Config.uniacid || 0)
+ '_' + oldUid + '_' + (currentAgentId || DEFAULT_AGENT_ID);
try { uni.removeStorageSync(oldKey); } catch (e) { /* ignore */ }
}
uni.setStorageSync('ai_last_user_id', newUid);
currentConversationId = null;
} catch (e) {
console.warn('[ai-service] onUserChange failed:', e && e.message);
}
},
/**
* 清除会话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() {
// PR-FIX (2026-09-20): 命中缓存直接返回
const nowTs = Date.now();
if (_serviceConfigCache && (nowTs - _serviceConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
console.log('[AI Service] getServiceConfig 命中缓存 (age=' + Math.round((nowTs - _serviceConfigCacheAt) / 1000) + 's)');
return _serviceConfigCache;
}
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 (后端校验不能为空)。递归查找并持久化。
const cfg = response && response.data;
const uuid = findConfigUuid(cfg, 0);
if (uuid) {
uni.setStorageSync('current_config_uuid', uuid);
console.log('[AI Service] 已缓存 config_uuid:', uuid);
} else {
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
}
// PR-FIX (2026-09-20): 成功响应写缓存 (失败不缓存, 下次重试)
_serviceConfigCache = response.data;
_serviceConfigCacheAt = Date.now();
return response.data;
} catch (error) {
console.error('[AI Service] getServiceConfig error:', error);
return null;
}
},
/**
* PR-FIX (2026-09-20): 清除服务配置缓存
* 供配置变更后主动调用 (目前 60s TTL 已足够, 预留显式失效入口)
*/
clearServiceConfigCache() {
resetServiceConfigCache();
_chatUiConfigCache = null;
_chatUiConfigCacheAt = 0;
},
/**
* PR-CHAT-UI-CONFIG (2026-09-20): 获取前端 UI 行为开关
* 5 个 P0 开关由后端 Config::getChatUiConfig() 下发, 字段缺失用前端 CHAT_UI_DEFAULTS 兜底
* 缓存策略: 60s TTL, 失败不缓存 (下次重试), 与 getServiceConfig 共享 /info 调用
* 入参: force=true 跳过缓存 (用于用户切换站点/账号后强制刷新)
*/
async getChatUiConfig(force) {
const nowTs = Date.now();
if (!force && _chatUiConfigCache && (nowTs - _chatUiConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
return _chatUiConfigCache;
}
try {
// 复用 getServiceConfig() 触发 /info; 它的缓存会带回 chat_ui_config 字段
// 若 service_config 缓存未命中, getServiceConfig() 内部会发请求并填充 _serviceConfigCache
const serviceData = await this.getServiceConfig();
const remote = (serviceData && serviceData.chat_ui_config) || null;
const merged = mergeChatUiConfig(remote);
_chatUiConfigCache = merged;
_chatUiConfigCacheAt = Date.now();
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时
// getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁)
try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ }
return merged;
} catch (e) {
console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e);
// 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试
return Object.assign({}, CHAT_UI_DEFAULTS);
}
},
/**
* PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存)
* 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用,
* 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。
* 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过)
* 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新
*/
getChatUiConfigSync() {
try {
const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG');
if (cached && typeof cached === 'object' && Object.keys(cached).length) {
return mergeChatUiConfig(cached);
}
} catch (e) { /* 存储读取失败按无缓存处理 */ }
return null;
},
/**
* 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/
async clearConversation(conversationId) {
try {
// PR-AI-CLEAR-CONV-FIX (2026-09-19): URL 必须用 camelCase
// ThinkPHP 后端方法名 = clearConversation, URL 不做 kebab→camel 转换
// 之前 'clear-conversation' 拼出 /api/kefu/clear-conversation → 404 method not exists
// 改 'clearConversation' → /api/kefu/clearConversation → 200
const response = await http.sendRequest({
url: resolveApiUrl('clearConversation'),
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
// PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点
// 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response
// (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/...
// 会拆出 module=api, controller=kefu/aiagent, action=chat → 命中 chat() 而非 elicitResponse()
// (PR-WB-MP-MEDIA 注释里记录的同款 thinkphp 路由陷阱)
// 必须用 resolveAddonName() 拿 addon 全名直接拼 URL
try {
const addon = resolveAddonName(currentAgentId); // aikefu / aiagent
const url = Config.baseUrl + '/' + addon + '/api/chat/elicit-response';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Token': store.state.token || 'test_token'
},
body: JSON.stringify({
conversation_id,
message_id,
tool_call_id,
rpc_id,
answers,
uniacid: this.getUniacid(),
user_id: getUserId(),
token: store.state.token || 'test_token'
})
});
const data = await resp.json();
return !!(data && (data.code === 0 || data.success));
} catch (e) {
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
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 重连 (路径变了)
*
* PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时
* 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑"
* - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对)
* - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话"
*
* @param {string} agentId
* @param {object} opts { silent: bool } true = 不回调, 默认 false
*/
setAgentId(agentId, opts) {
var oldAgentId = currentAgentId;
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
// PR-AGENT-LIFECYCLE-CLEANUP: agent 字符串真变化 (A → B) → 清 conv_id 缓存
// 不变化 (aikefu → aikefu 同 string) → 保留缓存, 让用户继续之前的多轮对话
var agentChanged = oldAgentId && oldAgentId !== agentId;
currentConversationId = null;
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved && !agentChanged) currentConversationId = saved;
else if (saved && agentChanged) {
// agent 变了, 把老 agent 的 conv_id 也清掉, 避免老 user 换个 product page 又用回
try {
const oldKey = getConversationKey(oldAgentId);
if (oldKey) uni.removeStorageSync(oldKey);
} catch (e) {}
}
} 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 (媒体上传路由用)
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
*/
getCurrentConfigUuid() {
return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || '';
},
/**
* 获取用户 ID (媒体上传字段用)
*/
getUserId() {
return getUserId();
},
/**
* Get current AI Agent.
* @returns {string}
*/
getAgentId() {
return getAgentId();
},
};