This commit is contained in:
2026-09-21 08:53:52 +08:00
6 changed files with 565 additions and 46 deletions

View File

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

11
App.vue
View File

@@ -5,6 +5,9 @@
import ThemeOverride from 'common/js/theme-override.js'; import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js'; import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.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 { import {
Weixin Weixin
@@ -463,6 +466,14 @@
if (uni.getStorageSync('memberInfo')) { if (uni.getStorageSync('memberInfo')) {
this.$store.commit('setMemberInfo', 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'); this.$store.dispatch('getCartNumber');
} }

View File

@@ -19,12 +19,38 @@ let currentConversationId = null;
export const DEFAULT_AGENT_ID = 'agent_aikefu'; export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID; let currentAgentId = DEFAULT_AGENT_ID;
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存 // PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_'; // 老 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) { 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); export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
// 统一 SSE/WS 事件分发器 (透出所有事件类型) // 统一 SSE/WS 事件分发器 (透出所有事件类型)
@@ -84,13 +110,24 @@ function getDomainParams() {
} }
// 初始化时从本地读取会话ID(按当前 agent 隔离) // 初始化时从本地读取会话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 { try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId)); setTimeout(() => {
if (saved) { try {
currentConversationId = saved; migrateLegacyConversationKey(currentAgentId);
} const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) {
currentConversationId = saved;
}
} catch (e) {
console.warn('读取会话ID失败:', e);
}
}, 0);
} catch (e) { } catch (e) {
console.warn('读取会话ID失败:', e); console.warn('初始化会话ID任务派发失败:', e);
} }
const GUEST_ID_KEY = 'ai_guest_user_id'; const GUEST_ID_KEY = 'ai_guest_user_id';
@@ -387,6 +424,55 @@ function disconnectWebSocket() {
} }
// #endif // #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 { export default {
/** /**
* 获取uniacid * 获取uniacid
@@ -550,7 +636,11 @@ export default {
// #ifdef H5 // #ifdef H5
try { try {
var chatUrl = resolveApiUrl('chat'); 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, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -598,6 +688,20 @@ export default {
if (!jsonStr || jsonStr === '[DONE]') continue; if (!jsonStr || jsonStr === '[DONE]') continue;
try { try {
const data = JSON.parse(jsonStr); 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) { switch (data.event) {
case 'message': { case 'message': {
var text = data.answer || data.text || data.content || ''; var text = data.answer || data.text || data.content || '';
@@ -705,11 +809,13 @@ export default {
/** /**
* 设置并持久化会话ID * 设置并持久化会话ID
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key
*/ */
setConversationId(id) { setConversationId(id) {
if (id && id !== currentConversationId) { if (id && id !== currentConversationId) {
currentConversationId = id; currentConversationId = id;
try { try {
migrateLegacyConversationKey(currentAgentId);
uni.setStorageSync(getConversationKey(currentAgentId), id); uni.setStorageSync(getConversationKey(currentAgentId), id);
} catch (e) { } catch (e) {
console.error('保存会话ID失败:', 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 * 清除会话ID
*/ */
@@ -785,6 +921,12 @@ export default {
* PR-7: URL 按 agent_id 路由 * PR-7: URL 按 agent_id 路由
*/ */
async getServiceConfig() { 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 { try {
const params = { const params = {
url: resolveApiUrl('info'), url: resolveApiUrl('info'),
@@ -814,6 +956,9 @@ export default {
} else { } else {
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg)); 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; return response.data;
} catch (error) { } catch (error) {
console.error('[AI Service] getServiceConfig error:', 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 路由 * PR-7: URL 按 agent_id 路由
*/ */
async clearConversation(conversationId) { async clearConversation(conversationId) {
try { 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({ const response = await http.sendRequest({
url: resolveApiUrl('clear-conversation'), url: resolveApiUrl('clearConversation'),
data: { data: {
agent_id: getAgentId(), agent_id: getAgentId(),
conversation_id: conversationId, conversation_id: conversationId,
@@ -871,10 +1057,38 @@ export default {
return true; return true;
// #endif // #endif
// #ifdef H5 // #ifdef H5
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点 // PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底 // 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底'); // (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/...
return false; // 会拆出 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 // #endif
}, },
@@ -1022,20 +1236,37 @@ export default {
/** /**
* Set current AI Agent (default aikefu; link system passes agent_id). * Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了) * PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
*
* PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时
* 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑"
* - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对)
* - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话"
*
* @param {string} agentId * @param {string} agentId
* @param {object} opts { silent: bool } true = 不回调, 默认 false
*/ */
setAgentId(agentId) { setAgentId(agentId, opts) {
var oldAgentId = currentAgentId;
var oldAddon = resolveAddonName(currentAgentId); var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId); setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId); var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) { if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ } 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; currentConversationId = null;
try { try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId)); 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 */ } } catch (e) { /* ignore */ }
}, },

View File

@@ -765,12 +765,27 @@ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answer
// #endif // #endif
// #ifdef H5 // #ifdef H5
// H5 端没有原生 WS 长连接, HTTP SSE 也没有 elicit_response 端点 // H5 端没有原生 WS 长连接, 走 PR-ELICIT-RESPONSE-HTTP (2026-09-18) 新端点:
// 兜底: 复用 chat 端点 + action=elicit_response body // POST /api/kefu/chat/elicit-response (aikefu)
// (此端点需后端补 — 当前后端 5 个 chat 入口 (aiagent Chat + aikefu Kefu B+A + 2 个 WS) 都没注册 elicit-response HTTP 路由) // POST /api/aiagent/chat/elicit-response (aiagent)
// 临时方案: 30s 超时后端 emit pending_stale, 用户的选项卡片就当 UI 提示展示, 不真回传给 Agent // 后端会从 conversation 行读 wb_runtime {url,token,connection_id}, 然后 curl POST
console.warn('[ai-service] H5 elicit_response 无可用通道, 仅展示用户选项 + 30s 后由 pending_stale 兜底'); // jsonrpc 应答帧到 WB Runtime. blocking JSON, 不阻塞聊天主链路.
return false; 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 // #endif
}, },
``` ```
@@ -2082,6 +2097,46 @@ curl -X POST 'https://your-domain.com/api/aiagent/clear-conversation' \
- 用户复制了 conversation_id 直接调后端 API (绕过前端) → 上下文乱了 - 用户复制了 conversation_id 直接调后端 API (绕过前端) → 上下文乱了
- `clearConversation` 后端成功但前端没 clear → 下一条还带旧 id - `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 端收不到 ### 6.5 attachments 上传后 AI 端收不到
排查顺序: 排查顺序:

View File

@@ -148,7 +148,8 @@
</view> </view>
<view class="message-content"> <view class="message-content">
<!-- 思考流折叠面板 (AI 思考过程) --> <!-- 思考流折叠面板 (AI 思考过程) -->
<view v-if="message.role === 'ai' && message.thoughtsText" class="thought-panel" :class="{ streaming: message.isStreaming && message.thoughtsText }"> <!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_show_thinking_process, 默认 true 不变体感 -->
<view v-if="message.role === 'ai' && message.thoughtsText && chatUiConfig.ui_show_thinking_process" class="thought-panel" :class="{ streaming: message.isStreaming && message.thoughtsText }">
<view class="thought-header" @tap.stop="toggleThought(message)"> <view class="thought-header" @tap.stop="toggleThought(message)">
<text class="thought-icon">{{ message.isStreaming ? '🧠' : '💡' }}</text> <text class="thought-icon">{{ message.isStreaming ? '🧠' : '💡' }}</text>
<text class="thought-label">{{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }}</text> <text class="thought-label">{{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }}</text>
@@ -288,7 +289,22 @@
</text> </text>
</view> </view>
</view> </view>
<!-- PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题
欢迎语气泡下方显示快捷问题 chips, 点击直接作为用户消息发送
一次性,点了之后从 message 上摘除 (避免重复点) -->
<view v-if="message.quickQuestions && message.quickQuestions.length > 0" class="quick-questions-row">
<view
v-for="(q, qi) in message.quickQuestions"
:key="qi"
class="quick-question-chip"
hover-class="quick-question-chip--hover"
@click="onQuickQuestionClick(message, q)">
<text class="quick-question-icon">💬</text>
<text class="quick-question-text">{{ getQuickQuestionText(q) }}</text>
</view>
</view>
<!-- 操作按钮 --> <!-- 操作按钮 -->
<view class="message-actions" v-if="message.actions && message.actions.length > 0"> <view class="message-actions" v-if="message.actions && message.actions.length > 0">
<button <button
@@ -303,7 +319,11 @@
</view> </view>
<!-- 工具调用 (调试可见性) --> <!-- 工具调用 (调试可见性) -->
<view v-if="message.toolCalls && message.toolCalls.length" class="tool-calls"> <!-- PR-CHAT-UI-CONFIG-TOOL-CALLS (2026-09-20): 受 ui_show_thinking_process 控制
修复前: v-if 只判断数据存在, 关掉"显示 AI 思考过程"后 tool-calls 仍显示
修复后: 与 thought-panel 共用同一开关, "思考过程可见性"统一管理
后端不动, 仅前端加条件渲染 -->
<view v-if="message.toolCalls && message.toolCalls.length && chatUiConfig.ui_show_thinking_process" class="tool-calls">
<view v-for="tc in message.toolCalls" :key="tc.tool_call_id || tc.id" class="tool-call-item" :class="['status-' + (tc.status || 'pending')]"> <view v-for="tc in message.toolCalls" :key="tc.tool_call_id || tc.id" class="tool-call-item" :class="['status-' + (tc.status || 'pending')]">
<text>🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }}</text> <text>🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }}</text>
</view> </view>
@@ -341,7 +361,8 @@
</view> </view>
<!-- AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 --> <!-- AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading'"> <!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_enable_feedback (隐藏点赞/点踩/更多), 默认 true -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading' && chatUiConfig.ui_enable_feedback">
<view class="ai-action-btn" @click="onAiActionCopy(message)"> <view class="ai-action-btn" @click="onAiActionCopy(message)">
<text class="iconfont icon-copy">⎘</text> <text class="iconfont icon-copy">⎘</text>
</view> </view>
@@ -426,7 +447,7 @@
class="message-input" class="message-input"
placeholder="发消息或按住说话" placeholder="发消息或按住说话"
placeholder-class="message-input-placeholder" placeholder-class="message-input-placeholder"
:maxlength="500" :maxlength="chatUiConfig.ui_input_max_length"
:auto-height="true" :auto-height="true"
:show-confirm-bar="false" :show-confirm-bar="false"
:adjust-position="adjustPosition" :adjust-position="adjustPosition"
@@ -508,8 +529,10 @@
</view> </view>
<!-- 更多工具面板 --> <!-- 更多工具面板 -->
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 透传后端 ui_enable_attachment_types 控制各 tab 显隐 -->
<ai-chat-tools-panel <ai-chat-tools-panel
:visible.sync="showToolsPanel" :visible.sync="showToolsPanel"
:attachment-types="chatUiConfig.ui_enable_attachment_types"
@image-picked="onImagePicked" @image-picked="onImagePicked"
@file-selected="onFileSelected" @file-selected="onFileSelected"
@voice-start="showVoicePanel" @voice-start="showVoicePanel"
@@ -532,11 +555,11 @@
<text>×</text> <text>×</text>
</button> </button>
</view> </view>
<textarea <textarea
v-model="expandInputText" v-model="expandInputText"
class="expand-editor-input" class="expand-editor-input"
:placeholder="$lang('ai-chat.index.inputPlaceholder')" :placeholder="$lang('ai-chat.index.inputPlaceholder')"
:maxlength="500" :maxlength="chatUiConfig.ui_input_max_length"
:auto-height="true" :auto-height="true"
:show-confirm-bar="false" :show-confirm-bar="false"
:adjust-position="true" :adjust-position="true"
@@ -693,7 +716,29 @@ export default {
avatarError: false, avatarError: false,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto) // 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
selectedModel: 'Auto', selectedModel: 'Auto',
availableModels: ['Auto', 'GPT-4', 'Claude', '文心一言', '通义千问', 'Kimi'], // PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20): availableModels 由后端 chat_ui_config.ui_available_models 注入
// 初始仅 'Auto' 兜底, created() 里后端 /info 返回后异步覆盖完整列表
// 数据源 (后端维护): https://docs.openclaw.ai/providers
availableModels: ['Auto'],
// PR-CHAT-UI-CONFIG (2026-09-20): 前端 UI 行为开关
// 字段与 ai-service.js::CHAT_UI_DEFAULTS 一一对应, 是后端 getChatUiConfig() 的缓存副本
// 5 个 P0 开关:
// ui_show_thinking_process - 控制是否显示 AI 思考过程面板
// ui_enable_attachment_types - 控制 tools-panel 4 类附件 tab 显隐 (image/file/audio/video)
// ui_input_max_length - 输入框最大字符数, 替代硬编 500
// ui_available_models - 模型选择器列表 (PR-CHAT-UI-CONFIG-SERVER-ONLY: 后端注入)
// ui_enable_feedback - 控制是否显示 AI 消息下方操作栏 (复制/点赞/点踩/更多)
// 加载失败/未配置时全走前端默认值 (ai-service.js 兜底), 这里手填一份做双重保险
// - ui_available_models 兜底仅 'Auto', 实际清单完全来自后端 (Config.php::DEFAULT_UI_CONFIG)
chatUiConfig: {
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
},
chatUiConfigLoaded: false,
// AiChat API相关 // AiChat API相关
currentConversationId: null, currentConversationId: null,
@@ -819,6 +864,8 @@ export default {
// 异步获取服务配置(含欢迎语) // 异步获取服务配置(含欢迎语)
this._loadWelcomeMessage() this._loadWelcomeMessage()
// PR-CHAT-UI-CONFIG (2026-09-20): 异步加载 UI 开关 (与欢迎语并行, 不阻塞 created)
this._loadChatUiConfig()
}, },
mounted() { mounted() {
// 初始加载时滚底(仅一次) // 初始加载时滚底(仅一次)
@@ -1001,23 +1048,56 @@ export default {
}, },
// 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome // 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome
async _loadChatUiConfig() {
// PR-CHAT-UI-CONFIG (2026-09-20): 拉取前端 UI 行为配置 (5 个 P0 开关)
// ai-service.getChatUiConfig() 内部已带 60s TTL + 与 getServiceConfig 共享 /info 调用
// 这里失败不影响主流程, 失败时保留 data() 里的初始默认值
try {
const cfg = await aiService.getChatUiConfig()
if (cfg && typeof cfg === 'object') {
this.chatUiConfig = cfg
// ui_available_models 同步覆盖 availableModels (UI 模型选择器用)
if (Array.isArray(cfg.ui_available_models) && cfg.ui_available_models.length) {
this.availableModels = cfg.ui_available_models.slice()
// 当前 selectedModel 已不在新列表里 → 回落 'Auto' 或第一项
if (!this.availableModels.includes(this.selectedModel)) {
this.selectedModel = this.availableModels[0] || 'Auto'
}
}
this.chatUiConfigLoaded = true
}
} catch (e) {
console.warn('[AI Chat] 加载 chatUiConfig 失败, 使用前端默认:', (e && e.message) || e)
}
},
async _loadWelcomeMessage() { async _loadWelcomeMessage() {
// PR-FIX (2026-09-20): 防重入 (created 异步加载期间 mounted/loadHistoryIfNeeded
// 等路径再触发会造成欢迎气泡重复 push; 网络慢时尤其容易)
if (this._welcomeLoading) return
this._welcomeLoading = true
// 仅全新会话才需要欢迎语 // 仅全新会话才需要欢迎语
if (this.currentConversationId) { if (this.currentConversationId) {
console.log('[AI Chat] 已有会话,跳过欢迎语') console.log('[AI Chat] 已有会话,跳过欢迎语')
this._welcomeLoading = false
return return
} }
let welcomeText = '' let welcomeText = ''
let isMarkdown = false let isMarkdown = false
let welcomeLoaded = false let welcomeLoaded = false
// PR-FIX (2026-09-20): welcomeInfo 原为 if 块内 const,第 3 步拼 quickQuestions
// 时在块外引用 → ReferenceError, 提升到函数作用域
// PR-WELCOME-INFO-CONSOLIDATE (2026-09-20): 字段名统一为 welcome, 移除 welcome_info
// 后端 KefuGetInfo 只下发 welcome 一个字段 (结构不变)
let welcomeInfo = null
// 1. 尝试从 API 获取远程欢迎语 // 1. 尝试从 API 获取远程欢迎语
try { try {
const config = await aiService.getServiceConfig() const config = await aiService.getServiceConfig()
console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config)) console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config))
if (config) { if (config) {
const welcomeInfo = config.welcome_info welcomeInfo = config.welcome
console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo)) console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo))
const remoteText = welcomeInfo?.welcome || '' const remoteText = welcomeInfo?.welcome || ''
const isEnabled = welcomeInfo?.is_enabled const isEnabled = welcomeInfo?.is_enabled
@@ -1041,6 +1121,7 @@ export default {
console.log('[AI Chat] 使用降级欢迎语') console.log('[AI Chat] 使用降级欢迎语')
} else { } else {
console.log('[AI Chat] 无可用欢迎语') console.log('[AI Chat] 无可用欢迎语')
this._welcomeLoading = false
return return
} }
} }
@@ -1053,12 +1134,69 @@ export default {
type: isMarkdown ? 'markdown' : 'text', type: isMarkdown ? 'markdown' : 'text',
content: welcomeText, content: welcomeText,
timestamp: Date.now(), timestamp: Date.now(),
actions: [] actions: [],
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 (chips)
// 后端可存 string[] 或 {id,text}[] ,前端统一归一化为 string[]
// message.quickQuestions 数组非空 → 模板渲染 chips;点击调 onQuickQuestionClick
quickQuestions: this.normalizeQuickQuestions(welcomeInfo && welcomeInfo.quick_questions)
} }
this.messages.push(welcomeMsg) this.messages.push(welcomeMsg)
this.$nextTick(() => this.scrollToBottom()) this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', welcomeMsg) this.$emit('ai-response', welcomeMsg)
} }
this._welcomeLoading = false
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 归一化 quick_questions 数据格式
// 后端可能存 string[] ("如何下单?" / "退换货流程")
// 也可能存 {id, text}[] ({id:'q1',text:'如何下单?'})
// PR-WELCOME-QUICK-NORMALIZE (2026-09-20): 兼容 textarea 时代的 string 格式
// 老数据 PR-WELCOME-SAVE-SANITIZE 上线前存的是 newline-separated string
// 例如 "如何下单?\n退换货流程\n价格说明",前端按 \n 切分兼容
// 后端 KefuGetInfo 已做归一化 (2026-09-20),前端是双保险
// 前端统一抽出 text,空值过滤
normalizeQuickQuestions(raw) {
if (Array.isArray(raw)) {
return raw
.map(function (q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
})
.filter(function (s) { return s && s.trim() !== '' })
}
// 兼容老数据 string 格式 (PR-WELCOME-SAVE-SANITIZE 上线前)
if (typeof raw === 'string' && raw !== '') {
return raw.split(/\r\n|\r|\n/)
.map(function (s) { return s.trim() })
.filter(function (s) { return s !== '' })
}
return []
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 渲染文本提取
getQuickQuestionText(q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 点击 chip
// 1. 把文本塞进 inputText,模拟"用户已输入"
// 2. 清空 welcome bubble 的 chips (一次性)
// 3. 调 sendMessage() 走正常 chat 流 (建 user bubble + AI 回复)
async onQuickQuestionClick(message, q) {
const text = this.getQuickQuestionText(q)
if (!text || !text.trim()) return
try {
// 一次性:点了就摘掉 chips,避免重复点
message.quickQuestions = []
// 走现有 sendMessage 流程 (inputText → 校验 → sendStream/Normal)
this.inputText = text
// 让 DOM 先更新再发,避免用户视觉上"消息凭空出现"
await this.$nextTick()
await this.sendMessage()
} catch (e) {
console.warn('[AI Chat] onQuickQuestionClick failed:', e && e.message)
}
}, },
// 生成消息的唯一 key(兼容非 H5 平台) // 生成消息的唯一 key(兼容非 H5 平台)
@@ -1266,6 +1404,13 @@ async loadChatHistoryIfExist() {
if (!hasContent && !hasEmoji && !allPending.length) return if (!hasContent && !hasEmoji && !allPending.length) return
// PR-FIX (2026-09-20): 手动发送消息后清掉欢迎气泡上的快捷问题 chips
// 原来只有点击 chip 才摘除, 用户手动打字发消息后 chips 一直挂着,
// 与"引导一次性"的语义不符
this.messages.forEach(msg => {
if (msg.quickQuestions && msg.quickQuestions.length) msg.quickQuestions = []
})
// 待发送文件/视频/音频附件 (与图片一并带出给后端) // 待发送文件/视频/音频附件 (与图片一并带出给后端)
const fileAttachments = pendingFilesList const fileAttachments = pendingFilesList
.filter(p => p.uploaded) .filter(p => p.uploaded)
@@ -1668,24 +1813,34 @@ async submitQuestionAnswer(msg) {
uni.showToast({ title: 'rpc_id 缺失, 无法回传', icon: 'none' }) uni.showToast({ title: 'rpc_id 缺失, 无法回传', icon: 'none' })
return 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) this.$set(msg, 'questionTimedOut', false)
if (msg.id && this.questionTimers[msg.id]) { if (msg.id && this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id]) clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id) this.$delete(this.questionTimers, msg.id)
} }
let ok = false
try { try {
await aiService.elicitResponse({ ok = await aiService.elicitResponse({
conversation_id: msg.questionConversationId || aiService.getConversationId(), conversation_id: msg.questionConversationId || aiService.getConversationId(),
message_id: msg.questionMessageId, message_id: msg.questionMessageId,
tool_call_id: msg.pendingQuestion && msg.pendingQuestion.tool_call_id, tool_call_id: msg.pendingQuestion && msg.pendingQuestion.tool_call_id,
rpc_id: msg.rpc_id, rpc_id: msg.rpc_id,
answers: msg.selectedAnswers answers: msg.selectedAnswers
}) })
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} catch (e) { } catch (e) {
ok = false
uni.showToast({ title: '选项提交失败: ' + (e.message || ''), icon: 'none' }) 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' })
} }
}, },
@@ -3817,6 +3972,53 @@ _handleStreamError(msg) {
} }
} }
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 chip 样式
// PR-FIX (2026-09-20): flex gap 在低版本微信基础库/部分安卓 WebView(小米) 不生效,
// 间距全部改用 margin 实现, 不依赖 gap
.quick-questions-row {
margin-top: 16rpx;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
max-width: 100%;
.quick-question-chip {
display: inline-flex;
align-items: center;
margin-right: 16rpx;
margin-bottom: 16rpx;
padding: 12rpx 24rpx;
background: #ffffff;
border: 1rpx solid #1E9FFF;
border-radius: 32rpx;
font-size: 26rpx;
color: #1E9FFF;
cursor: pointer;
transition: all 0.2s ease;
max-width: 100%;
line-height: 1.4;
.quick-question-icon {
font-size: 24rpx;
flex-shrink: 0;
margin-right: 8rpx;
}
.quick-question-text {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
}
.quick-question-chip--hover {
background: #1E9FFF;
color: #ffffff;
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(30, 159, 255, 0.2);
}
}
.message-actions { .message-actions {
margin-top: 24rpx; margin-top: 24rpx;
display: flex; display: flex;
@@ -3824,7 +4026,7 @@ _handleStreamError(msg) {
justify-content: flex-start; justify-content: flex-start;
flex-wrap: nowrap; flex-wrap: nowrap;
width: 100%; width: 100%;
.action-btn { .action-btn {
border-radius: $radius-lg; border-radius: $radius-lg;
font-size: 22rpx; font-size: 22rpx;

View File

@@ -9,26 +9,29 @@
</button> </button>
</view> </view>
<view class="tools-grid"> <view class="tools-grid">
<view class="tool-item" @click="selectImage"> <!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
location 不属于附件, 不受此开关控制 -->
<view v-if="isTypeEnabled('image')" class="tool-item" @click="selectImage">
<view class="tool-icon"> <view class="tool-icon">
<text class="icondiy icon-system-image-line"></text> <text class="icondiy icon-system-image-line"></text>
</view> </view>
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
</view> </view>
<view class="tool-item" @click="selectVideo"> <view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
<view class="tool-icon"> <view class="tool-icon">
<text class="icondiy icon-system-video"></text> <text class="icondiy icon-system-video"></text>
</view> </view>
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key --> <!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
</view> </view>
<view class="tool-item" @click="selectFile"> <view v-if="isTypeEnabled('file')" class="tool-item" @click="selectFile">
<view class="tool-icon"> <view class="tool-icon">
<text class="icondiy icon-system-file-2-line"></text> <text class="icondiy icon-system-file-2-line"></text>
</view> </view>
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
</view> </view>
<view class="tool-item" @click="selectVoiceOrAudio"> <view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
<view class="tool-icon"> <view class="tool-icon">
<text class="icondiy icon-system-chat-voice-line"></text> <text class="icondiy icon-system-chat-voice-line"></text>
</view> </view>
@@ -52,6 +55,9 @@ import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
* *
* Props: * Props:
* visible: Boolean - 控制显示/隐藏 * visible: Boolean - 控制显示/隐藏
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
* location 不属于附件, 不受此 prop 影响
* *
* Events: * Events:
* update:visible - 同步关闭状态 * update:visible - 同步关闭状态
@@ -67,6 +73,12 @@ export default {
visible: { visible: {
type: Boolean, type: Boolean,
default: false default: false
},
attachmentTypes: {
type: Array,
default: function () {
return ['image', 'file', 'audio', 'video']
}
} }
}, },
@@ -81,6 +93,13 @@ export default {
this.$emit('update:visible', false) this.$emit('update:visible', false)
}, },
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
isTypeEnabled(kind) {
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
return this.attachmentTypes.indexOf(kind) !== -1
},
selectImage() { selectImage() {
const that = this const that = this
uni.chooseImage({ uni.chooseImage({