diff --git a/.local.config.js b/.local.config.js index 564de8a..441f18d 100644 --- a/.local.config.js +++ b/.local.config.js @@ -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 diff --git a/App.vue b/App.vue index 740e5f5..2fbeceb 100644 --- a/App.vue +++ b/App.vue @@ -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'); } diff --git a/common/js/ai-service.js b/common/js/ai-service.js index e146994..7ebc9f6 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -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_ (只按 agent 隔离, 跨用户/跨 uniacid 串会话) +// 新 key: ai_conv___ (按 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 }, diff --git a/docs/ai-agent-integration.md b/docs/ai-agent-integration.md index 86be5c3..3b6f8f0 100644 --- a/docs/ai-agent-integration.md +++ b/docs/ai-agent-integration.md @@ -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_ // 例: ai_conversation_id_agent_aikefu +``` +只按 agent 维度隔离 — 跨用户/跨 uniacid 都会串会话。 + +**新 key (2026-09-18 起)**: +```js +ai_conv___ +// 例: 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 端收不到 排查顺序: diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 58c7c21..aaca559 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -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' }) } },