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..372c131 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'; @@ -387,6 +424,55 @@ function disconnectWebSocket() { } // #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 +}; + +// 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 @@ -550,7 +636,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 +688,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 +809,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 +823,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 */ @@ -785,6 +921,12 @@ export default { * 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'), @@ -814,6 +956,9 @@ export default { } 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); @@ -821,14 +966,55 @@ export default { } }, + /** + * 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(); + return merged; + } catch (e) { + console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e); + // 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试 + return Object.assign({}, CHAT_UI_DEFAULTS); + } + }, + /** * 清除会话历史(调用后端接口) * 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('clear-conversation'), + url: resolveApiUrl('clearConversation'), data: { agent_id: getAgentId(), conversation_id: conversationId, @@ -871,10 +1057,38 @@ 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, 修 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 }, @@ -1022,20 +1236,37 @@ export default { /** * 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) { + setAgentId(agentId, opts) { + var oldAgentId = currentAgentId; var oldAddon = resolveAddonName(currentAgentId); setAgentId(agentId); var newAddon = resolveAddonName(currentAgentId); if (oldAddon !== newAddon) { try { disconnectWebSocket(); } catch (e) { /* ignore */ } } - // 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离) + // 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) currentConversationId = saved; + 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 */ } }, 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 2e79ca6..bf9b73e 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -148,7 +148,8 @@ - + + {{ message.isStreaming ? '🧠' : '💡' }} {{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }} @@ -288,7 +289,22 @@ - + + + + + 💬 + {{ getQuickQuestionText(q) }} + + + -