This commit is contained in:
2026-09-16 16:38:34 +08:00
parent 171190c1cd
commit 78973c99a3
5 changed files with 197 additions and 57 deletions

View File

@@ -116,6 +116,28 @@ function getUserId() {
return guestId; return guestId;
} }
/**
* 递归在 info 响应中查找配置 uuid (媒体上传必填)。
* 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。
*/
function findConfigUuid(obj, depth) {
if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return '';
var keys;
try { keys = Object.keys(obj); } catch (e) { return ''; }
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]);
}
for (var j = 0; j < keys.length; j++) {
var v = obj[keys[j]];
if (v && typeof v === 'object') {
var found = findConfigUuid(v, (depth || 0) + 1);
if (found) return found;
}
}
return '';
}
/** /**
* 获取当前 AI Agent。 * 获取当前 AI Agent。
*/ */
@@ -783,13 +805,14 @@ export default {
async: false, async: false,
}); });
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response)); console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
// 媒体上传需要 config_uuid (后端校验 config_uid/config_uuid 不能为空)。 // 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。
// 此前从未写入过该缓存, 导致上传表单里 config_uuid 恒为空。
// 这里从 info 响应中提取并持久化, 兼容多种字段名。
const cfg = response && response.data; const cfg = response && response.data;
if (cfg && typeof cfg === 'object') { const uuid = findConfigUuid(cfg, 0);
const uuid = cfg.config_uuid || cfg.config_uid || cfg.uuid || (cfg.info && (cfg.info.config_uuid || cfg.info.uuid)) || ''; if (uuid) {
if (uuid) uni.setStorageSync('current_config_uuid', uuid); uni.setStorageSync('current_config_uuid', uuid);
console.log('[AI Service] 已缓存 config_uuid:', uuid);
} else {
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
} }
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -1038,9 +1061,10 @@ export default {
/** /**
* 获取当前 config_uuid (媒体上传路由用) * 获取当前 config_uuid (媒体上传路由用)
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
*/ */
getCurrentConfigUuid() { getCurrentConfigUuid() {
return uni.getStorageSync('current_config_uuid') || ''; return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || '';
}, },
/** /**

View File

@@ -63,7 +63,7 @@ if (process?.env?.NODE_ENV === 'development') {
} }
// #endif // #endif
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain } const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
export default { export default {
/** /**
@@ -80,6 +80,10 @@ export default {
//api请求地址 //api请求地址
baseUrl: domain, baseUrl: domain,
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
aikefuConfigUuid: aikefuConfigUuid,
// 图片域名 // 图片域名
imgDomain: domain, imgDomain: domain,

View File

@@ -99,8 +99,10 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
name: 'file', name: 'file',
formData: { formData: {
kind, kind,
agent_id: addon === 'aiagent' ? agentId : undefined, // aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(), uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '', user_id: aiService.getUserId?.() || '',
}, },
@@ -135,7 +137,13 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
form.append('file', file) form.append('file', file)
form.append('kind', kind) form.append('kind', kind)
if (addon === 'aiagent') form.append('agent_id', agentId) if (addon === 'aiagent') form.append('agent_id', agentId)
else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '') else {
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
const uuid = aiService.getCurrentConfigUuid?.() || ''
form.append('config_uuid', uuid)
form.append('config_uid', uuid)
form.append('agent_id', agentId)
}
form.append('uniacid', aiService.getUniacid()) form.append('uniacid', aiService.getUniacid())
form.append('user_id', aiService.getUserId?.() || '') form.append('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, { const res = await fetch(Config.baseUrl + url, {
@@ -184,8 +192,9 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
'token': store.state.token || '' 'token': store.state.token || ''
}, },
data: { data: {
agent_id: addon === 'aiagent' ? agentId : undefined, agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(), uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '', user_id: aiService.getUserId?.() || '',
}, },

View File

@@ -53,11 +53,17 @@
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) --> <!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message"> <view v-if="message.role === 'user'" class="user-message">
<view class="message-content"> <view class="message-content">
<view v-if="message.type === 'image'" class="message-bubble user-image-bubble"> <view v-if="message.type === 'image'">
<view class="message-bubble user-image-bubble">
<image :src="message.imageUrl" mode="aspectFill" class="user-image" /> <image :src="message.imageUrl" mode="aspectFill" class="user-image" />
<view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view> <view v-if="message.uploading" class="upload-mask"><text class="upload-tip">上传中...</text></view>
<view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view> <view v-if="message.uploadError" class="upload-mask upload-error"><text class="upload-tip">{{ message.uploadError }}</text></view>
</view> </view>
<!-- 图片配文: 图文一起发出时, 文字显示在图片下方 -->
<view v-if="message.content" class="message-bubble image-caption-bubble">
<text class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡 <!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 用户文件气泡
tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走 tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走
用户侧, 走 AI 侧 attachments) 时, m.type='file' + m.fileName/size 用户侧, 走 AI 侧 attachments) 时, m.type='file' + m.fileName/size
@@ -315,6 +321,13 @@
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) --> <!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle"> <view class="input-area" :style="inputAreaStyle">
<view class="input-card"> <view class="input-card">
<!-- 待发送图片附件预览: 挂在输入条内部顶端(文字行上方), 出现时输入框自动向上撑高, 删除后恢复原高度 -->
<view v-if="pendingImage" class="pending-attachment">
<image :src="pendingImage.uploaded ? pendingImage.uploaded.url : pendingImage.tempPath" mode="aspectFill" class="pending-image" />
<view v-if="pendingImage.uploading" class="pending-mask"><text>上传中</text></view>
<view v-else-if="pendingImage.uploadError" class="pending-mask error"><text>失败</text></view>
<view class="pending-remove" @tap="removePendingImage">×</view>
</view>
<!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 --> <!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 -->
<view class="input-row-top"> <view class="input-row-top">
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 --> <!-- 无文字时左侧显示语音图标,点击才打开语音面板 -->
@@ -353,8 +366,8 @@
<text class="plus-icon">+</text> <text class="plus-icon">+</text>
</button> </button>
<!-- 有文字时显示发送按钮 --> <!-- 有文字或图片附件上传完成时显示发送按钮 -->
<button v-if="hasInputText" class="top-send-btn" @click="sendMessage"> <button v-if="hasInputText || canSendPendingImage" class="top-send-btn" @click="sendMessage">
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 --> <!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
<text class="send-plane-text">➤</text> <text class="send-plane-text">➤</text>
</button> </button>
@@ -390,7 +403,7 @@
<!-- 更多工具面板 --> <!-- 更多工具面板 -->
<ai-chat-tools-panel <ai-chat-tools-panel
:visible.sync="showToolsPanel" :visible.sync="showToolsPanel"
@image-selected="onImageSelected" @image-picked="onImagePicked"
@file-selected="onFileSelected" @file-selected="onFileSelected"
@voice-start="showVoicePanel" @voice-start="showVoicePanel"
@location-selected="onLocationSelected" @location-selected="onLocationSelected"
@@ -454,6 +467,7 @@
</template> </template>
<script> <script>
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js' import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
import { uploadImage } from '@/common/js/media-upload.js'
import IMAGE_CONFIG from '@/common/js/image-config.js' import IMAGE_CONFIG from '@/common/js/image-config.js'
import { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js' import { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js'
import AiChatSearch from './components/ai-chat-search.vue' import AiChatSearch from './components/ai-chat-search.vue'
@@ -519,6 +533,8 @@ export default {
return { return {
messages: [], messages: [],
inputText: '', inputText: '',
// 输入框待发送图片附件 (选图后先预览并后台上传, 发送时随文字一起发出)
pendingImage: null,
scrollTop: 0, scrollTop: 0,
scrollIntoViewId: null, scrollIntoViewId: null,
hasMoreHistory: false, hasMoreHistory: false,
@@ -609,6 +625,10 @@ export default {
const hasEmoji = this.containsEmoji(this.inputText) const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji return hasContent || hasEmoji
}, },
// 待发送图片已上传完成, 可发送
canSendPendingImage() {
return !!(this.pendingImage && !this.pendingImage.uploading && !this.pendingImage.uploadError && this.pendingImage.uploaded)
},
hasInputText() { hasInputText() {
return this.inputText && this.inputText.trim() !== '' return this.inputText && this.inputText.trim() !== ''
} }
@@ -735,9 +755,27 @@ export default {
this.$emit('nickname-changed', newNickname) this.$emit('nickname-changed', newNickname)
}, },
onImageSelected(imageMessage) { // 选图后: 不直接进聊天区, 作为输入框上方附件预览并立即开始上传
this.messages.push(imageMessage) onImagePicked(paths) {
this.$emit('image-selected', imageMessage) const list = Array.isArray(paths) ? paths : (paths ? [paths] : [])
if (!list.length) return
const path = list[0]
this.pendingImage = { tempPath: path, uploading: true, uploaded: null, uploadError: null }
uploadImage(path).then((uploaded) => {
if (!this.pendingImage) return
this.$set(this.pendingImage, 'uploading', false)
this.$set(this.pendingImage, 'uploaded', uploaded)
}).catch((e) => {
if (!this.pendingImage) return
this.$set(this.pendingImage, 'uploading', false)
this.$set(this.pendingImage, 'uploadError', (e && e.message) || '上传失败')
uni.showToast({ title: '图片上传失败: ' + ((e && e.message) || ''), icon: 'none' })
})
},
// 移除待发送图片附件
removePendingImage() {
this.pendingImage = null
}, },
onFileSelected(fileMessage) { onFileSelected(fileMessage) {
@@ -983,11 +1021,24 @@ async loadChatHistoryIfExist() {
// 发送消息 // 发送消息
async sendMessage() { async sendMessage() {
// 确保有内容或表情 // 确保有内容、表情或待发送图片
const hasContent = this.inputText && this.inputText.trim() !== '' const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText) const hasEmoji = this.containsEmoji(this.inputText)
const pending = this.pendingImage
if (!hasContent && !hasEmoji) return // 附件图片必须上传完成才能发送
if (pending) {
if (pending.uploading) {
uni.showToast({ title: '图片上传中,请稍候', icon: 'none' })
return
}
if (pending.uploadError || !pending.uploaded) {
uni.showToast({ title: '图片上传失败,请删除后重试', icon: 'none' })
return
}
}
if (!hasContent && !hasEmoji && !pending) return
// 收集已上传、尚未随消息发送的附件 (用户图片/文件消息) // 收集已上传、尚未随消息发送的附件 (用户图片/文件消息)
const readyAttachments = this.messages const readyAttachments = this.messages
@@ -1006,13 +1057,34 @@ async loadChatHistoryIfExist() {
} }
}) })
// 输入框待发送图片: 图文一起发出
if (pending && pending.uploaded) {
readyAttachments.push({
type: pending.uploaded.kind || 'image',
url: pending.uploaded.url,
mime: pending.uploaded.mime,
name: pending.uploaded.name,
size: pending.uploaded.size
})
}
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed // 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now() this._aiRequestStart = Date.now()
const queryText = this.inputText && this.inputText.trim() const queryText = hasContent
? this.inputText.trim() ? this.inputText.trim()
: (readyAttachments.length ? '[附件]' : '') : (readyAttachments.length ? '[附件]' : '')
const userMessage = { // 带图片时消息类型为 image, 图片 + 配文一起显示; 纯文字时仍为 text
const userMessage = pending
? {
id: ++this.messageId,
role: 'user',
type: 'image',
imageUrl: pending.uploaded.url || pending.tempPath,
content: hasContent ? this.inputText.trim() : '',
timestamp: Date.now()
}
: {
id: ++this.messageId, id: ++this.messageId,
role: 'user', role: 'user',
type: 'text', type: 'text',
@@ -1022,6 +1094,7 @@ async loadChatHistoryIfExist() {
this.shouldScrollToBottom = true this.shouldScrollToBottom = true
this.messages.push(userMessage) this.messages.push(userMessage)
this.inputText = '' this.inputText = ''
this.pendingImage = null
// 显示AI正在处理 - 带状态轮换提示 // 显示AI正在处理 - 带状态轮换提示
const loadingMessage = { const loadingMessage = {
@@ -1046,7 +1119,8 @@ async loadChatHistoryIfExist() {
try { try {
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判) // 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理 // 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content) // 纯图片无文字时, 给 AI 的 query 用占位文案, 避免空 query
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content || (readyAttachments.length ? '[图片]' : ''))
if (this.enableStreaming) { if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage, readyAttachments) await this.sendStreamMessage(enhancedMessage, readyAttachments)
} else { } else {
@@ -4578,4 +4652,54 @@ _handleStreamError(msg) {
&.upload-error .upload-tip { color: #ffb3b3; } &.upload-error .upload-tip { color: #ffb3b3; }
} }
} }
/* 图片消息配文气泡 (图文一起发出时, 显示在图片下方) */
.image-caption-bubble {
margin-top: 10rpx;
background: #f5f5f5;
border-radius: 20rpx;
padding: 14rpx 22rpx;
max-width: 460rpx;
.message-text {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
word-break: break-all;
}
}
/* 输入框内待发送图片缩略图: 位于文字输入行上方, 出现时输入框自动向上撑高 */
.pending-attachment {
position: relative;
display: inline-flex;
align-self: flex-start;
margin: 6rpx 14rpx 10rpx;
width: 120rpx;
height: 120rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
.pending-image { width: 100%; height: 100%; display: block; }
.pending-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
text { color: #fff; font-size: 20rpx; }
&.error text { color: #ffb3b3; }
}
.pending-remove {
position: absolute;
top: 0; right: 0;
width: 36rpx; height: 36rpx;
line-height: 32rpx;
text-align: center;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 26rpx;
border-radius: 0 0 0 14rpx;
}
}
</style> </style>

View File

@@ -80,32 +80,11 @@ export default {
count: 9, count: 9,
sizeType: ['compressed'], sizeType: ['compressed'],
sourceType: ['album', 'camera'], sourceType: ['album', 'camera'],
success: async (res) => { success: (res) => {
that.hideToolsPanel() that.hideToolsPanel()
for (const p of res.tempFilePaths) { // 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
const pending = { // 用户可继续输入文字, 点发送时图文一起发出
id: ++that.messageId, that.$emit('image-picked', res.tempFilePaths || [])
role: 'user',
type: 'image',
content: '',
imageUrl: p,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
that.$emit('image-selected', pending)
try {
const uploaded = await uploadImage(p)
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploaded', uploaded)
that.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '图片上传失败: ' + (e.message || ''), icon: 'none' })
}
}
}, },
fail: (err) => { fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) { if (err.errMsg && !err.errMsg.includes('cancel')) {