Files
msh-system/msh_single_uniapp/pages/tool/ai-nutritionist.vue
msh-agent 31f909247e fix(ai-nutritionist): 图文问答注入营养师 system prompt(test-0415 反馈3-4)
- 旧版多模态消息仅有用户 parts,模型无领域上下文,常回退到通用客套话
- 新增 SYSTEM_NUTRITIONIST:专业中文营养师人设 + 慢病饮食知识 + 非食物兜底
- 仅在含 image_url 时注入,避免影响纯文字流式(3-2)回归
- image 类型用户提示从「请分析这张图片」改为「识别食物,给营养成分与饮食建议」

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:18:27 +08:00

1531 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="iconfont icon-fuzhi action-icon"></text>
</view>
<!-- 重新生成仅最后一条AI消息显示 -->
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
<text class="iconfont icon-shuaxin action-icon"></text>
</view>
<!-- 语音朗读 -->
<view class="action-btn" :class="{ 'action-btn-active': ttsPlayingIndex === index }" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
<text class="iconfont icon-laba action-icon"></text>
</view>
<!-- 删除 -->
<view class="action-btn" @click="deleteMessage(index)">
<text class="iconfont icon-shanchu 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';
export default {
data() {
return {
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 {
// 跳过 OSS 上传(图片接口会拒绝 mp3直接读取 base64 调用一句话识别≤60s
const base64Data = await this.readFileAsBase64(res.tempFilePath);
if (!base64Data) {
throw new Error('读取录音文件失败');
}
const dataLen = res.fileSize || Math.floor(base64Data.length * 3 / 4);
const sentenceRes = await api.sentenceRecognition(base64Data, dataLen, 'mp3');
if (!sentenceRes || sentenceRes.code !== 200) {
throw new Error((sentenceRes && (sentenceRes.message || sentenceRes.msg)) || '语音识别失败');
}
const recognizedText = sentenceRes.data && sentenceRes.data.result ? String(sentenceRes.data.result).trim() : '';
if (recognizedText) {
this.inputText = this.inputText ? (this.inputText + ' ' + recognizedText) : recognizedText;
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: error && error.message ? error.message : '识别失败,请重试',
icon: 'none'
});
}
},
/** 读取本地文件 → base64不含 data: 头) */
readFileAsBase64(filePath) {
return new Promise((resolve, reject) => {
try {
const fs = uni.getFileSystemManager();
fs.readFile({
filePath,
encoding: 'base64',
success: (r) => resolve(r.data || ''),
fail: (e) => reject(e)
});
} catch (e) { reject(e); }
});
},
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 = [];
}
}
})
},
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();
},
/**
* 构建 OpenAI 兼容格式的 messagesKieAI Gemini /api/front/kieai/gemini/chat
* @param {string|Array} content 消息内容
* @param {string} type 消息类型 text / image / multimodal
* @returns {Array} messages 数组 [{role, content}]
*/
buildChatMessages(content, type) {
// test-0415 反馈3-4图文问答需明确告诉模型「请基于图片识别食物 / 营养建议」,
// 否则模型容易回退到通用客套话;纯文字场景仍走旧逻辑(避免影响 3-2 流式回归)
const SYSTEM_NUTRITIONIST = '你是一名专业的中文营养师助手。当用户消息中包含图片image_url请先识别图片中的食物或菜品然后基于其常见营养成分能量、蛋白质、脂肪、碳水、钾、磷、钠、嘌呤等给出针对慢性肾病/痛风/糖尿病人群的饮食建议;若图片不是食物,请明确说明「这张图不是食物」并简短回应用户。'
if (type === 'text') {
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }]
}
if (type === 'image') {
let fileInfo = content
if (typeof fileInfo === 'string') {
try { fileInfo = JSON.parse(fileInfo) } catch (e) { /* 非JSON */ }
}
const imageUrl = (fileInfo && fileInfo.url) || (fileInfo && fileInfo.path) || ''
if (imageUrl) {
return [
{ role: 'system', content: SYSTEM_NUTRITIONIST },
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: imageUrl } },
{ type: 'text', text: '请基于这张图片识别食物,给出营养成分与饮食建议' }
]
}
]
}
return [{ role: 'user', content: '我发送了一张图片,请帮我分析' }]
}
// multimodal直接传多模态 parts含 image_url 时附加 system prompt 引导模型分析图片
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }]
const hasImage = parts.some((p) => p && (p.type === 'image_url' || p.image_url))
const messages = []
if (hasImage) {
messages.push({ role: 'system', content: SYSTEM_NUTRITIONIST })
}
messages.push({ role: 'user', content: parts })
return messages
},
async sendToAI(content, type) {
this.isLoading = true;
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
this.messageList.push(aiMsg);
this.scrollToBottom();
try {
// BUG-005本页文本/多模态对话统一走 KieAI GeminiPOST /api/front/kieai/gemini/chat
await this._sendViaGemini(content, type, aiMsg)
} 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));
}
}
}
},
// ========== KieAI Gemini流式 SSEtest-0415 反馈3-2==========
// 旧版 stream: false 等待全文返回 → 5-15s 才出文字。
// 现行:调用 kieaiGeminiChatStream首包到达即停 loading 圈,逐 chunk 渲染 → TTFB <1.5s
_sendViaGemini(content, type, aiMsg) {
return new Promise((resolve) => {
const messages = this.buildChatMessages(content, type)
let receivedAny = false
let errorMsg = ''
const ctrl = api.kieaiGeminiChatStream({ messages })
this._streamCtrl = ctrl
ctrl.onMessage((delta) => {
if (!receivedAny) {
receivedAny = true
aiMsg.loading = false
aiMsg.streaming = true
this.messageList = [...this.messageList]
}
aiMsg.content += delta
// 每个 chunk 触发响应式刷新(保证 streaming 光标移动)
this.messageList = [...this.messageList]
this.scrollToBottom()
})
.onError((err) => {
errorMsg = (err && err.message) || ''
console.error('Gemini 流式对话失败:', err)
})
.onComplete(() => {
if (!receivedAny) {
aiMsg.content = errorMsg || '抱歉,处理您的请求时出现错误,请稍后再试。'
}
this._streamCtrl = null
resolve()
})
})
},
// ---------- TTS 方法 ----------
initAudioContext() {
// #ifdef MP-WEIXIN || APP-PLUS
const ctx = uni.createInnerAudioContext()
ctx.onEnded(() => {
// test-0415 反馈3-3分句队列里还有下一句则继续播否则结束
if (this.ttsPlaying && (this._ttsPrefetch || (this._ttsQueue && this._ttsQueue.length > 0))) {
this._playNextTTSChunk()
return
}
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()
}
},
/**
* 分句播放 TTStest-0415 反馈3-3
* - 旧版:把全文丢给 TTS → 大文本合成耗时长 → 首声延迟 4-8s
* - 现行:按中文句号/问号/感叹号/分号切分;首句合成立即播放,
* 下一句在前一句播放期间预合成,形成流水线
*/
async playTTS(index) {
const msg = this.messageList[index]
if (!msg || !msg.content) return
if (this.ttsPlaying) this.stopTTS()
const sentences = this.splitTTSSentences(msg.content)
if (sentences.length === 0) return
this.ttsPlayingIndex = index
this.ttsPlaying = true
this._ttsQueue = sentences.slice()
this._ttsCurrentIndex = index
// 预先发起首句合成(首字节最快)
this._ttsPrefetch = api.cozeTextToSpeech({ input: this._ttsQueue.shift() })
this._playNextTTSChunk()
},
async _playNextTTSChunk() {
if (!this.ttsPlaying || this.ttsPlayingIndex !== this._ttsCurrentIndex) return
let prefetch = this._ttsPrefetch
this._ttsPrefetch = null
// 提前发起下一句合成pipeline
if (this._ttsQueue && this._ttsQueue.length > 0) {
const nextText = this._ttsQueue.shift()
this._ttsPrefetch = api.cozeTextToSpeech({ input: nextText })
}
try {
const tempPath = await prefetch
if (!this.innerAudioContext || !this.ttsPlaying) return
this.innerAudioContext.src = tempPath
this.innerAudioContext.play()
} catch (e) {
console.error('[TTS] 合成失败', e)
if (this._ttsQueue && this._ttsQueue.length > 0) {
// 当前句失败,跳到下一句
this._playNextTTSChunk()
} else {
uni.showToast({ title: '语音合成失败', icon: 'none' })
this.ttsPlaying = false
this.ttsPlayingIndex = -1
}
}
},
/** 中文句末标点切分;保留标点;空白过滤;超长(>120 字)强切 */
splitTTSSentences(text) {
if (!text) return []
const re = /[^。!?!?;\n]+[。!?!?;]?/g
const arr = (String(text).match(re) || [])
.map((s) => s.trim())
.filter(Boolean)
const result = []
for (const s of arr) {
if (s.length <= 120) {
result.push(s)
} else {
// 超长无终点:按 80 字硬切
for (let i = 0; i < s.length; i += 80) result.push(s.slice(i, i + 80))
}
}
return result
},
stopTTS() {
if (this.innerAudioContext) {
this.innerAudioContext.stop()
}
this._ttsQueue = []
this._ttsPrefetch = null
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: 20rpx;
margin-top: 12rpx;
align-items: center;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52rpx;
height: 52rpx;
background: rgba(0, 0, 0, 0.04);
border-radius: 12rpx;
cursor: pointer;
transition: background 0.2s;
}
.action-btn:active {
transform: scale(0.92);
background: rgba(0, 0, 0, 0.08);
}
.action-btn-active {
background: rgba(76, 175, 80, 0.15);
}
.action-icon {
font-size: 28rpx;
color: #666;
}
.action-btn-active .action-icon {
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>