feat: 初步实现 webchat 页面

This commit is contained in:
Soulter
2025-01-10 21:48:15 +08:00
parent 966df00124
commit 480dffb51b
18 changed files with 704 additions and 10 deletions
@@ -30,6 +30,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-puzzle',
to: '/extension'
},
{
title: '聊天',
icon: 'mdi-chat',
to: '/chat'
},
{
title: '控制台',
icon: 'mdi-console',
+5
View File
@@ -36,6 +36,11 @@ const MainRoutes = {
name: 'Project ATRI',
path: '/project-atri',
component: () => import('@/views/ATRIProject.vue')
},
{
name: 'Chat',
path: '/chat',
component: () => import('@/views/ChatPage.vue')
}
]
};
+285
View File
@@ -0,0 +1,285 @@
<script setup>
import axios from 'axios';
import { ref } from 'vue';
import { marked } from 'marked';
marked.setOptions({
breaks: true
});
</script>
<template>
<v-card style="margin-bottom: 16px; width: 100%; background-color: #fff; height: 100%;">
<v-card-text style="width: 100%; height: calc(100vh - 120px);">
<div style="height: 100%; display: flex; gap: 16px;">
<div style="max-width: 120px;">
<!-- conversation -->
<v-btn style="margin-bottom: 16px;" @click="newC">创建对话</v-btn>
<v-btn style="margin-bottom: 16px;" v-if="currCid" @click="deleteConversation(currCid)" color="error">删除对话</v-btn>
<v-card class="mx-auto" min-width="200">
<v-list dense nav
v-if="conversations.length > 0"
@update:selected="getConversationMessages"
>
<v-list-item
v-for="(item, i) in conversations"
:key="item.cid"
:value="item.cid"
color="primary"
rounded="xl"
>
<v-list-item-title>新对话</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(item.updated_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</div>
<div style="height: 100%; width: 100%;">
<div style="height: calc(100% - 64px); overflow-y: auto; padding: 16px; " ref="messageContainer">
<div class="fade-in" v-if="messages.length == 0"
style="height: 100%; display: flex; justify-content: center; align-items: center;">
<span style="font-size: 28px;">Hello, I'm</span>
<span style="font-weight: 1000; font-size: 28px; margin-left: 8px;">AstrBot ⭐</span>
</div>
<div v-else style="max-height: 100%; padding: 16px; max-width: 700px; margin: 0 auto;">
<div class="fade-in" v-for="(msg, index) in messages" :key="index"
style="margin-bottom: 16px;">
<div v-if="msg.type == 'user'" style="display: flex; justify-content: flex-end;">
<div
style="padding: 12px; border-radius: 8px; background-color: rgba(94, 53, 177, 0.15)">
<span>{{ msg.message }}</span>
</div>
</div>
<div v-else style="display: flex; justify-content: flex-start; gap: 16px;">
<span style="font-size: 32px;">✨</span>
<div v-html="marked(msg.message)" class="mc" style="font-family: inherit;"></div>
</div>
</div>
</div>
</div>
<div class="fade-in" style="bottom: 16px; width: 100%; padding: 16px; ">
<div style="width: 100%; justify-content: center; align-items: center; display: flex; ">
<v-text-field variant="outlined" v-model="prompt" label="聊天吧!" placeholder="Start typing..."
loading clear-icon="mdi-close-circle" clearable @click:clear="clearMessage"
@keyup.enter="sendMessage" style="width: 100%; max-width: 930px;">
<template v-slot:loader>
</template>
<template v-slot:append>
<v-icon @click="sendMessage" size="35" icon="mdi-arrow-up-circle" />
</template>
</v-text-field>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script>
export default {
name: 'ChatPage',
components: {
},
data() {
return {
prompt: '',
messages: [],
conversations: [],
currCid: ''
}
},
mounted() {
this.getConversations();
},
methods: {
clearMessage() {
this.prompt = '';
},
getConversations() {
axios.get('/api/chat/conversations').then(response => {
this.conversations = response.data.data;
}).catch(err => {
console.error(err);
});
},
getConversationMessages(cid) {
if (!cid[0])
return;
axios.get('/api/chat/get_conversation?conversation_id='+cid[0]).then(response => {
this.currCid = cid[0];
let message = JSON.parse(response.data.data.history);
for (let i = 0; i < message.length; i++) {
if (message[i].message.startsWith('[IMAGE]')) {
let img = message[i].message.replace('[IMAGE]', '');
message[i].message = `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
}
}
this.messages = message;
}).catch(err => {
console.error(err);
});
},
async newConversation() {
await axios.get('/api/chat/new_conversation').then(response => {
this.currCid = response.data.data.conversation_id;
this.getConversations();
}).catch(err => {
console.error(err);
});
},
newC() {
this.currCid = '';
this.messages = [];
},
formatDate(timestamp) {
const date = new Date(timestamp * 1000); // 假设时间戳是以秒为单位
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
return date.toLocaleString('zh-CN', options).replace(/\//g, '-').replace(/, /g, ' ');
},
deleteConversation(cid) {
axios.get('/api/chat/delete_conversation?conversation_id='+cid).then(response => {
this.getConversations();
this.currCid = '';
this.messages = [];
}).catch(err => {
console.error(err);
});
},
async sendMessage() {
if (this.currCid == '') {
await this.newConversation();
}
this.messages.push({
type: 'user',
message: this.prompt
});
// let bot_resp = {
// type: 'bot',
// message: ref('')
// }
// this.messages.push(bot_resp);
this.scrollToBottom();
fetch('/api/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({ message: this.prompt, conversation_id: this.currCid }) // 发送请求体
})
.then(response => {
this.prompt = '';
const reader = response.body.getReader(); // 获取流的 Reader
const decoder = new TextDecoder();
const readStream = async () => {
const { done, value } = await reader.read(); // 读取流中的数据
if (done) {
console.log("Stream finished.");
return;
}
const chunk = decoder.decode(value, { stream: true });
// bot_resp.message.value += chunk;
console.log("!!!!", chunk);
if (chunk.startsWith('[IMAGE]')) {
let img = chunk.replace('[IMAGE]', '');
let bot_resp = {
type: 'bot',
message: `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
}
this.messages.push(bot_resp);
} else {
let bot_resp = {
type: 'bot',
message: chunk
}
this.messages.push(bot_resp);
}
this.scrollToBottom();
readStream(); // 递归读取流
};
readStream();
})
.catch(err => {
console.error(err);
});
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messageContainer;
container.scrollTop = container.scrollHeight;
});
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
}
.mc h1,
.mc h2,
.mc h3,
.mc h4,
.mc h5,
.mc h6 {
margin-bottom: 10px;
}
.mc li {
margin-left: 16px;
}
.mc p {
margin-top: 10px;
margin-bottom: 10px;
}
</style>