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;
}
/**
* 递归在 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。
*/
@@ -783,13 +805,14 @@ export default {
async: false,
});
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
// 媒体上传需要 config_uuid (后端校验 config_uid/config_uuid 不能为空)。
// 此前从未写入过该缓存, 导致上传表单里 config_uuid 恒为空。
// 这里从 info 响应中提取并持久化, 兼容多种字段名。
// 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。
const cfg = response && response.data;
if (cfg && typeof cfg === 'object') {
const uuid = cfg.config_uuid || cfg.config_uid || cfg.uuid || (cfg.info && (cfg.info.config_uuid || cfg.info.uuid)) || '';
if (uuid) uni.setStorageSync('current_config_uuid', uuid);
const uuid = findConfigUuid(cfg, 0);
if (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;
} catch (error) {
@@ -1038,9 +1061,10 @@ export default {
/**
* 获取当前 config_uuid (媒体上传路由用)
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
*/
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
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
export default {
/**
@@ -80,6 +80,10 @@ export default {
//api请求地址
baseUrl: domain,
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
aikefuConfigUuid: aikefuConfigUuid,
// 图片域名
imgDomain: domain,

View File

@@ -99,8 +99,10 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
name: 'file',
formData: {
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_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
@@ -135,7 +137,13 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
form.append('file', file)
form.append('kind', kind)
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('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, {
@@ -184,8 +192,9 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
'token': store.state.token || ''
},
data: {
agent_id: addon === 'aiagent' ? agentId : undefined,
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},

View File

@@ -53,11 +53,17 @@
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message">
<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" />
<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>
<!-- 图片配文: 图文一起发出时, 文字显示在图片下方 -->
<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): 用户文件气泡
tools-panel _uploadOneFile 选 kind='file' (audio/video 当前未走
用户侧, 走 AI 侧 attachments) 时, m.type='file' + m.fileName/size
@@ -315,6 +321,13 @@
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle">
<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">
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 -->
@@ -353,8 +366,8 @@
<text class="plus-icon">+</text>
</button>
<!-- 有文字时显示发送按钮 -->
<button v-if="hasInputText" class="top-send-btn" @click="sendMessage">
<!-- 有文字或图片附件上传完成时显示发送按钮 -->
<button v-if="hasInputText || canSendPendingImage" class="top-send-btn" @click="sendMessage">
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
<text class="send-plane-text">➤</text>
</button>
@@ -390,7 +403,7 @@
<!-- 更多工具面板 -->
<ai-chat-tools-panel
:visible.sync="showToolsPanel"
@image-selected="onImageSelected"
@image-picked="onImagePicked"
@file-selected="onFileSelected"
@voice-start="showVoicePanel"
@location-selected="onLocationSelected"
@@ -454,6 +467,7 @@
</template>
<script>
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 { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js'
import AiChatSearch from './components/ai-chat-search.vue'
@@ -519,6 +533,8 @@ export default {
return {
messages: [],
inputText: '',
// 输入框待发送图片附件 (选图后先预览并后台上传, 发送时随文字一起发出)
pendingImage: null,
scrollTop: 0,
scrollIntoViewId: null,
hasMoreHistory: false,
@@ -609,6 +625,10 @@ export default {
const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji
},
// 待发送图片已上传完成, 可发送
canSendPendingImage() {
return !!(this.pendingImage && !this.pendingImage.uploading && !this.pendingImage.uploadError && this.pendingImage.uploaded)
},
hasInputText() {
return this.inputText && this.inputText.trim() !== ''
}
@@ -735,9 +755,27 @@ export default {
this.$emit('nickname-changed', newNickname)
},
onImageSelected(imageMessage) {
this.messages.push(imageMessage)
this.$emit('image-selected', imageMessage)
// 选图后: 不直接进聊天区, 作为输入框上方附件预览并立即开始上传
onImagePicked(paths) {
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) {
@@ -983,11 +1021,24 @@ async loadChatHistoryIfExist() {
// 发送消息
async sendMessage() {
// 确保有内容或表情
// 确保有内容、表情或待发送图片
const hasContent = this.inputText && this.inputText.trim() !== ''
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
@@ -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
this._aiRequestStart = Date.now()
const queryText = this.inputText && this.inputText.trim()
const queryText = hasContent
? this.inputText.trim()
: (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,
role: 'user',
type: 'text',
@@ -1022,6 +1094,7 @@ async loadChatHistoryIfExist() {
this.shouldScrollToBottom = true
this.messages.push(userMessage)
this.inputText = ''
this.pendingImage = null
// 显示AI正在处理 - 带状态轮换提示
const loadingMessage = {
@@ -1046,7 +1119,8 @@ async loadChatHistoryIfExist() {
try {
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content)
// 纯图片无文字时, 给 AI 的 query 用占位文案, 避免空 query
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content || (readyAttachments.length ? '[图片]' : ''))
if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage, readyAttachments)
} else {
@@ -4578,4 +4652,54 @@ _handleStreamError(msg) {
&.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>

View File

@@ -80,32 +80,11 @@ export default {
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
success: (res) => {
that.hideToolsPanel()
for (const p of res.tempFilePaths) {
const pending = {
id: ++that.messageId,
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' })
}
}
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
// 用户可继续输入文字, 点发送时图文一起发出
that.$emit('image-picked', res.tempFilePaths || [])
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {