chore:更新 AI 服务会话管理逻辑,优化用户状态切换时的 key 迁移与会话隔离,确保 H5 端 elicitResponse 请求正常处理

This commit is contained in:
2026-09-19 15:26:38 +08:00
parent 480a109114
commit 67ce0f8b39
5 changed files with 213 additions and 26 deletions

View File

@@ -19,12 +19,38 @@ let currentConversationId = null;
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_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) {
return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
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 别名(指向默认 agent)
// 一次性老 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 事件分发器 (透出所有事件类型)
@@ -84,13 +110,24 @@ function getDomainParams() {
}
// 初始化时从本地读取会话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 {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) {
currentConversationId = saved;
}
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);
console.warn('初始化会话ID任务派发失败:', e);
}
const GUEST_ID_KEY = 'ai_guest_user_id';
@@ -550,7 +587,11 @@ export default {
// #ifdef H5
try {
var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
// 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: {
@@ -598,6 +639,20 @@ export default {
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 || '';
@@ -705,11 +760,13 @@ export default {
/**
* 设置并持久化会话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);
@@ -717,6 +774,36 @@ export default {
}
},
/**
* 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
*/
@@ -871,10 +958,33 @@ export default {
return true;
// #endif
// #ifdef H5
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底');
return false;
// PR-ELICIT-RESPONSE-HTTP (2026-09-18): H5 端改 fetch POST 新端点
// 后端 InitRoute 已注册 /aiagent/api/chat/elicit-response 和 /aikefu/api/chat/elicit-response
// 复用 resolveApiUrl 走 agent_id 路由,与 chat/info 等接口一致
try {
const resp = await fetch(Config.baseUrl + resolveApiUrl('chat/elicit-response'), {
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
},