Files
webUI/src/pages/chat/chat.vue

1705 lines
42 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="chat-container">
<!-- 夜空装饰背景 -->
<view class="night-sky-decoration">
<view class="star star-1"></view>
<view class="star star-2"></view>
<view class="star star-3"></view>
<view class="star star-4"></view>
<view class="star star-5"></view>
<view class="star star-6"></view>
<view class="star star-7"></view>
<view class="star star-8"></view>
</view>
<!-- 自定义导航栏 -->
<view class="custom-navbar">
<view class="navbar-left">
<view v-if="showBackButton" @tap="goBack" class="left-btn">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="clear-btn" @tap="clearContext">清空</text>
</view>
<view class="navbar-center" :class="{'centered': !showBackButton}">
<image class="character-avatar" :src="currentCharacter.avatar" mode="aspectFill" />
<text class="character-name">{{ currentCharacter.name }}</text>
</view>
<view class="navbar-right">
</view>
</view>
<!-- 聊天消息区域 - 使用 Ant Design X Bubble.List -->
<scroll-view
class="chat-messages"
scroll-y="true"
:scroll-top="scrollTop"
scroll-with-animation="true"
>
<view class="message-list">
<!-- 使用 Bubble 组件展示消息 -->
<view
v-for="(message, index) in messages"
:key="index"
class="message-wrapper"
>
<!-- 系统消息 -->
<view v-if="message.type === 'system'" class="system-message">
<text class="system-text">{{ message.content }}</text>
</view>
<!-- AI/用户消息 -->
<view v-else class="bubble-container" :class="[message.type]">
<view class="bubble-avatar" v-if="message.type === 'ai'">
<image :src="currentCharacter.avatar" mode="aspectFill" />
</view>
<view class="bubble-content">
<view class="bubble" :class="[message.type]">
<text class="bubble-text">{{ message.content }}</text>
<!-- AI 消息操作按钮 - 已隐藏播放按钮 -->
<!-- <view class="bubble-actions" v-if="message.type === 'ai' && isLoggedIn">
<view class="action-btn" @click="() => playVoiceResponse(message.content)">
<text class="action-icon">🔊</text>
</view>
</view> -->
<view class="bubble-time">{{ message.time }}</view>
</view>
</view>
<view class="bubble-avatar" v-if="message.type === 'user'">
<image :src="userAvatar" mode="aspectFill" />
</view>
</view>
</view>
<!-- 正在输入指示器 -->
<view class="typing-indicator" v-if="isTyping">
<view class="bubble-avatar">
<image :src="currentCharacter.avatar" mode="aspectFill" />
</view>
<view class="typing-bubble">
<view class="typing-dots">
<view class="dot"></view>
<view class="dot"></view>
<view class="dot"></view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 输入区域 - 使用 Ant Design X Sender 风格 -->
<view class="input-area">
<!-- 智能体选择按钮 -->
<!-- <view class="action-bar" v-if="availableTemplates.length > 0">
<view class="template-selector-btn" @click="showTemplateSelector">
<text class="template-icon">🤖</text>
<text class="template-name">{{ currentTemplateName }}</text>
<text class="arrow-icon"></text>
</view>
</view> -->
<view class="sender-container">
<!-- 文本输入模式 -->
<view v-if="!isRecordingMode" class="text-input-mode">
<input
class="text-input"
v-model="inputText"
placeholder="输入消息..."
:disabled="isTyping || isLoading"
@confirm="sendMessage"
confirm-type="send"
@focus="handleInputFocus"
/>
<view class="input-actions">
<!-- 麦克风按钮已隐藏 -->
<!-- <view class="action-icon-btn" @click="toggleRecordingMode">
<text class="icon">🎙</text>
</view> -->
<view
class="send-btn"
:class="{'active': inputText.trim()}"
@click="sendMessage"
>
<text class="send-icon">发送</text>
</view>
</view>
</view>
<!-- 语音输入模式 -->
<view v-else class="voice-input-mode">
<view
class="hold-to-speak"
:class="{'recording': isActuallyRecording}"
@touchstart="startActualRecording"
@touchend="stopActualRecording"
@touchcancel="cancelActualRecording"
@touchmove="handleRecordingMove"
>
<text class="speak-text">{{ isActuallyRecording ? '松手发送...' : '按住说话' }}</text>
</view>
<!-- 键盘按钮已隐藏 -->
<!-- <view class="action-icon-btn" @click="toggleRecordingMode">
<text class="icon"></text>
</view> -->
</view>
</view>
<!-- 录音状态提示 -->
<view class="recording-overlay" v-if="isActuallyRecording">
<view class="recording-modal">
<text class="recording-tip">{{ recordingTipText }}</text>
<view class="recording-animation" v-if="!isRecordingCancelled">
<view class="wave" v-for="i in 5" :key="i"></view>
</view>
</view>
</view>
</view>
<!-- 智能体选择弹窗 -->
<!-- <view class="modal-overlay" v-if="showTemplateModal" @click="hideTemplateSelector">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">选择智能体</text>
<view class="close-btn" @click="hideTemplateSelector">
<text class="close-icon"></text>
</view>
</view>
<view class="template-list">
<view
class="template-item"
:class="{'active': currentTemplateId === template.templateId}"
v-for="template in availableTemplates"
:key="template.templateId"
@click="() => selectTemplate(template)"
>
<view class="template-info">
<text class="template-name">{{ template.templateName }}</text>
<text class="template-desc">{{ template.templateDesc }}</text>
</view>
<view class="check-mark" v-if="currentTemplateId === template.templateId">
<text class="check-icon"></text>
</view>
</view>
</view>
</view>
</view> -->
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useUserStore } from '@/stores/user.js';
import { getCharacterById, getSmartResponse } from '@/utils/aiCharacters.js';
import { chatAPI, voiceAPI, cleanText, configAPI } from '@/utils/api.js';
// 状态管理
const userStore = useUserStore();
// 响应式数据
const messages = ref([]);
const inputText = ref('');
const isTyping = ref(false);
const isOnline = ref(true);
const scrollTop = ref(0);
const currentCharacter = ref({});
const userAvatar = ref('/static/default-avatar.png');
const conversationId = ref(null);
const isLoading = ref(false);
const isLoggedIn = ref(false);
const showBackButton = ref(true);
// 录音相关
const isRecordingMode = ref(false);
const isActuallyRecording = ref(false);
const isRecordingCancelled = ref(false);
const recordingTipText = ref('松手发送,上移取消');
const touchStartY = ref(0);
const recorderManager = ref(null);
const audioContext = ref(null);
// AI配置
const availableTemplates = ref([]);
const currentModelId = ref(null);
const currentTemplateId = ref(null);
const showTemplateModal = ref(false);
// 当前模板名称
const currentTemplateName = computed(() => {
const template = availableTemplates.value.find(t => t.templateId === currentTemplateId.value);
return template?.templateName || '默认智能体';
});
// 生命周期
onMounted(() => {
initPage();
initRecorder();
});
// 初始化页面
const initPage = async () => {
checkLoginStatus();
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options || {};
if (!options.characterId && !options.roleId) {
showBackButton.value = false;
}
// 蔚AI
if (options.characterId === 'wei-ai' || !options.characterId) {
currentCharacter.value = {
id: 'wei-ai',
name: options.characterName || '蔚AI',
avatar: options.characterAvatar || '/static/avatar/icon_hushi.jpg',
greeting: decodeURIComponent(options.introMessage || '你好我是蔚AI很高兴为您服务')
};
await loadAIConfigs();
createNewConversation('wei-ai');
// 尝试加载历史消息
await loadHistoryMessages();
// 如果没有历史消息,显示欢迎消息
if (messages.value.length === 0) {
addMessage('ai', currentCharacter.value.greeting);
if (!isLoggedIn.value) {
addMessage('system', '您当前处于未登录状态将使用本地模拟回复。登录后可享受完整的AI对话功能。');
}
}
}
// AI角色
else if (options.roleId) {
currentCharacter.value = {
id: options.characterId,
roleId: options.roleId,
name: decodeURIComponent(options.roleName || 'AI角色'),
avatar: decodeURIComponent(options.avatar || '/static/logo.png'),
greeting: decodeURIComponent(options.greeting || '你好!很高兴认识你!'),
roleDesc: decodeURIComponent(options.roleDesc || ''),
modelId: options.modelId || null,
templateId: options.templateId || null,
ttsId: options.ttsId || null,
sttId: options.sttId || null,
temperature: options.temperature || null,
topP: options.topP || null
};
await loadAIConfigs();
if (options.modelId) {
currentModelId.value = parseInt(options.modelId);
}
if (options.templateId) {
currentTemplateId.value = parseInt(options.templateId);
}
createNewConversation(options.roleId);
// 尝试加载历史消息
await loadHistoryMessages();
// 如果没有历史消息,显示欢迎消息
if (messages.value.length === 0) {
addMessage('ai', currentCharacter.value.greeting);
if (!isLoggedIn.value) {
addMessage('system', '您当前处于未登录状态将使用本地模拟回复。登录后可享受完整的AI对话功能。');
}
}
}
// 默认角色
else {
const characterId = options.characterId || 5;
const character = getCharacterById(characterId);
if (character) {
currentCharacter.value = character;
await loadAIConfigs();
createNewConversation(characterId);
// 尝试加载历史消息
await loadHistoryMessages();
// 如果没有历史消息,显示欢迎消息
if (messages.value.length === 0) {
addMessage('ai', character.greeting);
if (!isLoggedIn.value) {
addMessage('system', '您当前处于未登录状态将使用本地模拟回复。登录后可享受完整的AI对话功能。');
}
}
}
}
if (userStore.userInfo?.avatar) {
userAvatar.value = userStore.userInfo.avatar;
}
};
// 加载AI配置
const loadAIConfigs = async () => {
try {
const templatesResult = await configAPI.getTemplates();
if (templatesResult.success) {
availableTemplates.value = templatesResult.data.data?.list || templatesResult.data.data || templatesResult.data;
if (availableTemplates.value.length > 0 && !currentTemplateId.value) {
currentTemplateId.value = availableTemplates.value[0].templateId;
}
} else {
// 模拟数据
availableTemplates.value = [
{ templateId: 1, templateName: '默认AI模型', templateDesc: '在用户与AI角色对话时这个智能体会记住用户的言语习惯。' },
{ templateId: 2, templateName: '1号AI模型', templateDesc: '学习用户的表达方式,并逐渐适应个性化的交流风格。' }
];
currentTemplateId.value = 1;
}
} catch (error) {
console.error('加载AI配置异常:', error);
availableTemplates.value = [
{ templateId: 1, templateName: '默认AI模型', templateDesc: '默认AI模型' }
];
currentTemplateId.value = 1;
}
};
// 检查登录状态
const checkLoginStatus = () => {
const customToken = uni.getStorageSync('custom_token');
const userToken = uni.getStorageSync('user_token');
const userInfo = uni.getStorageSync('userInfo');
isLoggedIn.value = !!(userStore.token || customToken || userToken || userInfo);
};
// 初始化录音器
const initRecorder = () => {
// #ifdef MP-WEIXIN
recorderManager.value = uni.getRecorderManager();
recorderManager.value.onStart(() => {
console.log('录音开始');
});
recorderManager.value.onStop((res) => {
console.log('录音结束', res);
if (!isRecordingCancelled.value) {
handleVoiceMessage(res.tempFilePath);
}
});
recorderManager.value.onError((err) => {
console.error('录音错误', err);
uni.showToast({
title: '录音失败',
icon: 'none'
});
isActuallyRecording.value = false;
});
// #endif
};
// 添加消息
const addMessage = (type, content) => {
const now = new Date();
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
messages.value.push({
type,
content,
time
});
nextTick(() => {
scrollToBottom();
});
};
// 滚动到底部
const scrollToBottom = () => {
const query = uni.createSelectorQuery();
query.select('.message-list').boundingClientRect();
query.exec((res) => {
if (res[0]) {
scrollTop.value = res[0].height;
}
});
};
// 延时工具
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 根据 & 分段,逐条展示 AI 回复
const addSegmentedAIResponse = async (text) => {
if (!text || typeof text !== 'string') return;
// 使用 & 作为分隔符,按段落逐条展示
const segments = text
.split('&')
.map(s => s.trim())
.filter(Boolean);
if (segments.length === 0) {
addMessage('ai', text.trim());
return;
}
for (const seg of segments) {
addMessage('ai', seg);
// 简单的节奏控制与长度相关介于300-1200ms
const delay = Math.min(1200, Math.max(300, seg.length * 30));
await sleep(delay);
}
};
// 发送消息
const sendMessage = async () => {
const text = inputText.value.trim();
if (!text || isTyping.value || isLoading.value) return;
addMessage('user', text);
inputText.value = '';
isTyping.value = true;
try {
await getAIResponse(text);
} catch (error) {
console.error('发送消息失败:', error);
uni.showToast({
title: '发送失败',
icon: 'none'
});
} finally {
isTyping.value = false;
}
};
// 创建或获取会话ID基于角色持久化存储
const createNewConversation = (characterId) => {
// 生成存储key根据角色类型区分
let storageKey = '';
if (characterId === 'wei-ai' || !currentCharacter.value.roleId) {
// 蔚AI或默认角色
storageKey = `session_weiai`;
} else {
// 剧情角色
storageKey = `session_role_${currentCharacter.value.roleId}`;
}
// 尝试从本地存储获取已有的sessionId
let existingSessionId = uni.getStorageSync(storageKey);
if (existingSessionId) {
// 已有sessionId直接使用保持上下文
conversationId.value = existingSessionId;
console.log('使用已有sessionId:', existingSessionId, 'storageKey:', storageKey);
} else {
// 生成新的sessionId
const userId = userStore.userInfo?.openid || userStore.userInfo?.userId || 'guest';
const timestamp = Date.now();
const newSessionId = `session_${characterId}_${userId}_${timestamp}`;
// 存储到本地
uni.setStorageSync(storageKey, newSessionId);
conversationId.value = newSessionId;
console.log('创建新sessionId:', newSessionId, 'storageKey:', storageKey);
}
};
// 加载历史消息
const loadHistoryMessages = async () => {
if (!conversationId.value || !isLoggedIn.value) {
console.log('没有sessionId或用户未登录跳过加载历史消息');
return;
}
try {
console.log('开始加载历史消息sessionId:', conversationId.value);
const result = await chatAPI.getHistoryMessages(conversationId.value);
if (result.success && result.data && result.data.length > 0) {
console.log('获取到历史消息:', result.data.length, '条');
// 将后端消息格式转换为前端格式保持与API回复一致的“清理后回复”逻辑
const historyMessages = [];
result.data.forEach(msg => {
// 格式化时间
let timeStr = '';
if (msg.createTime) {
const date = new Date(msg.createTime);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
timeStr = `${hours}:${minutes}`;
}
const messageType = msg.sender === 'assistant' ? 'ai' : (msg.sender === 'user' ? 'user' : 'system');
const rawContent = msg.message || '';
// 与API一致先清理文本再根据 & 分段
const cleanedContent = cleanText(rawContent);
if (messageType === 'ai' && cleanedContent.includes('&')) {
const segments = cleanedContent
.split('&')
.map(s => s.trim())
.filter(Boolean);
segments.forEach(segment => {
historyMessages.push({
type: messageType,
content: segment,
time: timeStr
});
});
} else {
// 非 AI 或无分隔符,直接使用清理后的内容
const content = cleanedContent.trim();
if (content) {
historyMessages.push({
type: messageType,
content,
time: timeStr
});
}
}
});
// 按时间排序(从旧到新)
historyMessages.sort((a, b) => {
if (!a.time || !b.time) return 0;
return a.time.localeCompare(b.time);
});
// 清空当前消息列表,加载历史消息
messages.value = historyMessages;
console.log('历史消息加载完成,共', messages.value.length, '条');
// 滚动到底部
await nextTick();
scrollToBottom();
} else {
console.log('没有历史消息或获取失败');
}
} catch (error) {
console.error('加载历史消息失败:', error);
}
};
// 获取AI回复
const getAIResponse = async (userMessage) => {
try {
isLoading.value = true;
const requestParams = {
message: userMessage,
characterId: currentCharacter.value.id,
conversationId: conversationId.value,
modelId: 10,
templateId: 6
};
if (currentCharacter.value.roleId) {
if (currentCharacter.value.modelId) {
requestParams.modelId = currentCharacter.value.modelId;
}
if (currentCharacter.value.templateId) {
requestParams.templateId = currentCharacter.value.templateId;
} else {
requestParams.templateId = currentCharacter.value.roleId;
}
}
const result = await chatAPI.syncChat(requestParams);
if (result.success) {
const aiResponse = result.data;
await addSegmentedAIResponse(aiResponse);
if (result.originalResponse && result.originalResponse.conversationId) {
conversationId.value = result.originalResponse.conversationId;
}
} else {
const fallbackResponse = getSmartResponse(currentCharacter.value, userMessage);
const cleanedResponse = cleanText(fallbackResponse);
await addSegmentedAIResponse(cleanedResponse);
if (!result.isAnonymous) {
uni.showToast({
title: 'AI服务暂时不可用使用本地回复',
icon: 'none',
duration: 2000
});
}
}
} catch (error) {
console.error('AI API调用异常:', error);
const fallbackResponse = getSmartResponse(currentCharacter.value, userMessage);
const cleanedResponse = cleanText(fallbackResponse);
await addSegmentedAIResponse(cleanedResponse);
uni.showToast({
title: '网络异常,使用本地回复',
icon: 'none',
duration: 2000
});
} finally {
isLoading.value = false;
}
};
// 切换录音模式
const toggleRecordingMode = () => {
if (isTyping.value) return;
isRecordingMode.value = !isRecordingMode.value;
if (!isRecordingMode.value && isActuallyRecording.value) {
stopActualRecording();
}
};
// 开始录音
const startActualRecording = (e) => {
if (isTyping.value || !isRecordingMode.value) return;
touchStartY.value = e.touches[0].clientY;
isRecordingCancelled.value = false;
recordingTipText.value = '松手发送,上移取消';
// #ifdef MP-WEIXIN
uni.authorize({
scope: 'scope.record',
success: () => {
isActuallyRecording.value = true;
recorderManager.value.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'aac'
});
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
showCancel: false
});
}
});
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '语音功能仅支持微信小程序',
icon: 'none'
});
// #endif
};
// 停止录音
const stopActualRecording = () => {
if (!isActuallyRecording.value) return;
// #ifdef MP-WEIXIN
isActuallyRecording.value = false;
isRecordingCancelled.value = false;
recorderManager.value.stop();
// #endif
};
// 取消录音
const cancelActualRecording = () => {
if (!isActuallyRecording.value) return;
// #ifdef MP-WEIXIN
isActuallyRecording.value = false;
isRecordingCancelled.value = true;
recorderManager.value.stop();
uni.showToast({
title: '录音已取消',
icon: 'none',
duration: 1000
});
// #endif
};
// 处理录音移动
const handleRecordingMove = (e) => {
if (isActuallyRecording.value) {
const currentY = e.touches[0].clientY;
const deltaY = touchStartY.value - currentY;
if (deltaY > 50) {
isRecordingCancelled.value = true;
recordingTipText.value = '松开取消录音';
} else {
isRecordingCancelled.value = false;
recordingTipText.value = '松手发送,上移取消';
}
}
};
// 处理语音消息
const handleVoiceMessage = async (filePath) => {
if (isRecordingCancelled.value) return;
addMessage('user', '[语音消息]');
isTyping.value = true;
try {
let effectiveModelId = currentModelId.value;
let effectiveTemplateId = currentTemplateId.value;
if (currentCharacter.value.roleId) {
if (currentCharacter.value.modelId) {
effectiveModelId = currentCharacter.value.modelId;
}
if (currentCharacter.value.templateId) {
effectiveTemplateId = currentCharacter.value.templateId;
} else {
effectiveTemplateId = currentCharacter.value.roleId;
}
}
const voiceChatResult = await voiceAPI.voiceChat(filePath, {
modelId: effectiveModelId,
templateId: effectiveTemplateId
});
if (voiceChatResult.success) {
const { userText, aiResponse, audioUrl } = voiceChatResult.data;
if (messages.value.length > 0) {
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage.type === 'user' && lastMessage.content === '[语音消息]') {
lastMessage.content = userText || '[语音消息]';
}
}
await addSegmentedAIResponse(aiResponse);
if (audioUrl) {
await playVoiceResponse(aiResponse, audioUrl);
}
} else {
const uploadResult = await voiceAPI.uploadVoiceChat(filePath, {
modelId: effectiveModelId,
templateId: effectiveTemplateId
});
if (uploadResult.success) {
const { userText, aiResponse, audioUrl } = uploadResult.data;
if (messages.value.length > 0) {
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage.type === 'user' && lastMessage.content === '[语音消息]') {
lastMessage.content = userText || '[语音消息]';
}
}
await addSegmentedAIResponse(aiResponse);
if (audioUrl) {
await playVoiceResponse(aiResponse, audioUrl);
}
} else {
const fallbackResponse = getSmartResponse(currentCharacter.value, '[语音消息]');
const cleanedResponse = cleanText(fallbackResponse);
await addSegmentedAIResponse(cleanedResponse);
uni.showToast({
title: '语音处理失败,使用本地回复',
icon: 'none',
duration: 2000
});
}
}
} catch (error) {
console.error('处理语音消息失败:', error);
const fallbackResponse = getSmartResponse(currentCharacter.value, '[语音消息]');
const cleanedResponse = cleanText(fallbackResponse);
await addSegmentedAIResponse(cleanedResponse);
uni.showToast({
title: '语音处理异常,使用本地回复',
icon: 'none',
duration: 2000
});
} finally {
isTyping.value = false;
}
};
// 播放语音
const playVoiceResponse = async (text, audioUrl = null) => {
// #ifdef MP-WEIXIN
try {
let finalAudioUrl = audioUrl;
if (!finalAudioUrl) {
const ttsResult = await voiceAPI.textToSpeech(text, currentCharacter.value.voiceStyle);
if (ttsResult.success) {
finalAudioUrl = ttsResult.data.audioUrl;
} else {
uni.showToast({
title: '语音合成失败',
icon: 'none'
});
return;
}
}
if (audioContext.value) {
audioContext.value.destroy();
}
audioContext.value = uni.createInnerAudioContext();
audioContext.value.src = finalAudioUrl;
audioContext.value.onPlay(() => {
uni.showToast({
title: '正在播放语音',
icon: 'none',
duration: 1000
});
});
audioContext.value.onError((err) => {
console.error('语音播放错误:', err);
uni.showToast({
title: '语音播放失败',
icon: 'none'
});
});
audioContext.value.play();
} catch (error) {
console.error('语音播放异常:', error);
uni.showToast({
title: '语音播放异常',
icon: 'none'
});
}
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '语音功能仅支持微信小程序',
icon: 'none'
});
// #endif
};
// 显示模板选择器
const showTemplateSelector = () => {
showTemplateModal.value = true;
};
// 隐藏模板选择器
const hideTemplateSelector = () => {
showTemplateModal.value = false;
};
// 选择模板
const selectTemplate = (template) => {
currentTemplateId.value = template.templateId;
uni.showToast({
title: `已切换到${template.templateName}`,
icon: 'none',
duration: 1500
});
hideTemplateSelector();
};
// 处理输入框聚焦
const handleInputFocus = () => {
console.log('输入框获得焦点');
};
// 返回
const goBack = () => {
uni.navigateBack();
};
// 清空对话上下文
const clearContext = async () => {
uni.showModal({
title: '清空对话',
content: '确定要清空与该角色的所有对话记录吗?此操作不可恢复。',
confirmText: '确定清空',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '清空中...' });
// 1. 调用后端清除session如果用户已登录
if (conversationId.value && isLoggedIn.value) {
try {
const result = await chatAPI.clearSession(conversationId.value);
console.log('后端清除会话结果:', result);
} catch (error) {
console.log('后端清除失败,继续本地清除:', error);
}
}
// 2. 删除本地存储的sessionId
let storageKey = '';
if (currentCharacter.value.id === 'wei-ai' || !currentCharacter.value.roleId) {
storageKey = `session_weiai`;
} else {
storageKey = `session_role_${currentCharacter.value.roleId}`;
}
uni.removeStorageSync(storageKey);
console.log('删除本地sessionId:', storageKey);
// 3. 生成新的sessionId
const characterIdForSession = currentCharacter.value.id || currentCharacter.value.roleId || 'default';
createNewConversation(characterIdForSession);
// 4. 清空消息列表(保留欢迎消息)
messages.value = [];
addMessage('ai', currentCharacter.value.greeting || '你好!很高兴再次见到你!');
uni.hideLoading();
uni.showToast({
title: '对话已清空',
icon: 'success'
});
} catch (error) {
console.error('清空对话失败:', error);
uni.hideLoading();
uni.showToast({
title: '清空失败',
icon: 'none'
});
}
}
}
});
};
</script>
<style scoped>
/* 页面容器 */
page {
height: 100vh;
overflow: hidden;
background: linear-gradient(180deg, #1a0b2e 0%, #2d1b4e 50%, #1a0b2e 100%);
}
.chat-container {
width: 100vw;
height: 100vh;
background: linear-gradient(180deg, #1a0b2e 0%, #2d1b4e 50%, #1a0b2e 100%);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
padding-top: calc(100rpx + env(safe-area-inset-top));
padding-bottom: 200rpx;
}
/* 夜空装饰 */
.night-sky-decoration {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.star {
position: absolute;
width: 6rpx;
height: 6rpx;
background: #f9e076;
border-radius: 50%;
animation: twinkle 2s infinite;
box-shadow: 0 0 10rpx #f9e076;
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.star-1 { top: 10%; left: 15%; animation-delay: 0s; }
.star-2 { top: 20%; left: 80%; animation-delay: 0.3s; }
.star-3 { top: 30%; left: 45%; animation-delay: 0.6s; }
.star-4 { top: 50%; left: 25%; animation-delay: 0.9s; }
.star-5 { top: 60%; left: 70%; animation-delay: 1.2s; }
.star-6 { top: 70%; left: 40%; animation-delay: 1.5s; }
.star-7 { top: 15%; left: 60%; animation-delay: 0.4s; }
.star-8 { top: 85%; left: 55%; animation-delay: 1.8s; }
/* 导航栏 */
.custom-navbar {
height: 100rpx;
background: rgba(26, 11, 46, 0.95);
backdrop-filter: blur(20rpx);
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.3);
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
padding-top: env(safe-area-inset-top);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.navbar-left {
position: absolute;
left: 24rpx;
display: flex;
align-items: center;
gap: 16rpx;
color: #f9e076;
font-size: 28rpx;
font-weight: 500;
text-shadow: 0 0 10rpx rgba(249, 224, 118, 0.3);
}
.left-btn {
display: flex;
align-items: center;
gap: 8rpx;
}
.back-icon {
font-size: 32rpx;
}
.navbar-center {
display: flex;
align-items: center;
gap: 16rpx;
}
.navbar-center.centered {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.character-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
border: 3rpx solid rgba(249, 224, 118, 0.3);
box-shadow: 0 4rpx 12rpx rgba(249, 224, 118, 0.2);
}
.character-name {
font-size: 32rpx;
font-weight: 600;
color: #f9e076;
text-shadow: 0 0 15rpx rgba(249, 224, 118, 0.4);
}
.navbar-right {
position: absolute;
right: 32rpx;
display: flex;
align-items: center;
}
.clear-btn {
font-size: 28rpx;
color: #f9e076;
font-weight: 500;
text-shadow: 0 0 8rpx rgba(249, 224, 118, 0.3);
padding: 8rpx 16rpx;
transition: all 0.3s;
}
.clear-btn:active {
opacity: 0.7;
transform: scale(0.95);
}
.status-dot.online {
background: #4caf50;
box-shadow: 0 0 12rpx rgba(76, 175, 80, 0.8);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* 聊天消息区域 */
.chat-messages {
height: calc(100vh - 100rpx - 200rpx - env(safe-area-inset-top));
padding: 24rpx;
overflow-y: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.message-wrapper {
width: 100%;
}
/* 系统消息 */
.system-message {
display: flex;
justify-content: center;
margin: 16rpx 0;
}
.system-text {
background: rgba(249, 224, 118, 0.1);
backdrop-filter: blur(10px);
border: 1rpx solid rgba(249, 224, 118, 0.2);
color: rgba(249, 224, 118, 0.8);
padding: 12rpx 24rpx;
border-radius: 20rpx;
font-size: 24rpx;
text-align: center;
max-width: 80%;
line-height: 1.5;
}
/* Bubble 样式 */
.bubble-container {
display: flex;
align-items: flex-end;
gap: 16rpx;
width: 100%;
}
.bubble-container.user {
flex-direction: row-reverse;
}
.bubble-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.bubble-avatar image {
width: 100%;
height: 100%;
}
.bubble-content {
flex: 1;
max-width: 70%;
}
.bubble {
padding: 20rpx 24rpx;
border-radius: 24rpx;
position: relative;
word-wrap: break-word;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
}
.bubble.ai {
background: linear-gradient(135deg, rgba(249, 224, 118, 0.15) 0%, rgba(249, 224, 118, 0.08) 100%);
border: 1rpx solid rgba(249, 224, 118, 0.3);
color: #f9e076;
border-bottom-left-radius: 8rpx;
}
.bubble.user {
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
color: #1a0b2e;
border-bottom-right-radius: 8rpx;
box-shadow: 0 8rpx 24rpx rgba(249, 224, 118, 0.3);
}
.bubble-text {
font-size: 28rpx;
line-height: 1.6;
display: block;
margin-bottom: 8rpx;
}
.bubble-actions {
display: flex;
gap: 12rpx;
margin: 12rpx 0 8rpx 0;
}
.action-btn {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(249, 224, 118, 0.2);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-btn:active {
background: rgba(249, 224, 118, 0.4);
transform: scale(0.95);
}
.action-icon {
font-size: 24rpx;
}
.bubble-time {
font-size: 20rpx;
opacity: 0.6;
text-align: right;
}
.bubble.ai .bubble-time {
text-align: left;
}
/* 正在输入 */
.typing-indicator {
display: flex;
align-items: flex-end;
gap: 16rpx;
}
.typing-bubble {
background: linear-gradient(135deg, rgba(249, 224, 118, 0.15) 0%, rgba(249, 224, 118, 0.08) 100%);
border: 1rpx solid rgba(249, 224, 118, 0.3);
padding: 20rpx 24rpx;
border-radius: 24rpx;
border-bottom-left-radius: 8rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.3);
}
.typing-dots {
display: flex;
gap: 8rpx;
align-items: center;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #f9e076;
box-shadow: 0 0 8rpx rgba(249, 224, 118, 0.5);
animation: typing 1.4s infinite ease-in-out;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes typing {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* 输入区域 */
.input-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(26, 11, 46, 0.95);
backdrop-filter: blur(20rpx);
border-top: 1rpx solid rgba(249, 224, 118, 0.1);
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.action-bar {
margin-bottom: 12rpx;
}
.template-selector-btn {
background: linear-gradient(135deg, rgba(249, 224, 118, 0.1) 0%, rgba(249, 224, 118, 0.05) 100%);
border: 2rpx solid rgba(249, 224, 118, 0.3);
border-radius: 16rpx;
padding: 12rpx 20rpx;
display: flex;
align-items: center;
gap: 12rpx;
transition: all 0.3s;
}
.template-selector-btn:active {
background: linear-gradient(135deg, rgba(249, 224, 118, 0.2) 0%, rgba(249, 224, 118, 0.1) 100%);
transform: scale(0.98);
}
.template-icon {
font-size: 28rpx;
}
.template-name {
flex: 1;
font-size: 26rpx;
color: #f9e076;
font-weight: 500;
}
.arrow-icon {
font-size: 32rpx;
color: #f9e076;
}
/* Sender 样式 */
.sender-container {
background: rgba(249, 224, 118, 0.08);
border: 1rpx solid rgba(249, 224, 118, 0.2);
border-radius: 24rpx;
overflow: hidden;
}
.text-input-mode {
display: flex;
align-items: center;
gap: 12rpx;
padding: 8rpx 16rpx;
}
.text-input {
flex: 1;
font-size: 28rpx;
color: #f9e076;
background: transparent;
border: none;
outline: none;
padding: 12rpx 0;
min-height: 48rpx;
}
.text-input::placeholder {
color: rgba(249, 224, 118, 0.5);
}
.input-actions {
display: flex;
align-items: center;
gap: 12rpx;
}
.action-icon-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(249, 224, 118, 0.15);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.action-icon-btn:active {
background: rgba(249, 224, 118, 0.3);
transform: scale(0.95);
}
.icon {
font-size: 28rpx;
}
.send-btn {
padding: 12rpx 24rpx;
border-radius: 20rpx;
background: rgba(249, 224, 118, 0.2);
font-size: 26rpx;
color: rgba(249, 224, 118, 0.6);
font-weight: 500;
transition: all 0.3s;
}
.send-btn.active {
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
color: #1a0b2e;
box-shadow: 0 4rpx 12rpx rgba(249, 224, 118, 0.4);
}
.send-btn.active:active {
transform: scale(0.95);
}
.send-icon {
display: block;
}
/* 语音输入模式 */
.voice-input-mode {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 16rpx;
}
.hold-to-speak {
flex: 1;
background: rgba(249, 224, 118, 0.1);
border: 2rpx dashed rgba(249, 224, 118, 0.3);
border-radius: 16rpx;
padding: 20rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.hold-to-speak.recording {
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
border-color: transparent;
animation: pulse-recording 1s infinite;
}
.speak-text {
font-size: 28rpx;
color: #f9e076;
font-weight: 500;
}
.hold-to-speak.recording .speak-text {
color: #1a0b2e;
}
@keyframes pulse-recording {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
/* 录音提示 */
.recording-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(4rpx);
}
.recording-modal {
background: rgba(26, 11, 46, 0.95);
backdrop-filter: blur(20rpx);
border: 2rpx solid rgba(249, 224, 118, 0.3);
padding: 48rpx;
border-radius: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.5);
}
.recording-tip {
font-size: 28rpx;
color: #f9e076;
font-weight: 500;
text-shadow: 0 0 10rpx rgba(249, 224, 118, 0.5);
}
.recording-animation {
display: flex;
gap: 8rpx;
align-items: center;
}
.wave {
width: 6rpx;
height: 40rpx;
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
border-radius: 3rpx;
animation: wave 1.2s infinite ease-in-out;
box-shadow: 0 0 8rpx rgba(249, 224, 118, 0.5);
}
.wave:nth-child(1) { animation-delay: 0s; }
.wave:nth-child(2) { animation-delay: 0.1s; }
.wave:nth-child(3) { animation-delay: 0.2s; }
.wave:nth-child(4) { animation-delay: 0.3s; }
.wave:nth-child(5) { animation-delay: 0.4s; }
@keyframes wave {
0%, 100% {
height: 40rpx;
}
50% {
height: 80rpx;
}
}
/* 模态框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(8rpx);
}
.modal-content {
width: 86%;
max-width: 640rpx;
max-height: 80vh;
background: linear-gradient(135deg, rgba(26, 11, 46, 0.98) 0%, rgba(45, 27, 78, 0.98) 100%);
border: 2rpx solid rgba(249, 224, 118, 0.3);
border-radius: 32rpx;
overflow: hidden;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
background: linear-gradient(135deg, rgba(249, 224, 118, 0.08) 0%, rgba(249, 224, 118, 0.03) 100%);
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #f9e076;
text-shadow: 0 0 15rpx rgba(249, 224, 118, 0.5);
}
.close-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(249, 224, 118, 0.1);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.close-btn:active {
background: rgba(249, 224, 118, 0.2);
transform: scale(0.95);
}
.close-icon {
font-size: 28rpx;
color: #f9e076;
}
.template-list {
max-height: 60vh;
overflow-y: auto;
}
.template-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid rgba(249, 224, 118, 0.1);
transition: all 0.3s;
}
.template-item:active {
background: rgba(249, 224, 118, 0.05);
}
.template-item.active {
background: linear-gradient(135deg, rgba(249, 224, 118, 0.15) 0%, rgba(249, 224, 118, 0.08) 100%);
}
.template-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
padding-right: 16rpx;
}
.template-item .template-name {
font-size: 28rpx;
font-weight: 600;
color: #f9e076;
margin-bottom: 4rpx;
}
.template-desc {
font-size: 24rpx;
color: rgba(249, 224, 118, 0.7);
line-height: 1.5;
}
.check-mark {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: linear-gradient(135deg, #f9e076 0%, #f5d042 100%);
box-shadow: 0 4rpx 12rpx rgba(249, 224, 118, 0.4);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.check-icon {
font-size: 24rpx;
color: #1a0b2e;
font-weight: bold;
}
</style>