489 lines
11 KiB
Vue
489 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
|
||
import {
|
||
Button,
|
||
Col,
|
||
Input,
|
||
InputSearch,
|
||
List,
|
||
ListItem,
|
||
Row,
|
||
Select,
|
||
} from 'ant-design-vue';
|
||
|
||
import { fetchAIStream, getConversations, getMessages } from '#/api/ai/chat';
|
||
|
||
interface Message {
|
||
id: null | number;
|
||
type: 'assistant' | 'user';
|
||
content: string;
|
||
}
|
||
|
||
interface ChatItem {
|
||
id: number;
|
||
title: string;
|
||
lastMessage: string;
|
||
}
|
||
|
||
// 历史对话
|
||
const chatList = ref<ChatItem[]>([]);
|
||
|
||
// 聊天消息
|
||
const messages = ref<Message[]>([]);
|
||
const currentMessages = ref<Message[]>([]);
|
||
|
||
// 模型列表
|
||
const modelOptions = [
|
||
{ label: 'deepseek', value: 'deepseek' },
|
||
{ label: 'GPT-4', value: 'gpt-4' },
|
||
];
|
||
|
||
const selectedChatId = ref<null | number>(chatList.value[0]?.id ?? null);
|
||
const selectedModel = ref(modelOptions[0]?.value);
|
||
const search = ref('');
|
||
const input = ref('');
|
||
const messagesRef = ref<HTMLElement | null>(null);
|
||
const currentAiMessage = ref<Message | null>(null);
|
||
const isAiTyping = ref(false);
|
||
|
||
const filteredChats = computed(() => {
|
||
if (!search.value) return chatList.value;
|
||
return chatList.value.filter((chat) => chat.title.includes(search.value));
|
||
});
|
||
|
||
async function selectChat(id: number) {
|
||
selectedChatId.value = id;
|
||
const { data } = await getMessages(id);
|
||
currentMessages.value = data;
|
||
nextTick(scrollToBottom);
|
||
}
|
||
|
||
function handleNewChat() {
|
||
const newId = Date.now();
|
||
chatList.value.unshift({
|
||
id: newId,
|
||
title: `新对话${chatList.value.length + 1}`,
|
||
lastMessage: '',
|
||
});
|
||
selectedChatId.value = newId;
|
||
nextTick(scrollToBottom);
|
||
}
|
||
|
||
async function handleSend() {
|
||
const msg: Message = {
|
||
id: null,
|
||
type: 'user',
|
||
content: input.value,
|
||
};
|
||
messages.value.push(msg);
|
||
|
||
// 预留AI消息
|
||
const aiMsgObj: Message = {
|
||
id: null,
|
||
type: 'assistant',
|
||
content: '',
|
||
};
|
||
messages.value.push(aiMsgObj);
|
||
currentAiMessage.value = aiMsgObj;
|
||
isAiTyping.value = true;
|
||
|
||
const stream = await fetchAIStream({
|
||
content: input.value,
|
||
conversation_id: selectedChatId.value, // 新增
|
||
});
|
||
|
||
for await (const chunk of stream) {
|
||
for (const char of chunk) {
|
||
aiMsgObj.content += char;
|
||
// 保证messages数组响应式更新
|
||
const idx = messages.value.indexOf(aiMsgObj);
|
||
if (idx !== -1) {
|
||
messages.value.splice(idx, 1, { ...aiMsgObj });
|
||
}
|
||
currentAiMessage.value = { ...aiMsgObj };
|
||
await nextTick();
|
||
scrollToBottom();
|
||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||
}
|
||
}
|
||
isAiTyping.value = false;
|
||
input.value = '';
|
||
nextTick(scrollToBottom);
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
if (messagesRef.value && messagesRef.value.scrollHeight !== undefined) {
|
||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// 获取历史对话
|
||
async function fetchConversations() {
|
||
const { data } = await getConversations();
|
||
chatList.value = data.map((item: any) => ({
|
||
id: item.id,
|
||
title: item.title,
|
||
lastMessage: item.last_message || '',
|
||
}));
|
||
// 默认选中第一个对话
|
||
if (chatList.value.length > 0) {
|
||
selectedChatId.value = chatList.value[0].id;
|
||
await selectChat(selectedChatId.value);
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchConversations();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Page auto-content-height>
|
||
<Row style="height: 100%">
|
||
<!-- 左侧历史对话 -->
|
||
<Col :span="6" class="chat-sider">
|
||
<div class="sider-header">
|
||
<Button type="primary" @click="handleNewChat">新建对话</Button>
|
||
<Input
|
||
v-model:value="search"
|
||
placeholder="搜索历史对话"
|
||
allow-clear
|
||
style="margin: 12px 0 8px 0"
|
||
/>
|
||
</div>
|
||
<div class="chat-list">
|
||
<List style="flex: 1; overflow-y: auto; padding-bottom: 12px">
|
||
<template #default>
|
||
<ListItem
|
||
v-for="item in filteredChats"
|
||
:key="item.id"
|
||
class="chat-list-item"
|
||
:class="[{ selected: item.id === selectedChatId }]"
|
||
@click="selectChat(item.id)"
|
||
>
|
||
<div class="chat-item-avatar">
|
||
<!-- 可用头像或首字母 -->
|
||
<span class="avatar-text">{{ item.title.slice(0, 1) }}</span>
|
||
</div>
|
||
<div class="chat-item-content">
|
||
<div class="chat-item-title-row">
|
||
<span class="chat-title" :title="item.title">{{
|
||
item.title
|
||
}}</span>
|
||
<!-- 未读角标(如有未读可加) -->
|
||
<!-- <span class="unread-dot"></span> -->
|
||
</div>
|
||
<div class="chat-desc">{{ item.lastMessage }}</div>
|
||
</div>
|
||
</ListItem>
|
||
</template>
|
||
</List>
|
||
</div>
|
||
</Col>
|
||
<!-- 右侧聊天区 -->
|
||
<Col :span="18" class="chat-content">
|
||
<div class="content-header">
|
||
<div class="model-select-wrap">
|
||
<Select
|
||
v-model:value="selectedModel"
|
||
style="width: 220px"
|
||
:options="modelOptions"
|
||
placeholder="选择AI模型"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="chat-messages" ref="messagesRef">
|
||
<div
|
||
v-for="msg in currentMessages"
|
||
:key="msg.id"
|
||
class="chat-message"
|
||
:class="[msg.type]"
|
||
>
|
||
<div class="bubble" :class="[msg.type]">
|
||
<span class="role">{{ msg.type === 'user' ? '我' : 'AI' }}</span>
|
||
<span class="bubble-content">
|
||
{{ msg.content }}
|
||
<span
|
||
v-if="
|
||
msg.type === 'ai' && isAiTyping && msg === currentAiMessage
|
||
"
|
||
class="typing-cursor"
|
||
></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="chat-input-wrap">
|
||
<InputSearch
|
||
v-model:value="input"
|
||
enter-button="发送"
|
||
@search="handleSend"
|
||
placeholder="请输入内容..."
|
||
/>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped lang="css">
|
||
.chat-sider {
|
||
background: #fafbfc;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-right: 1px solid #eee;
|
||
padding: 16px 8px 8px 8px;
|
||
}
|
||
.sider-header {
|
||
margin-bottom: 8px;
|
||
}
|
||
.chat-list-item {
|
||
border-radius: 6px;
|
||
margin-bottom: 4px;
|
||
transition:
|
||
box-shadow 0.2s,
|
||
background 0.2s;
|
||
cursor: pointer;
|
||
}
|
||
.chat-list-item.selected {
|
||
background: #e6f7ff;
|
||
box-shadow: 0 2px 8px #1677ff22;
|
||
border: 1.5px solid #1677ff;
|
||
}
|
||
.chat-list-item:hover {
|
||
background: #f0f5ff;
|
||
box-shadow: 0 2px 8px #1677ff11;
|
||
}
|
||
.chat-title {
|
||
font-weight: 500;
|
||
font-size: 15px;
|
||
max-width: 140px;
|
||
display: inline-block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chat-desc {
|
||
color: #888;
|
||
font-size: 12px;
|
||
max-width: 140px;
|
||
display: inline-block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chat-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px 24px 8px 24px;
|
||
background: #f6f8fa;
|
||
}
|
||
.content-header {
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
.model-select-wrap {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 24px 16px 80px 16px;
|
||
margin-bottom: 0;
|
||
min-height: 300px;
|
||
box-shadow: 0 2px 8px #0001;
|
||
transition: box-shadow 0.2s;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #d6dee1 #f6f8fa;
|
||
}
|
||
.chat-messages::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
.chat-messages::-webkit-scrollbar-thumb {
|
||
background: #d6dee1;
|
||
border-radius: 4px;
|
||
}
|
||
.chat-messages::-webkit-scrollbar-track {
|
||
background: #f6f8fa;
|
||
}
|
||
.chat-message {
|
||
display: flex;
|
||
margin-bottom: 16px;
|
||
}
|
||
.chat-message.user {
|
||
justify-content: flex-end;
|
||
}
|
||
.chat-message.ai {
|
||
justify-content: flex-start;
|
||
}
|
||
.bubble {
|
||
max-width: 70%;
|
||
padding: 10px 16px;
|
||
border-radius: 18px;
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
box-shadow: 0 1px 4px #0001;
|
||
display: flex;
|
||
align-items: center;
|
||
word-break: break-all;
|
||
}
|
||
.bubble.user {
|
||
background: linear-gradient(90deg, #1677ff 0%, #69b1ff 100%);
|
||
color: #fff;
|
||
border-bottom-right-radius: 4px;
|
||
}
|
||
.bubble.ai {
|
||
background: #f0f5ff;
|
||
color: #333;
|
||
border-bottom-left-radius: 4px;
|
||
}
|
||
.role {
|
||
font-weight: bold;
|
||
margin-right: 8px;
|
||
font-size: 13px;
|
||
opacity: 0.7;
|
||
}
|
||
.bubble-content {
|
||
flex: 1;
|
||
}
|
||
.chat-input-wrap {
|
||
position: absolute;
|
||
left: 24%;
|
||
right: 24px;
|
||
bottom: 24px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px #0001;
|
||
padding: 12px 16px 8px 16px;
|
||
z-index: 10;
|
||
}
|
||
@media (max-width: 1200px) {
|
||
.chat-input-wrap {
|
||
left: 6%;
|
||
right: 6px;
|
||
}
|
||
.chat-content {
|
||
padding: 8px 4px 8px 4px;
|
||
}
|
||
}
|
||
.typing-cursor {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 1.2em;
|
||
background: #1677ff;
|
||
margin-left: 2px;
|
||
animation: blink-cursor 1s steps(1) infinite;
|
||
vertical-align: bottom;
|
||
border-radius: 2px;
|
||
}
|
||
@keyframes blink-cursor {
|
||
0%,
|
||
50% {
|
||
opacity: 1;
|
||
}
|
||
51%,
|
||
100% {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
.chat-list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
border-radius: 8px;
|
||
margin-bottom: 6px;
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.2s,
|
||
box-shadow 0.2s;
|
||
}
|
||
.chat-list-item.selected {
|
||
background: #e6f7ff;
|
||
box-shadow: 0 2px 8px #1677ff22;
|
||
border: 1.5px solid #1677ff;
|
||
}
|
||
.chat-list-item:hover {
|
||
background: #f0f5ff;
|
||
box-shadow: 0 2px 8px #1677ff11;
|
||
}
|
||
.chat-item-avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
background: #1677ff22;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-right: 12px;
|
||
font-size: 18px;
|
||
color: #1677ff;
|
||
font-weight: bold;
|
||
}
|
||
.avatar-text {
|
||
user-select: none;
|
||
}
|
||
.chat-item-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.chat-item-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.chat-title {
|
||
font-weight: 500;
|
||
font-size: 15px;
|
||
max-width: 140px;
|
||
display: inline-block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.unread-dot {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
background: #ff4d4f;
|
||
border-radius: 50%;
|
||
margin-left: 8px;
|
||
}
|
||
.chat-desc {
|
||
color: #888;
|
||
font-size: 12px;
|
||
max-width: 140px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-top: 2px;
|
||
}
|
||
.chat-sider {
|
||
background: #fafbfc;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-right: 1px solid #eee;
|
||
padding: 16px 8px 8px 8px;
|
||
height: 100%; /* 关键:让侧边栏高度100% */
|
||
min-width: 220px;
|
||
}
|
||
|
||
.sider-header {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.chat-list {
|
||
flex: 1;
|
||
overflow-y: auto; /* 只在对话列表区滚动 */
|
||
min-height: 0; /* 关键:flex子项内滚动时必须加 */
|
||
max-height: calc(100vh - 120px); /* 可根据实际header/footer高度调整 */
|
||
}
|
||
</style>
|