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

@@ -242,8 +242,9 @@ const localDevConfig = ({
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置
// })['2811']; // 本地
//})['5g-317']; // 电磁兼容
})['5g-351']; //
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['local-1']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

11
App.vue
View File

@@ -5,6 +5,9 @@
import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.js';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
import aiService from 'common/js/ai-service.js';
import {
Weixin
@@ -463,6 +466,14 @@
if (uni.getStorageSync('memberInfo')) {
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
try {
if (aiService && typeof aiService.onUserChange === 'function') {
aiService.onUserChange();
}
} catch (e) { /* ignore */ }
// 查询购物车信息
this.$store.dispatch('getCartNumber');
}

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
},

View File

@@ -765,12 +765,27 @@ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answer
// #endif
// #ifdef H5
// H5 端没有原生 WS 长连接, HTTP SSE 也没有 elicit_response 端点
// 兜底: 复用 chat 端点 + action=elicit_response body
// (此端点需后端补 — 当前后端 5 个 chat 入口 (aiagent Chat + aikefu Kefu B+A + 2 个 WS) 都没注册 elicit-response HTTP 路由)
// 临时方案: 30s 超时后端 emit pending_stale, 用户的选项卡片就当 UI 提示展示, 不真回传给 Agent
console.warn('[ai-service] H5 elicit_response 无可用通道, 仅展示用户选项 + 30s 后由 pending_stale 兜底');
return false;
// H5 端没有原生 WS 长连接, 走 PR-ELICIT-RESPONSE-HTTP (2026-09-18) 新端点:
// POST /api/kefu/chat/elicit-response (aikefu)
// POST /api/aiagent/chat/elicit-response (aiagent)
// 后端会从 conversation 行读 wb_runtime {url,token,connection_id}, 然后 curl POST
// jsonrpc 应答帧到 WB Runtime. blocking JSON, 不阻塞聊天主链路.
try {
const resp = await fetch(Config.baseUrl + resolveApiUrl('elicit-response'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversation_id, message_id, tool_call_id, rpc_id, answers,
uniacid: this.getUniacid(),
token: store.state.token || ''
})
});
const data = await resp.json();
return data && data.code === 0;
} catch (e) {
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
return false;
}
// #endif
},
```
@@ -2082,6 +2097,46 @@ curl -X POST 'https://your-domain.com/api/aiagent/clear-conversation' \
- 用户复制了 conversation_id 直接调后端 API (绕过前端) → 上下文乱了
- `clearConversation` 后端成功但前端没 clear → 下一条还带旧 id
#### 6.4.1 Storage key 三维化 (PR-MULTI-TURN-KEY-BINDING 2026-09-18)
**老 key (2026-09-18 前)**:
```js
ai_conversation_id_<agentId> // 例: ai_conversation_id_agent_aikefu
```
只按 agent 维度隔离 — 跨用户/跨 uniacid 都会串会话。
**新 key (2026-09-18 起)**:
```js
ai_conv_<uniacid>_<userId>_<agentId>
// 例: ai_conv_1_guest_e2e_test_agent_aikefu
```
按 uniacid × userId × agentId 三维隔离。
**一次性迁移** (前端 `ai-service.js` 模块 init 时自动跑, 在 `setTimeout(0)` 里执行避开 uniapp H5 storage shim 时序坑):
```js
// ai-service.js module-level init
setTimeout(() => {
migrateLegacyConversationKey(currentAgentId);
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) currentConversationId = saved;
}, 0);
```
- 老 key 有值 + 新 key 为空 → 写入新 key
- 老 key 保留 1 版本 (兜底, 不删)
- 下次发版独立 PR `uni.removeStorageSync('ai_conversation_id_*')` 清老 key
**onUserChange 钩子** (用户登录态变化时清旧 user 的 key):
```js
// App.vue setMemberInfo 之后
aiService.onUserChange();
// 内部: 检测 ai_last_user_id 与新 userId 不同 → removeStorageSync 旧 key → setStorageSync ai_last_user_id
```
**H5 时序坑** (E2E 2026-09-19 发现):
uniapp H5 的 `uni.getStorageSync` 在 webpack module 顶层同步执行时返回空字符串,
100ms 后才读到真值 (uniapp H5 runtime shim 在 bundle 之后才挂上 `window.uni`)。
所有 ai-service.js 顶层的 storage 读/写必须包 `setTimeout(..., 0)` 或挂生命周期。
### 6.5 attachments 上传后 AI 端收不到
排查顺序:

View File

@@ -1668,24 +1668,34 @@ async submitQuestionAnswer(msg) {
uni.showToast({ title: 'rpc_id 缺失, 无法回传', icon: 'none' })
return
}
this.$set(msg, 'questionAnswered', true)
// PR-FA-PRE (2026-09-18): 仅在 elicitResponse 真返回 true 时才置 questionAnswered=true
// 老逻辑先置 true 再 await,后端返回 false (H5 后端未就绪) 时也已被 UI 标记为已答,
// 用户看不到正确答案的继续流 → 体感"假性中断"。修:成功后才标记;失败提示并保留未答状态
// 由 30s pending_stale 兜底超时。
this.$set(msg, 'questionTimedOut', false)
if (msg.id && this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id)
}
let ok = false
try {
await aiService.elicitResponse({
ok = await aiService.elicitResponse({
conversation_id: msg.questionConversationId || aiService.getConversationId(),
message_id: msg.questionMessageId,
tool_call_id: msg.pendingQuestion && msg.pendingQuestion.tool_call_id,
rpc_id: msg.rpc_id,
answers: msg.selectedAnswers
})
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} catch (e) {
ok = false
uni.showToast({ title: '选项提交失败: ' + (e.message || ''), icon: 'none' })
this.$set(msg, 'questionAnswered', false)
}
if (ok) {
this.$set(msg, 'questionAnswered', true)
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} else {
// H5 后端暂未就绪 或 网络失败:保留 questionAnswered=false,30s 由 pending_stale 兜底
uni.showToast({ title: '选项已展示,等待后端确认…', icon: 'none' })
}
},