Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
This commit is contained in:
@@ -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 路由
|
||||
|
||||
@@ -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 /<addon>/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`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '智能助手',
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
|
||||
<!-- 顶部栏: 标题 + 新对话 -->
|
||||
<view class="chat-topbar">
|
||||
<text class="chat-title">{{ $lang('ai-chat.index.aiNickname') }}</text>
|
||||
<!-- PR-CHAT-UI-CONFIG-V2 (2026-09-24): 智能体显示名走后端配置, 空则回落 i18n 默认
|
||||
PR-CHAT-UI-CONFIG-V3 (2026-09-24): ui_show_assistant_name 控制顶栏名称显隐 -->
|
||||
<text class="chat-title" v-if="chatUiConfig.ui_show_assistant_name">{{ chatUiConfig.ui_assistant_name || $lang('ai-chat.index.aiNickname') }}</text>
|
||||
<view class="new-chat-btn" @click="onNewChat">
|
||||
<!-- 气泡+加号线性图标 (base64 SVG: 小程序不支持内联 SVG) -->
|
||||
<view class="new-chat-icon"></view>
|
||||
@@ -137,7 +139,8 @@
|
||||
|
||||
<!-- AI消息 -->
|
||||
<view v-else class="ai-message">
|
||||
<view class="avatar">
|
||||
<!-- PR-CHAT-UI-CONFIG-V3 (2026-09-24): ui_show_assistant_avatar 控制头像显隐; 自定义头像在 aiAvatarUrl computed 优先 -->
|
||||
<view class="avatar" v-if="chatUiConfig.ui_show_assistant_avatar">
|
||||
<!-- 智能客服头像 -->
|
||||
<image
|
||||
v-if="aiAvatarUrl && !avatarError"
|
||||
@@ -163,7 +166,7 @@
|
||||
</view>
|
||||
|
||||
<!-- AI 回答上方只显示时间(昵称"智能助手"已按需求移除) -->
|
||||
<view class="message-nickname ai-nickname">
|
||||
<view class="message-nickname ai-nickname" v-if="chatUiConfig.ui_show_message_time">
|
||||
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
|
||||
</view>
|
||||
<view class="message-bubble">
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
|
||||
<view class="chat-content" :style="{ bottom: '0rpx' }">
|
||||
<ai-chat-message
|
||||
v-if="uiCfgReady"
|
||||
ref="chat"
|
||||
:preloaded-ui-config="preloadedUiCfg"
|
||||
:initial-messages="initialMessages"
|
||||
:fallback-welcome="fallbackWelcome"
|
||||
:user-avatar="userAvatar"
|
||||
@@ -96,6 +98,12 @@ export default {
|
||||
agentId: 'agent_aikefu',
|
||||
pageTitle: '',
|
||||
|
||||
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
|
||||
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
|
||||
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
|
||||
uiCfgReady: false,
|
||||
preloadedUiCfg: null,
|
||||
|
||||
// --- 系统导航栏/状态栏相关 ---
|
||||
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
|
||||
statusBarHeight: 0, // 状态栏高度,默认0
|
||||
@@ -140,6 +148,34 @@ export default {
|
||||
async onLoad(options) {
|
||||
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
|
||||
|
||||
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
|
||||
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
|
||||
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
|
||||
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
|
||||
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
|
||||
try {
|
||||
const cached = aiService.getChatUiConfigSync();
|
||||
if (cached) {
|
||||
this.preloadedUiCfg = cached;
|
||||
this.uiCfgReady = true;
|
||||
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
|
||||
}
|
||||
} catch (e) { /* 缓存读取失败走预取 */ }
|
||||
if (!this.uiCfgReady) {
|
||||
try {
|
||||
const cfg = await Promise.race([
|
||||
aiService.getChatUiConfig(true),
|
||||
new Promise(resolve => 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 传不同值,
|
||||
|
||||
Reference in New Issue
Block a user