对比新文件 |
| | |
| | | <template> |
| | | <el-container class="ai-layout"> |
| | | <!-- 左侧:对话列表 --> |
| | | <ConversationList |
| | | :active-id="activeConversationId" |
| | | ref="conversationListRef" |
| | | @on-conversation-create="handleConversationCreateSuccess" |
| | | @on-conversation-click="handleConversationClick" |
| | | @on-conversation-clear="handleConversationClear" |
| | | @on-conversation-delete="handlerConversationDelete" |
| | | /> |
| | | <!-- 右侧:对话详情 --> |
| | | <el-container class="detail-container"> |
| | | <el-header class="header"> |
| | | <div class="title"> |
| | | {{ activeConversation?.title ? activeConversation?.title : '对话' }} |
| | | <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span> |
| | | </div> |
| | | <div class="btns" v-if="activeConversation"> |
| | | <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm"> |
| | | <span v-html="activeConversation?.modelName"></span> |
| | | <Icon icon="ep:setting" class="ml-10px" /> |
| | | </el-button> |
| | | <el-button size="small" class="btn" @click="handlerMessageClear"> |
| | | <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" /> |
| | | </el-button> |
| | | <el-button size="small" class="btn"> |
| | | <Icon icon="ep:download" color="#787878" /> |
| | | </el-button> |
| | | <el-button size="small" class="btn" @click="handleGoTopMessage"> |
| | | <Icon icon="ep:top" color="#787878" /> |
| | | </el-button> |
| | | </div> |
| | | </el-header> |
| | | |
| | | <!-- main:消息列表 --> |
| | | <el-main class="main-container"> |
| | | <div> |
| | | <div class="message-container"> |
| | | <!-- 情况一:消息加载中 --> |
| | | <MessageLoading v-if="activeMessageListLoading" /> |
| | | <!-- 情况二:无聊天对话时 --> |
| | | <MessageNewConversation |
| | | v-if="!activeConversation" |
| | | @on-new-conversation="handleConversationCreate" |
| | | /> |
| | | <!-- 情况三:消息列表为空 --> |
| | | <MessageListEmpty |
| | | v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation" |
| | | @on-prompt="doSendMessage" |
| | | /> |
| | | <!-- 情况四:消息列表不为空 --> |
| | | <MessageList |
| | | v-if="!activeMessageListLoading && messageList.length > 0" |
| | | ref="messageRef" |
| | | :conversation="activeConversation" |
| | | :list="messageList" |
| | | @on-delete-success="handleMessageDelete" |
| | | @on-edit="handleMessageEdit" |
| | | @on-refresh="handleMessageRefresh" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </el-main> |
| | | |
| | | <!-- 底部 --> |
| | | <el-footer class="footer-container"> |
| | | <form class="prompt-from"> |
| | | <textarea |
| | | class="prompt-input" |
| | | v-model="prompt" |
| | | @keydown="handleSendByKeydown" |
| | | @input="handlePromptInput" |
| | | @compositionstart="onCompositionstart" |
| | | @compositionend="onCompositionend" |
| | | placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" |
| | | ></textarea> |
| | | <div class="prompt-btns"> |
| | | <div> |
| | | <el-switch v-model="enableContext" /> |
| | | <span class="ml-5px text-14px text-#8f8f8f">上下文</span> |
| | | </div> |
| | | <el-button |
| | | type="primary" |
| | | size="default" |
| | | @click="handleSendByButton" |
| | | :loading="conversationInProgress" |
| | | v-if="conversationInProgress == false" |
| | | > |
| | | {{ conversationInProgress ? '进行中' : '发送' }} |
| | | </el-button> |
| | | <el-button |
| | | type="danger" |
| | | size="default" |
| | | @click="stopStream()" |
| | | v-if="conversationInProgress == true" |
| | | > |
| | | 停止 |
| | | </el-button> |
| | | </div> |
| | | </form> |
| | | </el-footer> |
| | | </el-container> |
| | | |
| | | <!-- 更新对话 Form --> |
| | | <ConversationUpdateForm |
| | | ref="conversationUpdateFormRef" |
| | | @success="handleConversationUpdateSuccess" |
| | | /> |
| | | </el-container> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' |
| | | import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' |
| | | import ConversationList from './components/conversation/ConversationList.vue' |
| | | import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue' |
| | | import MessageList from './components/message/MessageList.vue' |
| | | import MessageListEmpty from './components/message/MessageListEmpty.vue' |
| | | import MessageLoading from './components/message/MessageLoading.vue' |
| | | import MessageNewConversation from './components/message/MessageNewConversation.vue' |
| | | |
| | | /** AI 聊天对话 列表 */ |
| | | defineOptions({ name: 'AiChat' }) |
| | | |
| | | const route = useRoute() // 路由 |
| | | const message = useMessage() // 消息弹窗 |
| | | |
| | | // 聊天对话 |
| | | const conversationListRef = ref() |
| | | const activeConversationId = ref<number | null>(null) // 选中的对话编号 |
| | | const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation |
| | | const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作 |
| | | |
| | | // 消息列表 |
| | | const messageRef = ref() |
| | | const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 |
| | | const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中 |
| | | const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中 |
| | | // 消息滚动 |
| | | const textSpeed = ref<number>(50) // Typing speed in milliseconds |
| | | const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds |
| | | |
| | | // 发送消息输入框 |
| | | const isComposing = ref(false) // 判断用户是否在输入 |
| | | const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) |
| | | const inputTimeout = ref<any>() // 处理输入中回车的定时器 |
| | | const prompt = ref<string>() // prompt |
| | | const enableContext = ref<boolean>(true) // 是否开启上下文 |
| | | // 接收 Stream 消息 |
| | | const receiveMessageFullText = ref('') |
| | | const receiveMessageDisplayedText = ref('') |
| | | |
| | | |
| | | // =========== 【聊天对话】相关 =========== |
| | | |
| | | /** 获取对话信息 */ |
| | | const getConversation = async (id: number | null) => { |
| | | if (!id) { |
| | | return |
| | | } |
| | | const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) |
| | | if (!conversation) { |
| | | return |
| | | } |
| | | activeConversation.value = conversation |
| | | activeConversationId.value = conversation.id |
| | | } |
| | | |
| | | /** |
| | | * 点击某个对话 |
| | | * |
| | | * @param conversation 选中的对话 |
| | | * @return 是否切换成功 |
| | | */ |
| | | const handleConversationClick = async (conversation: ChatConversationVO) => { |
| | | // 对话进行中,不允许切换 |
| | | if (conversationInProgress.value) { |
| | | message.alert('对话中,不允许切换!') |
| | | return false |
| | | } |
| | | |
| | | // 更新选中的对话 id |
| | | activeConversationId.value = conversation.id |
| | | activeConversation.value = conversation |
| | | // 刷新 message 列表 |
| | | await getMessageList() |
| | | // 滚动底部 |
| | | scrollToBottom(true) |
| | | // 清空输入框 |
| | | prompt.value = '' |
| | | return true |
| | | } |
| | | |
| | | /** 删除某个对话*/ |
| | | const handlerConversationDelete = async (delConversation: ChatConversationVO) => { |
| | | // 删除的对话如果是当前选中的,那么就重置 |
| | | if (activeConversationId.value === delConversation.id) { |
| | | await handleConversationClear() |
| | | } |
| | | } |
| | | /** 清空选中的对话 */ |
| | | const handleConversationClear = async () => { |
| | | // 对话进行中,不允许切换 |
| | | if (conversationInProgress.value) { |
| | | message.alert('对话中,不允许切换!') |
| | | return false |
| | | } |
| | | activeConversationId.value = null |
| | | activeConversation.value = null |
| | | activeMessageList.value = [] |
| | | } |
| | | |
| | | /** 修改聊天对话 */ |
| | | const conversationUpdateFormRef = ref() |
| | | const openChatConversationUpdateForm = async () => { |
| | | conversationUpdateFormRef.value.open(activeConversationId.value) |
| | | } |
| | | const handleConversationUpdateSuccess = async () => { |
| | | // 对话更新成功,刷新最新信息 |
| | | await getConversation(activeConversationId.value) |
| | | } |
| | | |
| | | /** 处理聊天对话的创建成功 */ |
| | | const handleConversationCreate = async () => { |
| | | // 创建对话 |
| | | await conversationListRef.value.createConversation() |
| | | } |
| | | /** 处理聊天对话的创建成功 */ |
| | | const handleConversationCreateSuccess = async () => { |
| | | // 创建新的对话,清空输入框 |
| | | prompt.value = '' |
| | | } |
| | | |
| | | // =========== 【消息列表】相关 =========== |
| | | |
| | | /** 获取消息 message 列表 */ |
| | | const getMessageList = async () => { |
| | | try { |
| | | if (activeConversationId.value === null) { |
| | | return |
| | | } |
| | | // Timer 定时器,如果加载速度很快,就不进入加载中 |
| | | activeMessageListLoadingTimer.value = setTimeout(() => { |
| | | activeMessageListLoading.value = true |
| | | }, 60) |
| | | |
| | | // 获取消息列表 |
| | | activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId( |
| | | activeConversationId.value |
| | | ) |
| | | |
| | | // 滚动到最下面 |
| | | await nextTick() |
| | | await scrollToBottom() |
| | | } finally { |
| | | // time 定时器,如果加载速度很快,就不进入加载中 |
| | | if (activeMessageListLoadingTimer.value) { |
| | | clearTimeout(activeMessageListLoadingTimer.value) |
| | | } |
| | | // 加载结束 |
| | | activeMessageListLoading.value = false |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 消息列表 |
| | | * |
| | | * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 |
| | | */ |
| | | const messageList = computed(() => { |
| | | if (activeMessageList.value.length > 0) { |
| | | return activeMessageList.value |
| | | } |
| | | // 没有消息时,如果有 systemMessage 则展示它 |
| | | if (activeConversation.value?.systemMessage) { |
| | | return [ |
| | | { |
| | | id: 0, |
| | | type: 'system', |
| | | content: activeConversation.value.systemMessage |
| | | } |
| | | ] |
| | | } |
| | | return [] |
| | | }) |
| | | |
| | | /** 处理删除 message 消息 */ |
| | | const handleMessageDelete = () => { |
| | | if (conversationInProgress.value) { |
| | | message.alert('回答中,不能删除!') |
| | | return |
| | | } |
| | | // 刷新 message 列表 |
| | | getMessageList() |
| | | } |
| | | |
| | | /** 处理 message 清空 */ |
| | | const handlerMessageClear = async () => { |
| | | if (!activeConversationId.value) { |
| | | return |
| | | } |
| | | try { |
| | | // 确认提示 |
| | | await message.delConfirm('确认清空对话消息?') |
| | | // 清空对话 |
| | | await ChatMessageApi.deleteByConversationId(activeConversationId.value) |
| | | // 刷新 message 列表 |
| | | activeMessageList.value = [] |
| | | } catch {} |
| | | } |
| | | |
| | | /** 回到 message 列表的顶部 */ |
| | | const handleGoTopMessage = () => { |
| | | messageRef.value.handlerGoTop() |
| | | } |
| | | |
| | | // =========== 【发送消息】相关 =========== |
| | | |
| | | /** 处理来自 keydown 的发送消息 */ |
| | | const handleSendByKeydown = async (event) => { |
| | | // 判断用户是否在输入 |
| | | if (isComposing.value) { |
| | | return |
| | | } |
| | | // 进行中不允许发送 |
| | | if (conversationInProgress.value) { |
| | | return |
| | | } |
| | | const content = prompt.value?.trim() as string |
| | | if (event.key === 'Enter') { |
| | | if (event.shiftKey) { |
| | | // 插入换行 |
| | | prompt.value += '\r\n' |
| | | event.preventDefault() // 防止默认的换行行为 |
| | | } else { |
| | | // 发送消息 |
| | | await doSendMessage(content) |
| | | event.preventDefault() // 防止默认的提交行为 |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 处理来自【发送】按钮的发送消息 */ |
| | | const handleSendByButton = () => { |
| | | doSendMessage(prompt.value?.trim() as string) |
| | | } |
| | | |
| | | /** 处理 prompt 输入变化 */ |
| | | const handlePromptInput = (event) => { |
| | | // 非输入法 输入设置为 true |
| | | if (!isComposing.value) { |
| | | // 回车 event data 是 null |
| | | if (event.data == null) { |
| | | return |
| | | } |
| | | isComposing.value = true |
| | | } |
| | | // 清理定时器 |
| | | if (inputTimeout.value) { |
| | | clearTimeout(inputTimeout.value) |
| | | } |
| | | // 重置定时器 |
| | | inputTimeout.value = setTimeout(() => { |
| | | isComposing.value = false |
| | | }, 400) |
| | | } |
| | | // TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 |
| | | const onCompositionstart = () => { |
| | | isComposing.value = true |
| | | } |
| | | const onCompositionend = () => { |
| | | // console.log('输入结束...') |
| | | setTimeout(() => { |
| | | isComposing.value = false |
| | | }, 200) |
| | | } |
| | | |
| | | /** 真正执行【发送】消息操作 */ |
| | | const doSendMessage = async (content: string) => { |
| | | // 校验 |
| | | if (content.length < 1) { |
| | | message.error('发送失败,原因:内容为空!') |
| | | return |
| | | } |
| | | if (activeConversationId.value == null) { |
| | | message.error('还没创建对话,不能发送!') |
| | | return |
| | | } |
| | | // 清空输入框 |
| | | prompt.value = '' |
| | | // 执行发送 |
| | | await doSendMessageStream({ |
| | | conversationId: activeConversationId.value, |
| | | content: content |
| | | } as ChatMessageVO) |
| | | } |
| | | |
| | | /** 真正执行【发送】消息操作 */ |
| | | const doSendMessageStream = async (userMessage: ChatMessageVO) => { |
| | | // 创建 AbortController 实例,以便中止请求 |
| | | conversationInAbortController.value = new AbortController() |
| | | // 标记对话进行中 |
| | | conversationInProgress.value = true |
| | | // 设置为空 |
| | | receiveMessageFullText.value = '' |
| | | |
| | | try { |
| | | // 1.1 先添加两个假数据,等 stream 返回再替换 |
| | | activeMessageList.value.push({ |
| | | id: -1, |
| | | conversationId: activeConversationId.value, |
| | | type: 'user', |
| | | content: userMessage.content, |
| | | createTime: new Date() |
| | | } as ChatMessageVO) |
| | | activeMessageList.value.push({ |
| | | id: -2, |
| | | conversationId: activeConversationId.value, |
| | | type: 'assistant', |
| | | content: '思考中...', |
| | | createTime: new Date() |
| | | } as ChatMessageVO) |
| | | // 1.2 滚动到最下面 |
| | | await nextTick() |
| | | await scrollToBottom() // 底部 |
| | | // 1.3 开始滚动 |
| | | textRoll() |
| | | |
| | | // 2. 发送 event stream |
| | | let isFirstChunk = true // 是否是第一个 chunk 消息段 |
| | | await ChatMessageApi.sendChatMessageStream( |
| | | userMessage.conversationId, |
| | | userMessage.content, |
| | | conversationInAbortController.value, |
| | | enableContext.value, |
| | | async (res) => { |
| | | const { code, data, msg } = JSON.parse(res.data) |
| | | if (code !== 0) { |
| | | message.alert(`对话异常! ${msg}`) |
| | | return |
| | | } |
| | | |
| | | // 如果内容为空,就不处理。 |
| | | if (data.receive.content === '') { |
| | | return |
| | | } |
| | | // 首次返回需要添加一个 message 到页面,后面的都是更新 |
| | | if (isFirstChunk) { |
| | | isFirstChunk = false |
| | | // 弹出两个假数据 |
| | | activeMessageList.value.pop() |
| | | activeMessageList.value.pop() |
| | | // 更新返回的数据 |
| | | activeMessageList.value.push(data.send) |
| | | activeMessageList.value.push(data.receive) |
| | | } |
| | | // debugger |
| | | receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content |
| | | // 滚动到最下面 |
| | | await scrollToBottom() |
| | | }, |
| | | (error) => { |
| | | message.alert(`对话异常! ${error}`) |
| | | stopStream() |
| | | }, |
| | | () => { |
| | | stopStream() |
| | | } |
| | | ) |
| | | } catch {} |
| | | } |
| | | |
| | | /** 停止 stream 流式调用 */ |
| | | const stopStream = async () => { |
| | | // tip:如果 stream 进行中的 message,就需要调用 controller 结束 |
| | | if (conversationInAbortController.value) { |
| | | conversationInAbortController.value.abort() |
| | | } |
| | | // 设置为 false |
| | | conversationInProgress.value = false |
| | | } |
| | | |
| | | /** 编辑 message:设置为 prompt,可以再次编辑 */ |
| | | const handleMessageEdit = (message: ChatMessageVO) => { |
| | | prompt.value = message.content |
| | | } |
| | | |
| | | /** 刷新 message:基于指定消息,再次发起对话 */ |
| | | const handleMessageRefresh = (message: ChatMessageVO) => { |
| | | doSendMessage(message.content) |
| | | } |
| | | |
| | | // ============== 【消息滚动】相关 ============= |
| | | |
| | | /** 滚动到 message 底部 */ |
| | | const scrollToBottom = async (isIgnore?: boolean) => { |
| | | await nextTick() |
| | | if (messageRef.value) { |
| | | messageRef.value.scrollToBottom(isIgnore) |
| | | } |
| | | } |
| | | |
| | | /** 自提滚动效果 */ |
| | | const textRoll = async () => { |
| | | let index = 0 |
| | | try { |
| | | // 只能执行一次 |
| | | if (textRoleRunning.value) { |
| | | return |
| | | } |
| | | // 设置状态 |
| | | textRoleRunning.value = true |
| | | receiveMessageDisplayedText.value = '' |
| | | const task = async () => { |
| | | // 调整速度 |
| | | const diff = |
| | | (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 |
| | | if (diff > 5) { |
| | | textSpeed.value = 10 |
| | | } else if (diff > 2) { |
| | | textSpeed.value = 30 |
| | | } else if (diff > 1.5) { |
| | | textSpeed.value = 50 |
| | | } else { |
| | | textSpeed.value = 100 |
| | | } |
| | | // 对话结束,就按 30 的速度 |
| | | if (!conversationInProgress.value) { |
| | | textSpeed.value = 10 |
| | | } |
| | | |
| | | if (index < receiveMessageFullText.value.length) { |
| | | receiveMessageDisplayedText.value += receiveMessageFullText.value[index] |
| | | index++ |
| | | |
| | | // 更新 message |
| | | const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] |
| | | lastMessage.content = receiveMessageDisplayedText.value |
| | | // 滚动到住下面 |
| | | await scrollToBottom() |
| | | // 重新设置任务 |
| | | timer = setTimeout(task, textSpeed.value) |
| | | } else { |
| | | // 不是对话中可以结束 |
| | | if (!conversationInProgress.value) { |
| | | textRoleRunning.value = false |
| | | clearTimeout(timer) |
| | | } else { |
| | | // 重新设置任务 |
| | | timer = setTimeout(task, textSpeed.value) |
| | | } |
| | | } |
| | | } |
| | | let timer = setTimeout(task, textSpeed.value) |
| | | } catch {} |
| | | } |
| | | |
| | | /** 初始化 **/ |
| | | onMounted(async () => { |
| | | // 如果有 conversationId 参数,则默认选中 |
| | | if (route.query.conversationId) { |
| | | const id = route.query.conversationId as unknown as number |
| | | activeConversationId.value = id |
| | | await getConversation(id) |
| | | } |
| | | |
| | | // 获取列表数据 |
| | | activeMessageListLoading.value = true |
| | | await getMessageList() |
| | | }) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .ai-layout { |
| | | position: absolute; |
| | | flex: 1; |
| | | top: 0; |
| | | left: 0; |
| | | height: 100%; |
| | | width: 100%; |
| | | } |
| | | |
| | | .conversation-container { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: space-between; |
| | | padding: 10px 10px 0; |
| | | |
| | | .btn-new-conversation { |
| | | padding: 18px 0; |
| | | } |
| | | |
| | | .search-input { |
| | | margin-top: 20px; |
| | | } |
| | | |
| | | .conversation-list { |
| | | margin-top: 20px; |
| | | |
| | | .conversation { |
| | | display: flex; |
| | | flex-direction: row; |
| | | justify-content: space-between; |
| | | flex: 1; |
| | | padding: 0 5px; |
| | | margin-top: 10px; |
| | | cursor: pointer; |
| | | border-radius: 5px; |
| | | align-items: center; |
| | | line-height: 30px; |
| | | |
| | | &.active { |
| | | background-color: #e6e6e6; |
| | | |
| | | .button { |
| | | display: inline-block; |
| | | } |
| | | } |
| | | |
| | | .title-wrapper { |
| | | display: flex; |
| | | flex-direction: row; |
| | | align-items: center; |
| | | } |
| | | |
| | | .title { |
| | | padding: 5px 10px; |
| | | max-width: 220px; |
| | | font-size: 14px; |
| | | overflow: hidden; |
| | | white-space: nowrap; |
| | | text-overflow: ellipsis; |
| | | } |
| | | |
| | | .avatar { |
| | | width: 28px; |
| | | height: 28px; |
| | | display: flex; |
| | | flex-direction: row; |
| | | justify-items: center; |
| | | } |
| | | |
| | | // 对话编辑、删除 |
| | | .button-wrapper { |
| | | right: 2px; |
| | | display: flex; |
| | | flex-direction: row; |
| | | justify-items: center; |
| | | color: #606266; |
| | | |
| | | .el-icon { |
| | | margin-right: 5px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 角色仓库、清空未设置对话 |
| | | .tool-box { |
| | | line-height: 35px; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | color: var(--el-text-color); |
| | | |
| | | > div { |
| | | display: flex; |
| | | align-items: center; |
| | | color: #606266; |
| | | padding: 0; |
| | | margin: 0; |
| | | cursor: pointer; |
| | | |
| | | > span { |
| | | margin-left: 5px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 头部 |
| | | .detail-container { |
| | | background: #ffffff; |
| | | |
| | | .header { |
| | | display: flex; |
| | | flex-direction: row; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | background: #fbfbfb; |
| | | box-shadow: 0 0 0 0 #dcdfe6; |
| | | |
| | | .title { |
| | | font-size: 18px; |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .btns { |
| | | display: flex; |
| | | width: 300px; |
| | | flex-direction: row; |
| | | justify-content: flex-end; |
| | | //justify-content: space-between; |
| | | |
| | | .btn { |
| | | padding: 10px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // main 容器 |
| | | .main-container { |
| | | margin: 0; |
| | | padding: 0; |
| | | position: relative; |
| | | height: 100%; |
| | | width: 100%; |
| | | |
| | | .message-container { |
| | | position: absolute; |
| | | top: 0; |
| | | bottom: 0; |
| | | left: 0; |
| | | right: 0; |
| | | overflow-y: hidden; |
| | | padding: 0; |
| | | margin: 0; |
| | | } |
| | | } |
| | | |
| | | // 底部 |
| | | .footer-container { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: auto; |
| | | margin: 0; |
| | | padding: 0; |
| | | |
| | | .prompt-from { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: auto; |
| | | border: 1px solid #e3e3e3; |
| | | border-radius: 10px; |
| | | margin: 10px 20px 20px 20px; |
| | | padding: 9px 10px; |
| | | } |
| | | |
| | | .prompt-input { |
| | | height: 80px; |
| | | //box-shadow: none; |
| | | border: none; |
| | | box-sizing: border-box; |
| | | resize: none; |
| | | padding: 0 2px; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .prompt-input:focus { |
| | | outline: none; |
| | | } |
| | | |
| | | .prompt-btns { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | padding-bottom: 0; |
| | | padding-top: 5px; |
| | | } |
| | | } |
| | | </style> |