ForcePilot/web/src/components/ChatComponent.vue

538 lines
12 KiB
Vue
Raw Normal View History

2024-07-14 16:42:38 +08:00
<!-- ChatComponent.vue -->
<template>
<div class="chat">
<div class="header">
<div class="header__left">
<div
v-if="!state.isSidebarOpen"
class="close nav-btn"
@click="state.isSidebarOpen = true"
>
<MenuOutlined />
</div>
<div
v-if="!state.isSidebarOpen"
class="newchat nav-btn"
@click="$emit('newconv')"
>
<FormOutlined />
</div>
</div>
<div class="header__right">
2024-07-17 18:52:20 +08:00
<a-dropdown>
<a class="ant-dropdown-link nav-btn text" @click.prevent>
<component :is="state.selectedKB === null ? BookOutlined : BookFilled" />&nbsp;
{{ state.selectedKB === null ? '未选择' : state.databases[state.selectedKB]?.name }}
</a>
<template #overlay>
<a-menu>
<a-menu-item v-for="(db, index) in state.databases" :key="index">
<a href="javascript:;" @click="state.selectedKB=index">{{ db.name }}</a>
</a-menu-item>
<a-menu-item >
<a href="javascript:;" @click="state.selectedKB = null">不使用</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="nav-btn text" @click="state.showPanel = !state.showPanel">张文杰</div>
<div v-if="state.showPanel" class="my-panal" ref="panel">暂时不知道干嘛的地方</div>
2024-07-14 16:42:38 +08:00
</div>
</div>
<div v-if="conv.messages.length == 0" class="chat-examples">
<h1>你好我是 Athena 😊</h1>
<div class="opt">
<div
class="opt__button"
v-for="(exp, key) in examples"
:key="key"
@click="autoSend(exp)"
>
{{ exp }}
</div>
</div>
</div>
<div ref="chatBox" class="chat-box">
<div
v-for="message in conv.messages"
:key="message.id"
class="message-box"
:class="message.role"
>
<p v-if="message.role=='sent'" style="white-space: pre-line" class="message-text">{{ message.text }}</p>
2024-07-17 18:52:20 +08:00
<p v-else
v-html="renderMarkdown(message.text)"
class="message-md"
@click="consoleMsg(message)"></p>
2024-07-14 16:42:38 +08:00
</div>
</div>
<div class="input-box">
2024-07-17 20:00:35 +08:00
<a-textarea
2024-07-14 16:42:38 +08:00
class="user-input"
2024-07-17 20:00:35 +08:00
v-model:value="conv.inputText"
2024-07-14 16:42:38 +08:00
@keydown.enter="sendMessage"
placeholder="输入问题……"
2024-07-17 20:00:35 +08:00
:auto-size="{ minRows: 1, maxRows: 10 }"
2024-07-14 16:42:38 +08:00
/>
2024-07-17 20:00:35 +08:00
<!-- <input
class="user-input"
v-model="conv.inputText"
@keydown.enter="sendMessage"
placeholder="输入问题……"
/> -->
2024-07-14 16:42:38 +08:00
<a-button size="large" @click="sendMessage" :disabled="(!conv.inputText && !isStreaming)">
<template #icon> <SendOutlined v-if="!isStreaming" /> <LoadingOutlined v-else/> </template>
</a-button>
</div>
<p class="note">即便强如雅典娜也可能会出错请注意辨别内容的可靠性</p>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, toRefs } from 'vue'
2024-07-17 18:52:20 +08:00
import { onClickOutside } from '@vueuse/core'
import {
SendOutlined,
MenuOutlined,
FormOutlined,
LoadingOutlined,
BookOutlined,
BookFilled,
} from '@ant-design/icons-vue'
2024-07-14 16:42:38 +08:00
import { marked } from 'marked';
const props = defineProps({
conv: Object,
state: Object
})
const emit = defineEmits(['renameTitle'])
const { conv, state } = toRefs(props)
const chatBox = ref(null)
const isStreaming = ref(false)
2024-07-17 18:52:20 +08:00
const panel = ref(null)
2024-07-14 16:42:38 +08:00
const examples = ref([
'写一个冒泡排序',
2024-07-17 18:52:20 +08:00
'肉碱是什么?',
2024-07-14 16:42:38 +08:00
'介绍一下江南大学',
'A大于BB小于CA和C哪个大',
'今天天气怎么样?'
])
marked.setOptions({
gfm: true,
breaks: true,
tables: true,
// 更多选项可以在 marked 文档中找到https://marked.js.org/
});
2024-07-17 18:52:20 +08:00
onClickOutside(panel, () => setTimeout(() => state.value.showPanel = false, 30))
2024-07-14 16:42:38 +08:00
const renameTitle = () => {
const prompt = '请用一个很短的句子关于下面的对话内容的主题起一个名字,不要带标点符号:'
const firstUserMessage = conv.value.messages[0].text
const firstAiMessage = conv.value.messages[1].text
const context = `${prompt}\n\n问题: ${firstUserMessage}\n\n回复: ${firstAiMessage},主题是(一句话):`
simpleCall(context).then((data) => {
emit('renameTitle', data.response.split("")[0])
})
}
2024-07-17 18:52:20 +08:00
const myAlert = (message) => alert(message)
const renderMarkdown = (text) => marked(text)
2024-07-14 16:42:38 +08:00
const scrollToBottom = () => {
setTimeout(() => {
chatBox.value.scrollTop = chatBox.value.scrollHeight - chatBox.value.clientHeight
}, 10)
}
2024-07-17 18:52:20 +08:00
const consoleMsg = (message) => console.log(message)
2024-07-14 16:42:38 +08:00
const generateRandomHash = (length) => {
let chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let hash = '';
for (let i = 0; i < length; i++) {
hash += chars.charAt(Math.floor(Math.random() * chars.length));
}
return hash;
}
const appendMessage = (message, role) => {
conv.value.messages.push({
id: generateRandomHash(16),
role,
text: message
})
scrollToBottom()
}
const appendUserMessage = (message) => {
conv.value.messages.push({
id: generateRandomHash(16),
role: 'sent',
text: message
})
scrollToBottom()
}
const appendAiMessage = (message, refs=null) => {
conv.value.messages.push({
id: generateRandomHash(16),
role: 'received',
text: message,
refs
})
scrollToBottom()
}
const updateMessage = (text, id) => {
const message = conv.value.messages.find((message) => message.id === id)
if (message) {
message.text = text
} else {
console.error('Message not found')
}
scrollToBottom()
}
const simpleCall = (message) => {
return new Promise((resolve, reject) => {
fetch('/api/call', {
method: 'POST',
body: JSON.stringify({
query: message,
}),
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => response.json())
.then((data) => resolve(data))
.catch((error) => reject(error))
})
}
const sendMessage = () => {
if (conv.value.inputText.trim()) {
isStreaming.value = true
appendUserMessage(conv.value.inputText)
const user_input = conv.value.inputText
var cur_res_id = null
conv.value.inputText = ''
fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
query: user_input,
history: conv.value.history,
2024-07-17 18:52:20 +08:00
meta: {
db_name: state.value.databases[state.value.selectedKB]?.metaname
}
2024-07-14 16:42:38 +08:00
}),
headers: {
'Content-Type': 'application/json'
}
}).then((response) => {const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
// 逐步读取响应文本
const readChunk = () => {
return reader.read().then(({ done, value }) => {
if (done) {
console.log(conv.value)
console.log('Finished')
isStreaming.value = false
if (conv.value.messages.length === 2) {
renameTitle()
}
return
}
buffer += decoder.decode(value, { stream: true })
const message = buffer.trim().split('\n').pop()
try {
const data = JSON.parse(message)
if (cur_res_id === null) {
appendAiMessage(data.response, data.refs)
cur_res_id = conv.value.messages[conv.value.messages.length - 1].id
} else {
updateMessage(data.response, cur_res_id)
}
conv.value.history = data.history
buffer = ''
} catch (e) {
console.log(e)
}
return readChunk()
})
}
return readChunk()
})
} else {
console.log('Please enter a message')
}
}
const autoSend = (message) => {
conv.value.inputText = message
sendMessage()
}
const clearChat = () => {
conv.value.messages = []
conv.value.history = []
}
onMounted(() => {
scrollToBottom()
})
</script>
<style lang="less" scoped>
.chat {
width: 200px;
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
background: white;
position: relative;
box-sizing: border-box;
flex: 5 5 auto;
}
.chat div.header {
height: var(--header-height);
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
2024-07-17 18:52:20 +08:00
.chat div.header {
user-select: none;
.header__left, .header__right {
display: flex;
align-items: center;
gap: 1rem;
}
}
.ant-dropdown-link {
color: var(--c-text-light-1);
cursor: pointer;
2024-07-14 16:42:38 +08:00
}
.nav-btn {
font-size: 1.2rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
color: #6D6D6D;
cursor: pointer;
&.text {
font-size: 1rem;
width: auto;
padding: 0.5rem 1rem;
}
&:hover {
background-color: #ECECEC;
}
}
2024-07-17 18:52:20 +08:00
.my-panal {
position: absolute;
top: 100%;
right: 0;
margin-top: 5px;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.05);
border-radius: 12px;
padding: 12px;
z-index: 100;
}
2024-07-14 16:42:38 +08:00
div.chat-examples {
padding: 0 50px;
text-align: center;
position: absolute;
top: 20%;
width: 100%;
z-index: 100;
}
.chat-examples h1 {
margin-bottom: 20px;
font-size: 24px;
color: #333;
}
.opt {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.opt__button {
background-color: white;
color: #222;
padding: 4px 1rem;
border-radius: 1rem;
cursor: pointer;
border: 2px solid white;
transition: background-color 0.3s;
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.05);
&:hover {
background-color: #fcfcfc;
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.1);
}
}
.chat-box {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
}
.message-box {
max-width: 90%;
display: inline-block;
border-radius: 0.8rem;
margin: 0.8rem 0;
padding: 0.8rem;
user-select: text;
word-break: break-word;
font-size: 16px;
font-variation-settings: 'wght' 400, 'opsz' 10.5;
font-weight: 400;
box-sizing: border-box;
2024-07-16 18:14:27 +08:00
color: #0D0D0D;
2024-07-14 16:42:38 +08:00
/* box-shadow: 0px 0.3px 0.9px rgba(0, 0, 0, 0.12), 0px 1.6px 3.6px rgba(0, 0, 0, 0.16); */
/* animation: slideInUp 0.1s ease-in; */
}
.message-box.sent {
background-color: #efefef;
line-height: 24px;
2024-07-16 18:14:27 +08:00
background: #EDF4F5;
2024-07-14 16:42:38 +08:00
align-self: flex-end;
}
.message-box.received {
color: initial;
2024-07-16 18:14:27 +08:00
width: fit-content;
2024-07-14 16:42:38 +08:00
padding-top: 16px;
background-color: #f7f7f7;
text-align: left;
word-wrap: break-word;
margin-bottom: 0;
padding-bottom: 0;
text-align: justify;
}
p.message-text {
max-width: 100%;
word-wrap: break-word;
margin-bottom: 0;
}
p.message-md {
word-wrap: break-word;
margin-bottom: 0;
}
img.message-image {
max-width: 300px;
max-height: 50vh;
object-fit: contain;
}
.input-box {
width: calc(100% - 2rem);
max-width: calc(1100px - 2rem);
margin: 0 auto;
display: flex;
2024-07-17 20:00:35 +08:00
align-items: flex-end;
2024-07-14 16:42:38 +08:00
background-color: #F4F4F4;
border-radius: 2rem;
2024-07-17 20:00:35 +08:00
height: auto;
2024-07-14 16:42:38 +08:00
padding: 0.5rem;
}
2024-07-17 20:00:35 +08:00
.user-input {
2024-07-14 16:42:38 +08:00
flex: 1;
height: 40px;
padding: 0.5rem 1rem;
background-color: transparent;
border: none;
font-size: 1.2rem;
margin: 0 0.6rem;
color: #111111;
font-size: 16px;
font-variation-settings: 'wght' 400, 'opsz' 10.5;
2024-07-17 20:00:35 +08:00
outline: none;
2024-07-14 16:42:38 +08:00
&:focus {
outline: none;
2024-07-17 20:00:35 +08:00
box-shadow: none;
}
&:active {
outline: none;
2024-07-14 16:42:38 +08:00
}
}
.ant-btn-icon-only {
font-size: 16px;
cursor: pointer;
background-color: transparent;
border: none;
height: 2.5rem;
background-color: black;
border-radius: 3rem;
color: white;
}
button:disabled {
background: #D7D7D7;
cursor: not-allowed;
}
p.note {
width: 100%;
font-size: small;
text-align: center;
padding: 0rem;
color: #ccc;
margin: 4px 0;
}
/*
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
*/
</style>