Files
schoolNews/schoolNewsWeb/src/views/public/ai/AIAgent.mobile.vue
2025-12-25 10:48:08 +08:00

1128 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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>
<div v-if="hasAgent">
<!-- 悬浮球 -->
<div
v-show="!drawerVisible"
class="ball-container"
:style="ballStyle"
@mousedown="startDrag"
@touchstart="startDrag"
>
<div class="chat-ball" @click="handleBallClick">
<img src="@/assets/imgs/chat-ball.svg" alt="AI助手" class="ball-icon" />
<div v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</div>
</div>
</div>
<!-- AI助手抽屉 -->
<el-drawer
v-model="drawerVisible"
title=""
direction="btt"
size="90%"
:show-close="false"
class="ai-drawer"
>
<!-- 自定义标题栏 -->
<template #header>
<div class="drawer-header">
<button @click="showHistory = !showHistory" class="hamburger-btn">
<div class="hamburger-lines">
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
</div>
</button>
<div class="drawer-title">
<span>{{ agentConfig?.name || 'AI助手' }}</span>
</div>
<button @click="drawerVisible = false" class="close-btn">
<el-icon><Close /></el-icon>
</button>
</div>
</template>
<!-- 抽屉内容 -->
<div class="drawer-content">
<!-- 历史对话侧边栏 -->
<div v-if="showHistory" class="history-sidebar">
<div class="history-header">
<h3>对话历史</h3>
<button @click="showHistory = false" class="close-history-btn">
<el-icon><Close /></el-icon>
</button>
</div>
<div class="history-list">
<button class="new-chat-btn" @click="prepareNewConversation">
+ 新建对话
</button>
<div v-for="conv in conversations" :key="conv.id" class="conversation-item"
:class="{ active: currentConversation?.id === conv.id }" @click="selectConversation(conv)">
<div class="conv-title">{{ conv.title || '新对话' }}</div>
<div class="conv-time">{{ formatTime(conv.updateTime) }}</div>
<button class="delete-conv-btn" @click.stop="deleteConversationConfirm(conv.id || '')">
×
</button>
</div>
</div>
</div>
<!-- 主要聊天区域 -->
<div class="chat-main" :class="{ 'with-history': showHistory }">
<!-- 欢迎界面 -->
<div v-if="!currentConversation && messages.length === 0" class="welcome-content">
<div class="welcome-icon">
<img v-if="agentAvatarUrl" :src="agentAvatarUrl" alt="AI助手" class="welcome-avatar" />
<img v-else src="@/assets/imgs/assistant.svg" alt="AI助手" class="welcome-avatar" />
</div>
<h2>你好我是{{ agentConfig?.name || 'AI助手' }}</h2>
<div class="recommend-wrapper">
<AIRecommend v-if="isFirstLoad" @select="handleRecommendSelect" />
</div>
</div>
<!-- 对话消息列表 -->
<div v-else class="messages-container" ref="chatContentRef">
<div v-for="message in messages" :key="message.id" class="message-item">
<!-- 用户消息 -->
<div v-if="message.role === 'user'" class="message user-message">
<div class="message-avatar">
<div class="avatar-circle">👤</div>
</div>
<div class="message-content">
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
</div>
</div>
<!-- AI 消息 -->
<div v-else class="message ai-message">
<div class="message-avatar">
<div class="avatar-circle ai-avatar">
<img v-if="agentAvatarUrl" :src="agentAvatarUrl" alt="AI助手" class="ai-avatar-img" />
<img v-else src="@/assets/imgs/assistant.svg" alt="AI助手" class="ai-avatar-img" />
</div>
</div>
<div class="message-content">
<div v-if="message.content" class="message-text" v-html="formatMarkdown(message.content)"></div>
<!-- 正在生成中的加载动画 -->
<div v-if="isGenerating && messages[messages.length - 1]?.id === message.id" class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<div class="message-time">{{ formatMessageTime(message.createTime) }}</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-section">
<div class="input-area">
<textarea v-model="inputMessage" class="input-text" placeholder="请输入问题..."
@keydown.enter.exact.prevent="sendMessage" ref="inputRef" rows="2" />
<div class="input-actions">
<button @click="sendMessage" class="send-btn" :disabled="!canSend">
<el-icon><Promotion /></el-icon>
</button>
</div>
</div>
</div>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Close, Paperclip, VideoPause, Promotion } from '@element-plus/icons-vue';
import { chatApi, chatHistoryApi, aiAgentConfigApi, fileUploadApi } from '@/apis/ai';
import type { AiConversation, AiMessage, AiAgentConfig, AiUploadFile } from '@/types/ai';
import { AIRecommend } from '@/views/public/ai';
interface AIAgentProps {
agentId?: string;
}
const props = withDefaults(defineProps<AIAgentProps>(), {
agentId: undefined
});
// ===== 移动端特有状态 =====
const drawerVisible = ref(false);
const showHistory = ref(false);
// ===== AI助手配置 =====
const hasAgent = ref(true);
const agentConfig = ref<AiAgentConfig | null>(null);
const agentAvatarUrl = ref<string>('');
// ===== 悬浮球相关 =====
const ballRef = ref<HTMLElement | null>(null);
const ballX = ref(0); // 实际位置(像素)
const ballY = ref(0);
const unreadCount = ref(0);
const isDragging = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
// 存储悬浮球的相对位置百分比用于窗口resize时保持相对位置
const ballXPercent = ref(1); // 1 表示右侧
const ballYPercent = ref(0.85); // 0.85 表示距离底部较近
const isUserDragged = ref(false); // 标记用户是否手动拖动过
// 拖拽检测相关
const dragStartPosX = ref(0); // 记录拖拽开始时的实际位置
const dragStartPosY = ref(0);
// 悬浮球样式
const ballStyle = computed(() => ({
left: `${ballX.value}px`,
top: `${ballY.value}px`,
position: 'fixed' as const
}));
// 打开抽屉
function openDrawer() {
drawerVisible.value = true;
}
// 开始拖拽
function startDrag(event: MouseEvent | TouchEvent) {
isDragging.value = true;
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
dragStartX.value = clientX - ballX.value;
dragStartY.value = clientY - ballY.value;
// 记录起始位置,用于判断是点击还是拖拽
dragStartPosX.value = ballX.value;
dragStartPosY.value = ballY.value;
// 添加事件监听
document.addEventListener('mousemove', onDrag);
document.addEventListener('touchmove', onDrag, { passive: false });
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchend', endDrag);
event.preventDefault();
}
// 结束拖拽
function endDrag() {
if (!isDragging.value) return;
// 移除事件监听
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', endDrag);
document.removeEventListener('touchmove', onDrag);
document.removeEventListener('touchend', endDrag);
// 计算移动距离
const moveDistanceX = Math.abs(ballX.value - dragStartPosX.value);
const moveDistanceY = Math.abs(ballY.value - dragStartPosY.value);
const totalDistance = Math.sqrt(moveDistanceX * moveDistanceX + moveDistanceY * moveDistanceY);
// 判断是点击还是拖拽移动距离阈值为5px
const isClick = totalDistance <= 5;
if (isClick) {
// 如果是点击,打开抽屉
openDrawer();
} else {
// 如果是拖拽,执行吸附和位置调整
isUserDragged.value = true;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const ballWidth = 40;
const ballHeight = 40;
const margin = 16;
// 自动吸附到左右两侧
if (ballX.value < windowWidth / 2) {
// 吸附到左侧
ballX.value = margin;
ballXPercent.value = 0;
} else {
// 吸附到右侧
ballX.value = windowWidth - ballWidth - margin;
ballXPercent.value = 1;
}
// 限制垂直位置并保存百分比
if (ballY.value < margin) {
ballY.value = margin;
} else if (ballY.value > windowHeight - ballHeight - margin) {
ballY.value = windowHeight - ballHeight - margin;
}
// 保存Y位置的百分比以中心点计算
ballYPercent.value = (ballY.value + ballHeight / 2) / windowHeight;
}
isDragging.value = false;
}
// 拖拽过程
function onDrag(event: MouseEvent | TouchEvent) {
if (!isDragging.value) return;
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
ballX.value = clientX - dragStartX.value;
ballY.value = clientY - dragStartY.value;
event.preventDefault();
}
// 根据百分比计算实际位置
function updateBallPosition() {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const ballWidth = 40;
const ballHeight = 40;
const margin = 16;
// 根据百分比计算位置
if (ballXPercent.value < 0.5) {
// 左侧
ballX.value = margin;
} else {
// 右侧
ballX.value = windowWidth - ballWidth - margin;
}
// 计算Y位置确保不超出边界
let targetY = windowHeight * ballYPercent.value - ballHeight / 2;
targetY = Math.max(margin, Math.min(targetY, windowHeight - ballHeight - margin));
ballY.value = targetY;
}
// 窗口resize监听器
function handleResize() {
updateBallPosition();
}
// 处理悬浮球点击(区分点击和拖拽)
function handleBallClick() {
if (isDragging.value) return; // 拖动过程中不触发
openDrawer();
}
// 对话相关
const conversations = ref<AiConversation[]>([]);
const currentConversation = ref<AiConversation | null>(null);
const hasMoreConversations = ref(false);
const messages = ref<AiMessage[]>([]);
const inputMessage = ref('');
const isGenerating = ref(false);
const chatContentRef = ref<HTMLElement | null>(null);
// 是否是第一次加载(用于控制推荐内容只在首次加载时显示)
const isFirstLoad = ref(true);
const inputRef = ref<HTMLTextAreaElement | null>(null);
const uploadedFiles = ref([]);
// 是否可以发送
const canSend = computed(() => {
return inputMessage.value.trim().length > 0 && !isGenerating.value;
});
// 加载AI助手配置
async function loadAgentConfig() {
try {
if (props.agentId) {
const result = await aiAgentConfigApi.getAgentById(props.agentId);
if (result.success && result.data) {
agentConfig.value = result.data;
} else {
hasAgent.value = false;
}
} else {
const result = await aiAgentConfigApi.listEnabledAgents();
if (result.success && result.dataList && result.dataList.length > 0) {
agentConfig.value = result.dataList[0];
} else {
hasAgent.value = false;
}
}
} catch (error) {
console.error('加载AI助手配置失败:', error);
}
}
// 加载最近对话
async function loadRecentConversations() {
try {
const result = await chatHistoryApi.getRecentConversations(10);
if (result.success) {
const conversationList = result.dataList || result.data;
if (conversationList && Array.isArray(conversationList)) {
conversations.value = conversationList;
} else {
conversations.value = [];
}
}
} catch (error) {
console.error('加载对话历史失败:', error);
}
}
// 准备新对话
function prepareNewConversation() {
currentConversation.value = null;
messages.value = [];
showHistory.value = false;
isFirstLoad.value = false; // 新建对话时不再显示推荐
ElMessage.success('已准备新对话');
}
// 选择对话
async function selectConversation(conv: AiConversation) {
currentConversation.value = conv;
showHistory.value = false;
if (conv.id) {
await loadMessages(conv.id);
}
}
// 删除对话确认
async function deleteConversationConfirm(conversationId: string) {
try {
await ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
});
const result = await chatApi.deleteConversation(conversationId);
if (result.success) {
conversations.value = conversations.value.filter(c => c.id !== conversationId);
if (currentConversation.value?.id === conversationId) {
prepareNewConversation();
}
ElMessage.success('对话已删除');
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除对话失败:', error);
ElMessage.error('删除对话失败');
}
}
}
// 加载更多对话
function loadMoreConversations() {
// TODO: 实现分页加载
}
// 加载消息
async function loadMessages(conversationId: string) {
try {
const result = await chatApi.listMessages(conversationId);
if (result.success) {
const messageList = result.dataList || result.data || [];
messages.value = Array.isArray(messageList) ? messageList : [];
await nextTick();
scrollToBottom();
}
} catch (error) {
console.error('加载消息失败:', error);
}
}
// 发送消息
async function sendMessage() {
if (!canSend.value) return;
const message = inputMessage.value.trim();
if (!message) return;
// 如果没有当前对话,创建新对话
if (!currentConversation.value) {
const title = message.length > 20 ? message.substring(0, 20) + '...' : message;
const newConv = await createNewConversation(title);
if (!newConv) {
ElMessage.error('创建对话失败');
return;
}
}
// 添加用户消息到界面
const userMessage: AiMessage = {
id: `temp-user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
conversationID: currentConversation.value?.id || '',
role: 'user',
content: message,
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
};
messages.value.push(userMessage);
inputMessage.value = '';
await nextTick();
scrollToBottom();
isGenerating.value = true;
// 添加AI消息
const aiMessage: AiMessage = {
id: `temp-ai-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
conversationID: currentConversation.value?.id || '',
role: 'assistant',
content: '',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString()
};
messages.value.push(aiMessage);
try {
let aiMessageContent = '';
chatApi.streamChat({
agentId: agentConfig.value!.id!,
conversationId: currentConversation.value?.id || '',
query: message,
files: []
}, {
onInit: (initData) => {
const lastMessage = messages.value[messages.value.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.id = initData.messageId;
}
},
onMessage: (chunk: string) => {
if (chunk) {
aiMessageContent += chunk;
}
const aiMessageIndex = messages.value.length - 1;
if (aiMessageIndex >= 0) {
messages.value[aiMessageIndex].content = aiMessageContent;
}
nextTick(() => scrollToBottom());
},
onDifyEvent: () => {return},
onMessageEnd: () => {
isGenerating.value = false;
},
onError: (error: Error) => {
console.error('对话失败:', error);
ElMessage.error('对话失败,请重试');
isGenerating.value = false;
}
});
} catch (error) {
console.error('发送消息失败:', error);
ElMessage.error('发送消息失败');
isGenerating.value = false;
}
}
// 创建新对话
async function createNewConversation(title?: string) {
try {
const result = await chatApi.createConversation(agentConfig.value!.id!, title);
if (result.success && result.data) {
currentConversation.value = result.data;
conversations.value.unshift(result.data);
return result.data;
}
} catch (error) {
console.error('创建对话失败:', error);
return null;
}
}
// 处理推荐选择
function handleRecommendSelect(text: string) {
inputMessage.value = text;
sendMessage();
}
// 滚动到底部
function scrollToBottom() {
if (chatContentRef.value) {
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight;
}
}
// 工具函数
function formatTime(dateStr: string | undefined) {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString();
}
function formatMessageTime(dateStr: string | undefined) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
function formatMarkdown(content: string) {
let html = content;
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\n/g, '<br>');
return html;
}
// 生命周期
onMounted(() => {
// 初始化悬浮球位置
updateBallPosition();
// 监听窗口resize事件
window.addEventListener('resize', handleResize);
loadAgentConfig();
loadRecentConversations();
});
// 清理监听器
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped lang="scss">
// 悬浮球样式
.ball-container {
position: fixed;
right: 16px;
bottom: 80px;
z-index: 9999;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.chat-ball {
width: 40px;
height: 40px;
cursor: pointer;
position: relative;
transition: transform 0.3s ease;
user-select: none;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
.ball-icon {
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.unread-badge {
position: absolute;
top: 0;
right: 0;
background: #E7000B;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: bold;
}
}
// 抽屉样式
:deep(.ai-drawer) {
.el-drawer__header {
padding: 0;
margin: 0;
border-bottom: 1px solid #E5E7EB;
}
.el-drawer__body {
padding: 0;
}
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
.hamburger-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
.hamburger-lines {
display: flex;
flex-direction: column;
gap: 3px;
.line {
width: 18px;
height: 2px;
background: #141F38;
transition: all 0.3s;
}
}
}
.drawer-title {
flex: 1;
text-align: center;
font-size: 18px;
font-weight: 600;
color: #141F38;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
color: #6B7280;
font-size: 20px;
}
}
.drawer-content {
display: flex;
height: 100%;
overflow: hidden;
}
// 历史对话侧边栏
.history-sidebar {
width: 280px;
background: #F9FAFB;
border-right: 1px solid #E5E7EB;
display: flex;
flex-direction: column;
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 10;
.history-header {
padding: 16px 20px;
border-bottom: 1px solid #E5E7EB;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #101828;
}
.close-history-btn {
background: none;
border: none;
cursor: pointer;
color: #6B7280;
}
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.new-chat-btn {
width: 100%;
padding: 12px;
background: #E7000B;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
&:hover {
background: #C90009;
}
}
.conversation-item {
padding: 12px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
position: relative;
transition: all 0.2s;
&:hover {
background: #EFF6FF;
.delete-conv-btn {
opacity: 1;
}
}
&.active {
background: #E7000B;
color: white;
.conv-title,
.conv-time {
color: white;
}
}
.conv-title {
font-size: 14px;
font-weight: 500;
color: #101828;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-time {
font-size: 12px;
color: #6B7280;
}
.delete-conv-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.1);
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
.load-more {
text-align: center;
padding: 12px;
button {
background: none;
border: none;
color: #E7000B;
cursor: pointer;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}
}
// 主聊天区域
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
max-width: 430px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
&.with-history {
margin-left: 280px;
}
.welcome-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
text-align: center;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
.recommend-wrapper {
width: 100%;
max-width: 380px;
min-width: 0;
box-sizing: border-box;
}
.welcome-icon {
margin-bottom: 20px;
.welcome-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
}
}
h2 {
font-size: 20px;
font-weight: 600;
color: #101828;
margin: 0 0 20px 0;
}
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
.message-item {
margin-bottom: 20px;
.message {
display: flex;
gap: 12px;
align-items: flex-start;
.message-avatar {
flex-shrink: 0;
.avatar-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: #E5E7EB;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
overflow: hidden;
&.ai-avatar {
background: #EFF6FF;
}
.ai-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.message-content {
flex: 1;
max-width: calc(100% - 50px);
.message-text {
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
:deep(code) {
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
}
:deep(pre) {
background: #1F2937;
color: #F9FAFB;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
code {
background: none;
color: inherit;
padding: 0;
}
}
}
.message-time {
font-size: 11px;
color: #9CA3AF;
margin-top: 4px;
padding-left: 4px;
}
}
}
// 用户消息靠右
.user-message {
flex-direction: row-reverse;
.message-content {
display: flex;
flex-direction: column;
align-items: flex-end;
.message-text {
background: #E7000B;
color: white;
max-width: 240px;
}
}
}
// AI消息靠左
.ai-message {
.message-content {
.message-text {
background: #F3F4F6;
color: #101828;
max-width: 280px;
}
}
}
}
// 加载动画
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 0;
width: fit-content;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #9CA3AF;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
.input-section {
border-top: 1px solid #E5E7EB;
padding: 16px 20px;
background: white;
flex-shrink: 0;
.input-area {
display: flex;
gap: 12px;
align-items: flex-end;
.input-text {
flex: 1;
padding: 12px 16px;
border: 1px solid #D1D5DB;
border-radius: 20px;
font-size: 14px;
resize: none;
max-height: 100px;
font-family: inherit;
background: #F9FAFB;
&:focus {
outline: none;
border-color: #E7000B;
background: white;
}
&::placeholder {
color: #9CA3AF;
}
}
.input-actions {
.send-btn {
width: 44px;
height: 44px;
background: #E7000B;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #C90009;
transform: scale(1.05);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
}
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-6px);
}
}
</style>