Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
This commit is contained in:
@@ -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
11
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');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) {
|
||||
currentConversationId = saved;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
console.warn('读取会话ID失败:', e);
|
||||
}
|
||||
}, 0);
|
||||
} catch (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 兜底');
|
||||
// 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 */ }
|
||||
},
|
||||
|
||||
|
||||
@@ -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 兜底');
|
||||
// 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 端收不到
|
||||
|
||||
排查顺序:
|
||||
|
||||
@@ -148,7 +148,8 @@
|
||||
</view>
|
||||
<view class="message-content">
|
||||
<!-- 思考流折叠面板 (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)">
|
||||
<text class="thought-icon">{{ message.isStreaming ? '🧠' : '💡' }}</text>
|
||||
<text class="thought-label">{{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }}</text>
|
||||
@@ -289,6 +290,21 @@
|
||||
</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">
|
||||
<button
|
||||
@@ -303,7 +319,11 @@
|
||||
</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')]">
|
||||
<text>🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }}</text>
|
||||
</view>
|
||||
@@ -341,7 +361,8 @@
|
||||
</view>
|
||||
|
||||
<!-- 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)">
|
||||
<text class="iconfont icon-copy">⎘</text>
|
||||
</view>
|
||||
@@ -426,7 +447,7 @@
|
||||
class="message-input"
|
||||
placeholder="发消息或按住说话"
|
||||
placeholder-class="message-input-placeholder"
|
||||
:maxlength="500"
|
||||
:maxlength="chatUiConfig.ui_input_max_length"
|
||||
:auto-height="true"
|
||||
:show-confirm-bar="false"
|
||||
:adjust-position="adjustPosition"
|
||||
@@ -508,8 +529,10 @@
|
||||
</view>
|
||||
|
||||
<!-- 更多工具面板 -->
|
||||
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 透传后端 ui_enable_attachment_types 控制各 tab 显隐 -->
|
||||
<ai-chat-tools-panel
|
||||
:visible.sync="showToolsPanel"
|
||||
:attachment-types="chatUiConfig.ui_enable_attachment_types"
|
||||
@image-picked="onImagePicked"
|
||||
@file-selected="onFileSelected"
|
||||
@voice-start="showVoicePanel"
|
||||
@@ -536,7 +559,7 @@
|
||||
v-model="expandInputText"
|
||||
class="expand-editor-input"
|
||||
:placeholder="$lang('ai-chat.index.inputPlaceholder')"
|
||||
:maxlength="500"
|
||||
:maxlength="chatUiConfig.ui_input_max_length"
|
||||
:auto-height="true"
|
||||
:show-confirm-bar="false"
|
||||
:adjust-position="true"
|
||||
@@ -693,7 +716,29 @@ export default {
|
||||
avatarError: false,
|
||||
// 当前选中的 AI 模型, 显示在输入框左侧(如 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相关
|
||||
currentConversationId: null,
|
||||
@@ -819,6 +864,8 @@ export default {
|
||||
|
||||
// 异步获取服务配置(含欢迎语)
|
||||
this._loadWelcomeMessage()
|
||||
// PR-CHAT-UI-CONFIG (2026-09-20): 异步加载 UI 开关 (与欢迎语并行, 不阻塞 created)
|
||||
this._loadChatUiConfig()
|
||||
},
|
||||
mounted() {
|
||||
// 初始加载时滚底(仅一次)
|
||||
@@ -1001,23 +1048,56 @@ export default {
|
||||
},
|
||||
|
||||
// 加载欢迎语:优先 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() {
|
||||
// PR-FIX (2026-09-20): 防重入 (created 异步加载期间 mounted/loadHistoryIfNeeded
|
||||
// 等路径再触发会造成欢迎气泡重复 push; 网络慢时尤其容易)
|
||||
if (this._welcomeLoading) return
|
||||
this._welcomeLoading = true
|
||||
// 仅全新会话才需要欢迎语
|
||||
if (this.currentConversationId) {
|
||||
console.log('[AI Chat] 已有会话,跳过欢迎语')
|
||||
this._welcomeLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let welcomeText = ''
|
||||
let isMarkdown = 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 获取远程欢迎语
|
||||
try {
|
||||
const config = await aiService.getServiceConfig()
|
||||
console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config))
|
||||
if (config) {
|
||||
const welcomeInfo = config.welcome_info
|
||||
welcomeInfo = config.welcome
|
||||
console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo))
|
||||
const remoteText = welcomeInfo?.welcome || ''
|
||||
const isEnabled = welcomeInfo?.is_enabled
|
||||
@@ -1041,6 +1121,7 @@ export default {
|
||||
console.log('[AI Chat] 使用降级欢迎语')
|
||||
} else {
|
||||
console.log('[AI Chat] 无可用欢迎语')
|
||||
this._welcomeLoading = false
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1053,12 +1134,69 @@ export default {
|
||||
type: isMarkdown ? 'markdown' : 'text',
|
||||
content: welcomeText,
|
||||
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.$nextTick(() => this.scrollToBottom())
|
||||
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 平台)
|
||||
@@ -1266,6 +1404,13 @@ async loadChatHistoryIfExist() {
|
||||
|
||||
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
|
||||
.filter(p => p.uploaded)
|
||||
@@ -1668,24 +1813,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' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
|
||||
@@ -9,26 +9,29 @@
|
||||
</button>
|
||||
</view>
|
||||
<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">
|
||||
<text class="icondiy icon-system-image-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectVideo">
|
||||
<view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-video"></text>
|
||||
</view>
|
||||
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectFile">
|
||||
<view v-if="isTypeEnabled('file')" class="tool-item" @click="selectFile">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-file-2-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectVoiceOrAudio">
|
||||
<view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||
</view>
|
||||
@@ -52,6 +55,9 @@ import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||
*
|
||||
* Props:
|
||||
* 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:
|
||||
* update:visible - 同步关闭状态
|
||||
@@ -67,6 +73,12 @@ export default {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
attachmentTypes: {
|
||||
type: Array,
|
||||
default: function () {
|
||||
return ['image', 'file', 'audio', 'video']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -81,6 +93,13 @@ export default {
|
||||
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() {
|
||||
const that = this
|
||||
uni.chooseImage({
|
||||
|
||||
Reference in New Issue
Block a user