Files
msh-system/msh_single_uniapp/pages/tool/ai-nutritionist.vue
msh-agent dce899f655 fix: 测试反馈0403修改 — 百科Bug修复/份数→克数/AI对话增强/流式输出
1. [P0] food-encyclopedia: 修复 goToFoodDetail TypeError 报错
   - 增加 item 空值防御性校验
   - 加固 filteredFoodList 过滤无效项

2. [P1] calculator-result: 食物份数建议改为克数
   - 模板展示从"X份"改为"X克"
   - applyResult 数据适配:优先读 gram 字段,兜底 portion * gramPerServing 换算

3. [P2] ai-nutritionist: 新增消息操作按钮(复制/重新生成/删除)
   - AI消息气泡下方新增 msg-actions 按钮组
   - 复制到剪贴板、删除单条消息、重新生成最后一条AI回复

4. [P2] ai-nutritionist + models-api: 启用流式输出改善响应速度
   - 新增 kieaiGeminiChatStream 函数(SSE + enableChunked)
   - sendToAI 优先走流式,失败自动降级为非流式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 15:20:10 +08:00

1550 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="ai-chat-page">
<!-- 顶部区域 -->
<view class="header-container">
<!-- 宣传横幅 -->
<view class="promo-banner">
<view class="promo-content">
<view class="promo-left">
<text class="promo-title">慢生活守护健康</text>
<text class="promo-sparkle"></text>
</view>
<text class="promo-subtitle">营养师专家入驻在线答疑</text>
</view>
<view class="promo-right">
<view class="tts-toggle-btn" @click="toggleTTS" :class="{ active: ttsEnabled }">
<text class="tts-toggle-icon">🔊</text>
<!--<text class="tts-toggle-text">{{ ttsEnabled ? '播报开' : '播报关' }}</text>-->
</view>
<view class="clear-btn" @click="clearChat">
<text class="clear-icon">🗑</text>
</view>
<image class="promo-avatar" src="https://uthink2025.oss-cn-shanghai.aliyuncs.com//crmebimage/public/content/2026/01/11/afcaba68d00b4fccaa49ad2a42c78e7fkk03hqv5vl.png" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 聊天消息列表 -->
<scroll-view
class="chat-container"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
>
<view class="message-list">
<!-- AI欢迎消息 -->
<view class="message-item ai-message">
<view class="message-avatar">
<text class="avatar-icon">🤖</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-sender">AI营养师</text>
</view>
<view class="message-bubble ai-bubble">
<text class="message-text">{{ welcomeMessage }}</text>
</view>
</view>
</view>
<!-- 消息列表 -->
<view
v-for="(msg, index) in messageList"
:key="index"
:class="['message-item', msg.role === 'user' ? 'user-message' : 'ai-message']"
>
<!-- AI头像 -->
<view class="message-avatar" v-if="msg.role === 'ai'">
<text class="avatar-icon">🤖</text>
</view>
<view class="message-content">
<!-- AI发送者名称 -->
<view class="message-header" v-if="msg.role === 'ai'">
<text class="message-sender">AI营养师</text>
</view>
<!-- 消息气泡 -->
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
<!-- AI 消息 loading 占位等待回复时显示打字动画-->
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}<text v-if="msg.streaming" class="streaming-cursor">|</text></text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
class="message-image"
@click="previewImage(msg.imageUrl)"
></image>
</view>
<!-- AI 回复操作按钮组 -->
<view
v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'"
class="msg-actions"
>
<!-- 复制 -->
<view class="action-btn" @click="copyMessage(index)">
<text class="action-icon">📋</text>
</view>
<!-- 重新生成仅最后一条AI消息显示 -->
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
<text class="action-icon">🔄</text>
</view>
<!-- 语音朗读 -->
<view class="action-btn" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
<text class="action-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
<!-- 删除 -->
<view class="action-btn" @click="deleteMessage(index)">
<text class="action-icon">🗑</text>
</view>
</view>
</view>
</view>
<!-- 加载中提示仅在没有流式占位消息时显示 -->
<view v-if="isLoading && !messageList.some(m => m.loading || m.streaming)" class="message-item ai-message">
<view class="message-avatar">
<text class="avatar-icon">🤖</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-sender">AI营养师</text>
</view>
<view class="message-bubble ai-bubble">
<view class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 快捷问题区域 -->
<view class="quick-questions">
<view class="quick-btn" @click="sendQuickQuestion('透析患者可以喝牛奶吗?')">
<text>透析患者可以喝牛奶吗</text>
</view>
<view class="quick-btn" @click="sendQuickQuestion('什么食物含磷比较低?')">
<text>什么食物含磷比较低</text>
</view>
</view>
<!-- 输入区域 -->
<view class="input-container">
<!-- 图片预览区域 -->
<view class="image-preview-area" v-if="pendingImages.length > 0">
<view class="preview-item" v-for="(img, index) in pendingImages" :key="index">
<image :src="img.path" mode="aspectFill" class="preview-img"></image>
<view class="delete-btn" @click="removeImage(index)">
<text class="delete-icon">×</text>
</view>
</view>
<!-- 继续上传按钮 -->
<view class="preview-item add-btn" @click="chooseImage" v-if="pendingImages.length < 3">
<text class="add-icon">+</text>
</view>
</view>
<view class="input-wrapper">
<!-- 照片上传按钮 -->
<view class="action-btn" @click="chooseImage">
<image class="action-icon-svg" src="/static/images/icon-camera.svg" mode="aspectFit"></image>
</view>
<!-- 语音切换按钮 -->
<view class="action-btn" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" class="action-icon-svg mic" src="/static/images/icon-mic.svg" mode="aspectFit"></image>
<text v-else class="action-icon"></text>
</view>
<!-- 文本输入框 -->
<input
v-if="!isVoiceMode"
class="chat-input"
v-model="inputText"
placeholder="输入您的问题..."
placeholder-style="color: #9fa5c0"
confirm-type="send"
@confirm="sendMessage"
:focus="inputFocus"
@focus="onInputFocus"
@blur="onInputBlur"
/>
<!-- 语音按住按钮 -->
<view
v-else
class="voice-hold-btn"
:class="{ recording: isRecording }"
@touchstart="startRecord"
@touchend="stopRecord"
>
<text>{{ isRecording ? '松开 结束' : '按住 说话' }}</text>
</view>
<!-- 发送按钮 -->
<view
class="send-btn"
:class="{ active: inputText.trim().length > 0 || pendingImages.length > 0 }"
@click="sendMessage"
>
<image
v-if="inputText.trim().length > 0 || pendingImages.length > 0"
class="send-icon-svg"
src="/static/images/icon-send-active.svg"
mode="aspectFit"
></image>
<image
v-else
class="send-icon-svg"
src="/static/images/icon-send.svg"
mode="aspectFit"
></image>
</view>
</view>
</view>
</view>
</template>
<script>
import api from '@/api/models-api.js';
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters(['userInfo','uid'])
},
data() {
return {
botId: '7591133240535449654',
conversationId: '',
scrollTop: 0,
lastScrollTop: 0, // 用于动态滚动
inputText: '',
inputFocus: false,
isLoading: false,
isVoiceMode: false,
isRecording: false,
recorderManager: null,
recordDuration: 0,
recordTimer: null,
pendingImages: [], // 待发送图片
welcomeMessage: `👋您好我是您的AI营养师助手。
我可以帮您:
• 解答饮食疑问
• 评估食物适宜性
• 提供烹饪建议
• 解读检验报告
有什么想问的吗?`,
messageList: [],
// TTS
ttsEnabled: false,
ttsPlaying: false,
ttsPlayingIndex: -1,
innerAudioContext: null
}
},
onLoad() {
// 页面加载时滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
});
this.initRecorder();
this.initAudioContext();
},
onUnload() {
this.stopRecordTimer();
if (this.isRecording && this.recorderManager) {
this.recorderManager.stop();
}
if (this._streamCtrl) {
this._streamCtrl.abort();
this._streamCtrl = null;
}
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
},
methods: {
// 初始化录音管理器
initRecorder() {
// #ifdef MP-WEIXIN || APP-PLUS
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStart(() => {
console.log('录音开始');
this.recordDuration = 0;
this.startRecordTimer();
});
this.recorderManager.onStop((res) => {
console.log('录音结束', res);
this.stopRecordTimer();
if (res.duration < 1000) {
uni.showToast({
title: '录音时间太短',
icon: 'none'
});
return;
}
this.handleRecordResult(res);
});
this.recorderManager.onError((err) => {
console.error('录音错误:', err);
this.isRecording = false;
this.stopRecordTimer();
uni.showToast({
title: '录音失败,请重试',
icon: 'none'
});
});
// #endif
},
startRecordTimer() {
this.recordTimer = setInterval(() => {
this.recordDuration++;
if (this.recordDuration >= 60) {
this.stopRecord();
}
}, 1000);
},
stopRecordTimer() {
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
this.recordDuration = 0;
},
toggleVoiceMode() {
this.isVoiceMode = !this.isVoiceMode;
if (this.isVoiceMode) {
// 隐藏键盘
uni.hideKeyboard();
} else {
// 聚焦输入框
this.$nextTick(() => {
this.inputFocus = true;
});
}
},
startRecord() {
// #ifdef H5
uni.showModal({
title: '提示',
content: 'H5环境暂不支持语音输入请使用小程序或APP',
showCancel: false
});
return;
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
if (!this.recorderManager) {
uni.showToast({
title: '录音功能初始化失败',
icon: 'none'
});
return;
}
uni.authorize({
scope: 'scope.record',
success: () => {
this.isRecording = true;
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
uni.openSetting();
}
}
});
}
});
// #endif
},
stopRecord() {
if (!this.isRecording) return;
this.isRecording = false;
// #ifdef MP-WEIXIN || APP-PLUS
if (this.recorderManager) {
this.recorderManager.stop();
}
// #endif
uni.showToast({
title: '正在识别...',
icon: 'loading',
duration: 2000
});
},
async handleRecordResult(res) {
try {
const uploadRes = await api.uploadFile(res.tempFilePath, {
model: 'audio',
pid: '8'
});
if (!uploadRes || uploadRes.code !== 200) {
throw new Error('录音文件上传失败');
}
const audioUrl = uploadRes.data.fullUrl;
const recognizedText = await this.recognizeSpeech(audioUrl);
if (recognizedText) {
this.inputText = this.inputText ? (this.inputText + ' ' + recognizedText) : recognizedText;
// Auto-switch back to text mode so user can see the recognized text
if (this.isVoiceMode) {
this.isVoiceMode = false;
this.$nextTick(() => {
this.inputFocus = true;
});
}
} else {
uni.showToast({
title: '未能识别出语音内容',
icon: 'none'
});
}
} catch (error) {
console.error('语音识别流程失败:', error);
uni.showToast({
title: '识别失败,请重试',
icon: 'none'
});
}
},
async recognizeSpeech(audioUrl) {
try {
const createTaskRes = await api.createAsrTask({
url: audioUrl,
engineModelType: '16k_zh',
channelNum: 1,
resTextFormat: 0,
sourceType: 0
});
if (!createTaskRes || createTaskRes.code !== 200) {
throw new Error('创建识别任务失败');
}
const taskId = createTaskRes.data.taskId;
const result = await this.pollAsrResult(taskId);
return this.parseAsrResult(result);
} catch (error) {
console.error('语音识别请求失败:', error);
throw error;
}
},
async pollAsrResult(taskId, maxAttempts = 30, interval = 2000) {
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
try {
const statusRes = await api.queryAsrStatus(taskId);
if (!statusRes || statusRes.code !== 200) throw new Error('查询识别状态失败');
const status = statusRes.data.status;
if (status === 2) return statusRes.data;
else if (status === 3) throw new Error(statusRes.data.errorMsg || '识别失败');
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
console.error('查询识别状态出错:', error);
if (attempts >= maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, interval));
}
}
throw new Error('识别超时,请重试');
},
parseAsrResult(data) {
if (!data || !data.result) return '';
let text = data.result;
text = text.replace(/\[\d+:\d+\.\d+,\d+:\d+\.\d+\]\s*/g, '');
text = text.replace(/\n+/g, ' ').trim();
return text.replace(/\s+/g, ' ');
},
chooseImage() {
uni.chooseImage({
count: 3 - this.pendingImages.length, // 限制最多选择3张
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
uni.showLoading({ title: '处理中...' });
try {
// 遍历处理所有选择的图片
for (const filePath of res.tempFilePaths) {
// 使用新的 Coze 上传接口
const uploadRes = await api.cozeUploadFile(filePath);
if (uploadRes && (uploadRes.code === 0 || uploadRes.code === 200)) {
const fileInfo = uploadRes.data;
// 添加到待发送列表
this.pendingImages.push({
path: filePath,
fileInfo: fileInfo
});
} else {
throw new Error('上传失败: ' + (uploadRes?.msg || uploadRes?.message || '服务器返回异常'));
}
}
} catch (error) {
console.error('上传图片失败:', error);
uni.showToast({
title: error.message || '上传图片失败',
icon: 'none',
duration: 3000
});
} finally {
uni.hideLoading();
}
}
});
},
removeImage(index) {
this.pendingImages.splice(index, 1);
},
/** 将本地图片转为 data URL用于 KieAI 多模态合并请求 */
readFileAsDataUrl(filePath) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager();
const isJpeg = /\.(jpe?g|jfif)$/i.test(filePath);
fs.readFile({
filePath,
encoding: 'base64',
success: (res) => {
const mime = isJpeg ? 'image/jpeg' : 'image/png';
resolve(`data:${mime};base64,${res.data}`);
},
fail: reject
});
});
},
sendImageMessage(imageUrl, fileInfo) {
// 该方法已废弃,逻辑合并到 sendMessage
},
previewImage(url) {
uni.previewImage({
urls: [url]
});
},
sendQuickQuestion(question) {
this.inputText = question;
this.$nextTick(() => {
this.sendMessage();
});
},
// ---------- 消息操作方法(复制/删除/重新生成) ----------
copyMessage(index) {
const msg = this.messageList[index]
if (!msg || !msg.content) return
uni.setClipboardData({
data: msg.content,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
},
deleteMessage(index) {
uni.showModal({
title: '提示',
content: '确定删除这条消息吗?',
success: (res) => {
if (res.confirm) {
this.messageList.splice(index, 1)
this.messageList = [...this.messageList]
}
}
})
},
regenerateMessage(index) {
// 找到该 AI 消息对应的上一条用户消息
let userMsgIndex = index - 1
while (userMsgIndex >= 0 && this.messageList[userMsgIndex].role !== 'user') {
userMsgIndex--
}
if (userMsgIndex < 0) return
const userMsg = this.messageList[userMsgIndex]
// 移除当前 AI 回复
this.messageList.splice(index, 1)
this.messageList = [...this.messageList]
// 重新发送
this.sendToAI(userMsg.content, userMsg.type || 'text')
},
isLastAiMessage(index) {
for (let i = this.messageList.length - 1; i >= 0; i--) {
if (this.messageList[i].role === 'ai' && !this.messageList[i].loading) {
return i === index
}
}
return false
},
clearChat() {
uni.showModal({
title: '提示',
content: '确定要清空对话吗?',
success: (res) => {
if (res.confirm) {
if (this._streamCtrl) {
this._streamCtrl.abort();
this._streamCtrl = null;
}
this.isLoading = false;
this.messageList = [];
this.conversationId = '';
}
}
})
},
showCommonQuestions() {
uni.showToast({
title: '常见问题功能开发中',
icon: 'none'
})
},
onInputFocus() {
this.inputFocus = true
// 延迟滚动到底部,等待键盘弹出
setTimeout(() => {
this.scrollToBottom()
}, 300)
},
onInputBlur() {
this.inputFocus = false
},
async sendMessage() {
if (!this.inputText.trim() && this.pendingImages.length === 0) {
return
}
const imagesToSend = [...this.pendingImages];
const text = this.inputText.trim();
this.pendingImages = [];
this.inputText = '';
// 先展示用户消息到界面
for (const img of imagesToSend) {
this.messageList.push({
role: 'user',
type: 'image',
imageUrl: img.path,
content: '[图片]'
});
}
if (text) {
this.messageList.push({ role: 'user', content: text, type: 'text' });
}
this.scrollToBottom();
// 合并为一次多模态请求:图片(base64) + 文字,统一走 KieAI Gemini
const contentParts = [];
try {
for (const img of imagesToSend) {
const dataUrl = await this.readFileAsDataUrl(img.path);
contentParts.push({ type: 'image_url', image_url: { url: dataUrl } });
}
if (text) {
contentParts.push({ type: 'text', text });
} else if (contentParts.length > 0) {
contentParts.push({ type: 'text', text: '请描述或分析这张图片' });
}
} catch (e) {
console.error('读取图片失败:', e);
this.messageList.push({ role: 'ai', content: '读取图片失败,请重试。' });
this.scrollToBottom();
return;
}
if (contentParts.length === 0) return;
// 仅文字时走纯文字接口;否则走多模态(图+文)一次请求
if (contentParts.length === 1 && contentParts[0].type === 'text') {
await this.sendToAI(contentParts[0].text, 'text');
} else {
await this.sendToAI(contentParts, 'multimodal');
}
this.scrollToBottom();
},
/**
* CommonResult.data 应为 OpenAI 形态;若网关多包一层 data则取内层含 choices/candidates 的对象。
* 展示内容仍只来自模型字段 choices[0].message.content或经后端规范后的同路径
*/
getGeminiPayload(data) {
if (!data || typeof data !== 'object') return null;
if (Array.isArray(data.choices) && data.choices.length > 0) return data;
if (Array.isArray(data.candidates) && data.candidates.length > 0) return data;
const inner = data.data;
if (inner && typeof inner === 'object') {
if (Array.isArray(inner.choices) && inner.choices.length > 0) return inner;
if (Array.isArray(inner.candidates) && inner.candidates.length > 0) return inner;
}
return data;
},
/** BUG-005: 从 KieAI Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } / { text } / { content } */
extractReplyContent(content) {
if (content == null) return '';
if (typeof content === 'number' || typeof content === 'boolean') return String(content);
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content === 'object') {
if (Array.isArray(content.parts)) {
return content.parts.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content.text === 'string') return content.text;
if (typeof content.content === 'string') return content.content;
}
return '';
},
/** 工具方法sleep ms 毫秒 */
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
buildGeminiMessages(content, type) {
if (type === 'text') {
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }];
}
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
return [{ role: 'user', content: parts }];
},
/**
* BUG-005主路径为 response.data.choices[0].message.contentmodels-api 已尽量规范 data 形态)。
* 兼容上游 candidates、或根级即 completion 等变体;仅使用接口返回字段,
* 成功路径不使用 api/tool.js 的 getAIResponse 或本地关键词话术。
*/
getGeminiReplyFromResponse(response) {
if (!response || typeof response !== 'object') return '';
const looksLikeCompletion = (o) =>
o && typeof o === 'object' &&
(Array.isArray(o.choices) || Array.isArray(o.candidates));
let data = response.data;
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) {
return '';
}
}
// 主路径OpenAI 形态 choices[0].message.content与需求 data.choices[0].message.content 一致)
if (data && typeof data === 'object' && Array.isArray(data.choices) && data.choices.length > 0) {
const choice0 = data.choices[0];
const msg = choice0 && choice0.message;
if (msg && typeof msg === 'object') {
const fromMsg = this.extractReplyContent(msg.content);
if (fromMsg.trim()) return fromMsg;
}
const delta = choice0 && choice0.delta;
if (delta && typeof delta === 'object') {
const fromDelta = this.extractReplyContent(delta.content);
if (fromDelta.trim()) return fromDelta;
}
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
return choice0.text;
}
}
if (!looksLikeCompletion(data) && looksLikeCompletion(response)) {
data = response;
}
if (!data || typeof data !== 'object') return '';
const payload = this.getGeminiPayload(data);
if (!payload || typeof payload !== 'object') return '';
const choices = payload.choices;
if (Array.isArray(choices) && choices.length > 0) {
const choice0 = choices[0];
const msg = choice0 && choice0.message;
if (msg && typeof msg === 'object') {
const fromMsg = this.extractReplyContent(msg.content);
if (fromMsg.trim()) return fromMsg;
}
const delta = choice0 && choice0.delta;
if (delta && typeof delta === 'object') {
const fromDelta = this.extractReplyContent(delta.content);
if (fromDelta.trim()) return fromDelta;
}
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
return choice0.text;
}
}
const cands = payload.candidates;
if (Array.isArray(cands) && cands.length > 0) {
const c0 = cands[0];
if (c0 && typeof c0 === 'object') {
const fromCand = this.extractReplyContent(c0.content);
if (fromCand.trim()) return fromCand;
if (typeof c0.text === 'string' && c0.text.trim()) return c0.text;
}
}
return '';
},
async sendToAI(content, type) {
this.isLoading = true;
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
this.messageList.push(aiMsg);
this.scrollToBottom();
const messages = this.buildGeminiMessages(content, type);
// 优先尝试流式输出以改善响应速度感知
try {
await new Promise((resolve, reject) => {
const ctrl = api.kieaiGeminiChatStream({ messages })
this._streamCtrl = ctrl
// 收到第一个 chunk 后切换为 streaming 状态
ctrl.onMessage((deltaText) => {
if (aiMsg.loading) {
aiMsg.loading = false
aiMsg.streaming = true
}
aiMsg.content += deltaText
this.messageList = [...this.messageList]
this.scrollToBottom()
})
ctrl.onError((err) => {
console.warn('[sendToAI] 流式请求失败,降级为非流式:', err)
this._streamCtrl = null
reject(err)
})
ctrl.onComplete(() => {
this._streamCtrl = null
resolve()
})
})
// 流式完成,检查内容
if (!aiMsg.content.trim()) {
aiMsg.content = '模型未返回有效内容,请稍后重试。'
}
} catch (streamError) {
// 流式失败,降级为非流式请求
try {
const response = await api.kieaiGeminiChat({
messages,
stream: false
});
const reply = this.getGeminiReplyFromResponse(response);
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。';
} catch (error) {
console.error('KieAI Gemini 对话失败:', error);
const errText = error && error.message ? String(error.message) : '';
aiMsg.content = errText || '抱歉,处理您的请求时出现错误,请稍后再试。';
}
} finally {
aiMsg.loading = false;
aiMsg.streaming = false;
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
if (this.ttsEnabled && aiMsg.content) {
const aiIdx = this.messageList.indexOf(aiMsg);
if (aiIdx !== -1) {
this.$nextTick(() => this.playTTS(aiIdx));
}
}
}
},
// ---------- TTS 方法 ----------
initAudioContext() {
// #ifdef MP-WEIXIN || APP-PLUS
const ctx = uni.createInnerAudioContext()
ctx.onEnded(() => {
this.ttsPlaying = false
this.ttsPlayingIndex = -1
})
ctx.onError((e) => {
console.error('[TTS] 播放出错', e)
this.ttsPlaying = false
this.ttsPlayingIndex = -1
})
this.innerAudioContext = ctx
// #endif
},
toggleTTS() {
this.ttsEnabled = !this.ttsEnabled
if (!this.ttsEnabled && this.ttsPlaying) {
this.stopTTS()
}
},
async playTTS(index) {
const msg = this.messageList[index]
if (!msg || !msg.content) return
if (this.ttsPlaying) {
this.stopTTS()
}
try {
const tempPath = await api.cozeTextToSpeech({ input: msg.content })
if (!this.innerAudioContext) {
console.warn('[TTS] innerAudioContext 未初始化')
return
}
this.ttsPlayingIndex = index
this.ttsPlaying = true
this.innerAudioContext.src = tempPath
this.innerAudioContext.play()
} catch (e) {
console.error('[TTS] 合成失败', e)
uni.showToast({ title: '语音合成失败', icon: 'none' })
this.ttsPlaying = false
this.ttsPlayingIndex = -1
}
},
stopTTS() {
if (this.innerAudioContext) {
this.innerAudioContext.stop()
}
this.ttsPlaying = false
this.ttsPlayingIndex = -1
},
// ---------- 滚动 ----------
scrollToBottom() {
this.$nextTick(() => {
// 动态切换 scrollTop 值以触发滚动更新
this.lastScrollTop = this.lastScrollTop === 99998 ? 99999 : 99998
this.scrollTop = this.lastScrollTop
})
}
}
}
</script>
<style lang="scss" scoped>
.ai-chat-page {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f4f5f7;
}
/* 顶部区域 */
.header-container {
background: #ffffff;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 98rpx;
padding: 0 24rpx;
border-bottom: 1rpx solid #d0dbea;
}
.nav-back {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 40rpx;
color: #2e3e5c;
font-weight: 300;
}
&:active {
opacity: 0.6;
}
}
.nav-title {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 400;
letter-spacing: -0.5rpx;
}
.nav-placeholder {
width: 56rpx;
}
/* 宣传横幅 */
.promo-banner {
height: 192rpx;
background: linear-gradient(135deg, #fdfaff 0%, #fcf5ff 50%, #ffffff 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx;
position: relative;
overflow: hidden;
}
.promo-content {
display: flex;
flex-direction: column;
gap: 8rpx;
z-index: 1;
}
.promo-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.promo-title {
font-size: 36rpx;
font-weight: 600;
color: #ff6b35;
letter-spacing: 1rpx;
}
.promo-sparkle {
font-size: 28rpx;
color: #ff6b35;
}
.promo-subtitle {
font-size: 28rpx;
color: #5c6bc0;
font-weight: 400;
}
.promo-right {
display: flex;
align-items: center;
gap: 20rpx;
z-index: 1;
}
.clear-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.clear-btn:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 1);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.clear-icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
.clear-text {
font-size: 22rpx;
color: #ff6b35;
font-weight: 500;
}
.tts-toggle-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
margin-right: 12rpx;
&.active {
background: rgba(76, 175, 80, 0.15);
box-shadow: 0 2rpx 8rpx rgba(76, 175, 80, 0.3);
}
}
.tts-toggle-btn:active {
transform: scale(0.95);
}
.tts-toggle-icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
.tts-toggle-text {
font-size: 20rpx;
color: #4caf50;
font-weight: 500;
}
/* 消息操作按钮组 */
.msg-actions {
display: flex;
gap: 16rpx;
margin-top: 8rpx;
align-items: center;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
background: rgba(76, 175, 80, 0.12);
border-radius: 50%;
cursor: pointer;
}
.action-btn:active {
transform: scale(0.9);
}
.action-icon {
font-size: 24rpx;
color: #4caf50;
}
.tts-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8rpx;
width: 48rpx;
height: 48rpx;
background: rgba(76, 175, 80, 0.12);
border-radius: 50%;
cursor: pointer;
}
.tts-play-btn:active {
transform: scale(0.9);
}
.tts-play-icon {
font-size: 24rpx;
color: #4caf50;
}
.promo-avatar {
width: 180rpx;
height: 180rpx;
}
/* 聊天容器 */
.chat-container {
flex: 1;
overflow-y: auto;
}
.message-list {
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.message-item {
display: flex;
gap: 16rpx;
&.ai-message {
justify-content: flex-start;
}
&.user-message {
justify-content: flex-end;
}
}
.message-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: linear-gradient(180deg, #4facfe 0%, #00f2fe 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.avatar-icon {
font-size: 28rpx;
}
}
.message-content {
flex: 1;
max-width: 75%;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.message-header {
display: flex;
align-items: center;
padding-left: 16rpx;
.message-sender {
font-size: 24rpx;
color: #9fa5c0;
}
}
.message-bubble {
border-radius: 32rpx;
padding: 24rpx 32rpx;
word-wrap: break-word;
transition: all 0.3s ease;
&.ai-bubble {
background: #ffffff;
border-top-left-radius: 8rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
.message-text {
color: #3e5481;
}
}
&.user-bubble {
background: linear-gradient(135deg, #ff8c52 0%, #ff6b35 100%);
border-top-right-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.3);
.message-text {
color: #ffffff;
}
}
.message-text {
font-size: 28rpx;
line-height: 1.65;
white-space: pre-wrap;
letter-spacing: -0.2rpx;
}
}
/* 流式输出闪烁光标 */
.streaming-cursor {
animation: blink 0.8s step-end infinite;
color: #4facfe;
font-weight: 600;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 打字指示器 */
.typing-indicator {
display: flex;
gap: 10rpx;
align-items: center;
padding: 8rpx 0;
}
.typing-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #4facfe;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-10rpx);
opacity: 1;
}
}
/* 消息图片 */
.message-image {
max-width: 400rpx;
border-radius: 16rpx;
}
/* 快捷问题区域 */
.quick-questions {
display: flex;
gap: 16rpx;
padding: 20rpx 32rpx;
flex-shrink: 0;
}
.quick-btn {
flex: 1;
height: 84rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
text {
font-size: 24rpx;
color: #3e5481;
letter-spacing: -0.2rpx;
}
&:active {
background: #f4f5f7;
transform: scale(0.98);
}
}
/* 图片预览区域 */
.image-preview-area {
display: flex;
gap: 20rpx;
padding-bottom: 20rpx;
overflow-x: auto;
white-space: nowrap;
.preview-item {
position: relative;
width: 120rpx;
height: 120rpx;
flex-shrink: 0;
.preview-img {
width: 100%;
height: 100%;
border-radius: 16rpx;
background: #f5f5f5;
}
.delete-btn {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 36rpx;
height: 36rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.delete-icon {
color: #ffffff;
font-size: 24rpx;
line-height: 1;
margin-top: -2rpx;
}
}
&.add-btn {
background: #f5f5f5;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx dashed #d0dbea;
.add-icon {
font-size: 48rpx;
color: #9fa5c0;
line-height: 1;
margin-top: -4rpx;
}
&:active {
background: #e0e0e0;
}
}
}
}
/* 输入区域 */
.input-container {
background: #f4f5f7;
padding: 16rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
flex-shrink: 0;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 16rpx;
}
/* 操作按钮 */
.action-btn {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.action-icon {
font-size: 44rpx;
}
.action-icon-svg {
width: 48rpx;
height: 48rpx;
&.mic {
width: 40rpx;
height: 40rpx;
}
}
&:active {
opacity: 0.7;
}
}
.nav-back-hover {
opacity: 0.6;
}
/* 语音按住按钮 */
.voice-hold-btn {
flex: 1;
height: 88rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
border-radius: 100rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
color: #3e5481;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
&.recording {
background: linear-gradient(135deg, #ff8c52 0%, #ff6b35 100%);
color: #ffffff;
border-color: transparent;
transform: scale(0.98);
}
&:active {
background: #f4f5f7;
}
}
.chat-input {
flex: 1;
height: 88rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
border-radius: 100rpx;
padding: 0 96rpx 0 32rpx;
font-size: 32rpx;
color: #3e5481;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
}
.send-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
&.active {
background: rgba(255, 107, 53, 0.1);
.send-icon-svg {
transform: scale(1.1);
}
&:active {
transform: scale(0.9);
background: rgba(255, 107, 53, 0.2);
}
}
.send-icon-svg {
width: 40rpx;
height: 40rpx;
transition: all 0.3s;
}
}
</style>