diff --git a/common/js/ai-service.js b/common/js/ai-service.js index 372c131..50a97ea 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -451,7 +451,16 @@ const CHAT_UI_DEFAULTS = { ui_enable_attachment_types: ['image', 'file', 'audio', 'video'], ui_input_max_length: 500, ui_available_models: ['Auto'], - ui_enable_feedback: true + ui_enable_feedback: true, + // PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_assistant_name: '', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_show_assistant_name: true, + ui_show_assistant_avatar: true, + ui_assistant_avatar: '', + ui_show_message_time: true, + ui_time_format: 'auto', + ui_time_humanize: false }; // chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步); @@ -995,6 +1004,9 @@ export default { const merged = mergeChatUiConfig(remote); _chatUiConfigCache = merged; _chatUiConfigCacheAt = Date.now(); + // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时 + // getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁) + try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ } return merged; } catch (e) { console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e); @@ -1003,6 +1015,23 @@ export default { } }, + /** + * PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存) + * 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用, + * 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。 + * 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过) + * 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新 + */ + getChatUiConfigSync() { + try { + const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG'); + if (cached && typeof cached === 'object' && Object.keys(cached).length) { + return mergeChatUiConfig(cached); + } + } catch (e) { /* 存储读取失败按无缓存处理 */ } + return null; + }, + /** * 清除会话历史(调用后端接口) * PR-7: URL 按 agent_id 路由 diff --git a/common/js/media-upload.js b/common/js/media-upload.js index d1b9a34..d5cb469 100644 --- a/common/js/media-upload.js +++ b/common/js/media-upload.js @@ -116,8 +116,17 @@ export async function uploadImage(filePath, opts = {}) { if (finalUseSts) { return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize) - } else { + } + try { return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize) + } catch (e) { + // PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期 + // → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试) + if (e && e.code === 'OSS_STS_REQUIRED') { + console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传') + return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize) + } + throw e } } @@ -159,8 +168,18 @@ export async function uploadMedia(filePath, kind, opts = {}) { // 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转, // 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用 + // PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期, + // PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试 if (strategy === 'backend') { - return await doPhpUpload() + try { + return await doPhpUpload() + } catch (e) { + if (e && e.code === 'OSS_STS_REQUIRED') { + console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传') + return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints) + } + throw e + } } try { @@ -168,8 +187,15 @@ export async function uploadMedia(filePath, kind, opts = {}) { } catch (e) { // PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败) // 时回退 PHP 中转, 保证音频/视频文件仍能上传 + // PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收, + // 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用) console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message) - return await doPhpUpload(e) + try { + return await doPhpUpload(e) + } catch (e2) { + if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e + throw e2 + } } } @@ -213,7 +239,11 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) { oss_sts_required: data.data.oss_sts_required || false, }) } else { - reject(new Error(data.msg || data.message || '上传失败')) + // PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转, + // 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈) + const err = new Error(data.data || data.msg || data.message || '上传失败') + if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED' + reject(err) } } catch (e) { reject(new Error('解析响应失败: ' + e.message)) @@ -256,6 +286,12 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) { oss_sts_required: data.data.oss_sts_required || false, } } + // PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传 + if (data.oss_sts_required === true) { + const err = new Error(data.data || data.msg || data.message || '上传失败') + err.code = 'OSS_STS_REQUIRED' + throw err + } throw new Error(data.msg || data.message || '上传失败') // #endif } @@ -311,7 +347,153 @@ function extractExt(filePath, mimeHint = '', nameHint = '') { return ext } +/** + * PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign) + * + * 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT + * (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头 + * 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`, + * 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。 + * 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js: + * 裸 PUT 403 / presign PUT 200), 前端同款切换。 + * 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的 + * 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。 + * + * 端点: POST //api/upload/ossPresign + * 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id) + * 出参: {upload_url, content_type, public_url, object_key, provider, ...} + * PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。 + */ +/** + * PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装 + * 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)` + * resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用), + * 导致 stsResp.statusCode/preResp.data 全部 TypeError。 + * 回调式调用是唯一跨端可靠用法, 统一从这里走。 + */ +function uniRequest(opts) { + return new Promise((resolve, reject) => { + uni.request(Object.assign({}, opts, { + success: resolve, + fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')), + })) + }) +} + +async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) { + // PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin' + // → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type + let mimeHint = hints.mime || '' + let nameHint = hints.name || '' + let probeFile = null + // #ifdef H5 + try { + probeFile = await pathToFile(filePath, kind) + if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type + if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name + } catch (e) { + // blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile + console.warn('[media-upload] presign probe failed:', e && e.message) + } + // #endif + const ext = extractExt(filePath, mimeHint, nameHint) + // 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍) + const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext + const contentType = mimeHint || guessMime(finalExt) + const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint)) + ? nameHint + : `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}` + + const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign' + const preResp = await uniRequest({ + url: Config.baseUrl + preUrl, + method: 'POST', + header: { + 'Content-Type': 'application/json', + 'token': store.state.token || '' + }, + data: { + filename, + content_type: contentType, + uniacid: aiService.getUniacid(), + agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined, + config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, + }, + }) + if (preResp.statusCode === 404) { + const err = new Error('后端无 ossPresign 端点 (版本过旧)') + err.code = 'PRESIGN_UNSUPPORTED' + throw err + } + if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) { + throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败') + } + const pre = preResp.data.data + const uploadUrl = pre.upload_url + const finalContentType = pre.content_type || contentType + + let uploadedSize = knownSize || 0 + // #ifdef H5 + if (probeFile && probeFile.size) uploadedSize = probeFile.size + // #endif + + // 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里) + // #ifdef H5 + const file = probeFile || await pathToFile(filePath, kind) + const ossRes = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': finalContentType }, + body: file, + }) + console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90)) + if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`) + // #endif + // #ifdef MP-WEIXIN + // uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT + await new Promise((resolve, reject) => { + uni.getFileSystemManager().readFile({ + filePath, + success: (res) => { + uni.request({ + url: uploadUrl, + method: 'PUT', + header: { 'Content-Type': finalContentType }, + data: res.data, + success: (r) => (r.statusCode >= 200 && r.statusCode < 300) + ? resolve() + : reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)), + fail: reject, + }) + }, + fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))), + }) + }) + // #endif + + return { + url: (pre.public_url || uploadUrl.split('?')[0]), + mime: finalContentType, + size: uploadedSize, + name: (pre.object_key || filename).split('/').pop(), + kind, + used_strategy: 'oss_sts', + provider: pre.provider || 'alioss', + } +} + async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) { + // PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名; + // 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测) + // 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程 + const providerHint = (config && config.oss_provider) || '' + if (!providerHint || providerHint === 'alioss') { + try { + return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints) + } catch (e) { + if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e + console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message) + } + } // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取 // H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL // 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin' @@ -321,7 +503,7 @@ async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = // 1. 拿 STS 凭证 (addon 模式 URL) const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts' - const stsResp = await uni.request({ + const stsResp = await uniRequest({ url: Config.baseUrl + stsUrl, method: 'POST', header: { @@ -377,7 +559,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = } else { // A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) // endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼 - const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com` + // ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug + // sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou) + // 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在) + // 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com` + // 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它 + const aliossTpl = sts.oss_url + ? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint + : `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com` + const tpl = (provider === 'alioss') ? aliossTpl : (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com` : (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com` : (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com` diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index e880b56..d24ad50 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -12,6 +12,12 @@ export const lang = { timeFormatToday: 'Today', timeFormatYesterday: 'Yesterday', timeFormatBeforeYesterday: 'Day before yesterday', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize) + timeJustNow: 'Just now', + timeMinutesAgo: '{n} min ago', + timeHoursAgo: '{n} h ago', + timeMonthDay: '{m}/{d}', + timeYearMonthDay: '{y}/{m}/{d}', // ====== ai-chat-message ====== aiNickname: 'AI Assistant', diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index f5bda06..3668e11 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -12,6 +12,12 @@ export const lang = { timeFormatToday: '今天', timeFormatYesterday: '昨天', timeFormatBeforeYesterday: '前天', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize) + timeJustNow: '刚刚', + timeMinutesAgo: '{n}分钟前', + timeHoursAgo: '{n}小时前', + timeMonthDay: '{m}月{d}日', + timeYearMonthDay: '{y}年{m}月{d}日', // ====== ai-chat-message ====== aiNickname: '智能助手', diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 663f6fc..cca9ef9 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -13,7 +13,9 @@ - {{ $lang('ai-chat.index.aiNickname') }} + + {{ chatUiConfig.ui_assistant_name || $lang('ai-chat.index.aiNickname') }} @@ -137,7 +139,8 @@ - + + - + {{ formatTime(message.timestamp) }} @@ -612,6 +615,13 @@ export default { type: Array, default: () => [] }, + // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面渲染前预取的聊天 UI 配置 + // 传入后 created() 立即用作首帧值, 消除"默认值渲染 → 异步配置到达跳变"的闪烁; + // 之后 _loadChatUiConfig() 仍会拉最新值覆盖 (与父页面共享 60s TTL 缓存) + preloadedUiConfig: { + type: Object, + default: null + }, // 初始用户头像(外部传入,可选) initUserAvatar: { type: String, @@ -737,7 +747,16 @@ export default { ui_enable_attachment_types: ['image', 'file', 'audio', 'video'], ui_input_max_length: 500, ui_available_models: ['Auto'], - ui_enable_feedback: true + ui_enable_feedback: true, + // PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_assistant_name: '', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_show_assistant_name: true, + ui_show_assistant_avatar: true, + ui_assistant_avatar: '', + ui_show_message_time: true, + ui_time_format: 'auto', + ui_time_humanize: false }, chatUiConfigLoaded: false, @@ -768,13 +787,18 @@ export default { } }, computed: { + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 后台配置了自定义头像则优先 ($util.img 兼容 pic_path 与完整 URL) aiAvatar() { + const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar; + if (custom) return this.$util.img(custom); return this.$util.img(IMAGE_CONFIG.aiAvatar); }, defaultAvatar() { return this.$util.img(IMAGE_CONFIG.userAvatar); }, aiAvatarUrl() { + const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar; + if (custom) return this.$util.img(custom); return this.$util.img(IMAGE_CONFIG.aiAvatar); }, chatMessagesStyle() { @@ -863,6 +887,21 @@ export default { this.currentConversationId = aiService.getConversationId(); console.log('[AI Chat] created, currentConversationId:', this.currentConversationId, 'messages.length:', this.messages.length); + // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面预取配置优先作为首帧值 + // 父页面已保证渲染本组件时配置就绪 (缓存或预取), 此处同步落位; + // _loadChatUiConfig() 随后仍拉最新值 (TTL 缓存命中则同值, 无视觉跳变) + if (this.preloadedUiConfig && typeof this.preloadedUiConfig === 'object') { + this.chatUiConfig = Object.assign({}, this.chatUiConfig, this.preloadedUiConfig) + if (Array.isArray(this.preloadedUiConfig.ui_available_models) && this.preloadedUiConfig.ui_available_models.length) { + this.availableModels = this.preloadedUiConfig.ui_available_models.slice() + if (!this.availableModels.includes(this.selectedModel)) { + this.selectedModel = this.availableModels[0] || 'Auto' + } + } + this.chatUiConfigLoaded = true + console.log('[AI Chat] chatUiConfig 使用父页面预取值 (首帧即真实配置)') + } + // 异步获取服务配置(含欢迎语) this._loadWelcomeMessage() // PR-CHAT-UI-CONFIG (2026-09-20): 异步加载 UI 开关 (与欢迎语并行, 不阻塞 created) @@ -2319,7 +2358,10 @@ _handleStreamError(msg) { return html }, - // 格式化时间(人性化显示) + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 时间格式化支持后端配置 + // ui_time_format = 'auto' 智能分段(默认, 与历史行为一致): 今天 HH:mm / 昨天 / 前天 / 本年 M/D / 跨年 Y/M/D + // ui_time_format = 'full' 始终完整 YYYY-MM-DD HH:mm + // ui_time_humanize = true 微信式相对时间(优先于 format): 刚刚 / N分钟前 / N小时前 / 昨天 / 前天 / M月D日 / Y年M月D日 formatTime(timestamp) { if (!timestamp) return ''; @@ -2355,6 +2397,19 @@ _handleStreamError(msg) { const minutes = date.getMinutes().toString().padStart(2, '0'); const timeStr = `${hours}:${minutes}`; + // 人性化相对时间 (开启后优先于 ui_time_format) + if (this.chatUiConfig && this.chatUiConfig.ui_time_humanize) { + return this.humanizeTime(date, msgDate, today, timeStr); + } + + // 完整时间格式: 始终 YYYY-MM-DD HH:mm + if (this.chatUiConfig && this.chatUiConfig.ui_time_format === 'full') { + const mm = (date.getMonth() + 1).toString().padStart(2, '0'); + const dd = date.getDate().toString().padStart(2, '0'); + return `${date.getFullYear()}-${mm}-${dd} ${timeStr}`; + } + + // auto: 智能分段 (原有逻辑) if (msgDate.getTime() === today.getTime()) { return timeStr; } @@ -2376,6 +2431,42 @@ _handleStreamError(msg) { const yearStr = date.getFullYear().toString(); return `${yearStr}/${monthStr}/${dayStr} ${timeStr}`; }, + + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 微信式人性化相对时间 + // 刚刚 / N分钟前 / 今天内N小时前 / 昨天 HH:mm / 前天 HH:mm / 本年 M月D日 HH:mm / 跨年 Y年M月D日 HH:mm + humanizeTime(date, msgDate, today, timeStr) { + const diff = Date.now() - date.getTime(); + const oneMin = 60 * 1000; + const oneHour = 60 * oneMin; + + if (diff >= 0 && diff < oneMin) { + return this.$lang('ai-chat.index.timeJustNow'); + } + if (diff >= 0 && diff < oneHour) { + return this.$lang('ai-chat.index.timeMinutesAgo', { n: Math.floor(diff / oneMin) }); + } + // 今天内且已超过 1 小时 + if (msgDate.getTime() === today.getTime()) { + if (diff < 24 * oneHour) { + return this.$lang('ai-chat.index.timeHoursAgo', { n: Math.floor(diff / oneHour) }); + } + return timeStr; // 兜底(理论不可达: 同一天 diff 不可能 >= 24h) + } + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + if (msgDate.getTime() === yesterday.getTime()) { + return `${this.$lang('ai-chat.index.timeFormatYesterday')} ${timeStr}`; + } + const beforeYesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000); + if (msgDate.getTime() === beforeYesterday.getTime()) { + return `${this.$lang('ai-chat.index.timeFormatBeforeYesterday')} ${timeStr}`; + } + const m = date.getMonth() + 1; + const d = date.getDate(); + if (date.getFullYear() === new Date().getFullYear()) { + return `${this.$lang('ai-chat.index.timeMonthDay', { m, d })} ${timeStr}`; + } + return `${this.$lang('ai-chat.index.timeYearMonthDay', { y: date.getFullYear(), m, d })} ${timeStr}`; + }, // 格式化文件大小 formatFileSize(bytes) { diff --git a/pages_tool/ai-chat/index.vue b/pages_tool/ai-chat/index.vue index aa2ac60..7d56618 100644 --- a/pages_tool/ai-chat/index.vue +++ b/pages_tool/ai-chat/index.vue @@ -16,7 +16,9 @@ setTimeout(() => resolve(null), 6000)) + ]); + if (cfg) this.preloadedUiCfg = cfg; + else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新'); + } catch (e) { /* 失败兜底默认值 */ } + this.uiCfgReady = true; + console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg); + } + // 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取, + // 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求 + // ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ====== // 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。 // 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,