ForcePilot/web/src/components/ChatComponent.vue
Wenjie Zhang 0d455bcc3d update
2024-09-06 12:54:17 +08:00

850 lines
22 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.

<!-- ChatComponent.vue -->
<template>
<div class="chat" ref="chatContainer">
<div class="header">
<div class="header__left">
<div
v-if="!state.isSidebarOpen"
class="close nav-btn"
@click="state.isSidebarOpen = true"
>
<MenuOutlined />
</div>
<div
class="newchat nav-btn"
@click="$emit('newconv')"
>
<PlusCircleOutlined /> <span class="text">新对话:{{ configStore.config?.model_name }}</span>
</div>
</div>
<div class="header__right">
<!-- <div class="nav-btn text metas">
<CompassFilled v-if="meta.use_web" />
<GoldenFilled v-if="meta.use_graph"/>
</div> -->
<a-dropdown v-if="meta.selectedKB !== null">
<a class="ant-dropdown-link nav-btn" @click.prevent>
<!-- <component :is="meta.selectedKB === null ? BookOutlined : BookFilled" /> -->
<BookOutlined />
<span class="text">{{ meta.selectedKB === null ? '不使用' : opts.databases[meta.selectedKB]?.name }}</span>
</a>
<template #overlay>
<a-menu>
<a-menu-item v-for="(db, index) in opts.databases" :key="index" @click="meta.selectedKB=index">
<a href="javascript:;" >{{ db.name }}</a>
</a-menu-item>
<a-menu-item @click="meta.selectedKB = null">
<a href="javascript:;">不使用</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="nav-btn text " @click="opts.showPanel = !opts.showPanel">
<component :is="opts.showPanel ? FolderOpenOutlined : FolderOutlined" /> <span class="text">选项</span>
</div>
<div v-if="opts.showPanel" class="my-panal swing-in-top-fwd" ref="panel">
<div class="flex-center" v-if="configStore.config.enable_knowledge_base">
知识库
<div @click.stop>
<a-dropdown>
<a class="ant-dropdown-link " @click.prevent>
<!-- <component :is="meta.selectedKB === null ? BookOutlined : BookFilled" />&nbsp; -->
<BookOutlined />&nbsp;
<span class="text">{{ meta.selectedKB === null ? '不使用' : opts.databases[meta.selectedKB]?.name }}</span>
</a>
<template #overlay>
<a-menu>
<a-menu-item v-for="(db, index) in opts.databases" :key="index" @click="meta.selectedKB=index">
<a href="javascript:;">{{ db.name }}</a>
</a-menu-item>
<a-menu-item @click="meta.selectedKB = null">
<a href="javascript:;">不使用</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div class="flex-center" @click="meta.use_graph = !meta.use_graph" v-if="configStore.config.enable_knowledge_base">
图数据库 <div @click.stop><a-switch v-model:checked="meta.use_graph" /></div>
</div>
<div class="flex-center" @click="meta.use_web = !meta.use_web" v-if="configStore.config.enable_search_engine">
搜索引擎Bing <div @click.stop><a-switch v-model:checked="meta.use_web" /></div>
</div>
<div class="flex-center" @click="meta.rewrite_query = !meta.rewrite_query" v-if="configStore.config.enable_reranker">
重写查询 <div @click.stop><a-switch v-model:checked="meta.rewrite_query" /></div>
</div>
<div class="flex-center" @click="meta.rewrite_query = !meta.rewrite_query">
流式输出 <div @click.stop><a-switch v-model:checked="meta.stream" /></div>
</div>
<div class="flex-center" @click="meta.summary_title = !meta.summary_title">
总结对话标题 <div @click.stop><a-switch v-model:checked="meta.summary_title" /></div>
</div>
<div class="flex-center">
最大历史轮数 <a-input-number id="inputNumber" v-model:value="meta.history_round" :min="1" :max="50" />
</div>
</div>
</div>
</div>
<div v-if="conv.messages.length == 0" class="chat-examples">
<h1>你好,我是 Athena 😊</h1>
<div class="opts">
<div
class="opt__button"
v-for="(exp, key) in examples"
:key="key"
@click="autoSend(exp)"
>
{{ exp }}
</div>
</div>
</div>
<div 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>
<div v-else-if="message.text.length == 0 && message.status=='querying'" class="loading-dots">
<div></div>
<div></div>
<div></div>
</div>
<div v-else-if="message.text.length == 0 || message.status == 'error'" class="err-msg">请求错误,请重试</div>
<p v-else
v-html="renderMarkdown(message)"
class="message-md"
@click="consoleMsg(message)"></p>
<RefsComponent v-if="message.role=='received' && message.status=='finished'" :message="message" />
</div>
</div>
<div class="bottom">
<div class="input-box">
<a-textarea
class="user-input"
v-model:value="conv.inputText"
@keydown="handleKeyDown"
placeholder="输入问题……"
:auto-size="{ minRows: 1, maxRows: 10 }"
/>
<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">请注意辨别内容的可靠性 模型供应商:{{ configStore.config?.model_provider }}: {{ configStore.config?.model_name }}</p>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, toRefs, nextTick, computed, watch } from 'vue'
import {
SendOutlined,
MenuOutlined,
FormOutlined,
LoadingOutlined,
BookOutlined,
BookFilled,
CompassFilled,
GoldenFilled,
SettingOutlined,
SettingFilled,
PlusCircleOutlined,
FolderOutlined,
FolderOpenOutlined,
GlobalOutlined,
FileTextOutlined,
} from '@ant-design/icons-vue'
import { onClickOutside } from '@vueuse/core'
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import { useConfigStore } from '@/stores/config'
import RefsComponent from '@/components/RefsComponent.vue'
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
const props = defineProps({
conv: Object,
state: Object
})
const emit = defineEmits(['rename-title', 'newconv']);
const configStore = useConfigStore()
const { conv, state } = toRefs(props)
const chatContainer = ref(null)
const isStreaming = ref(false)
const panel = ref(null)
const examples = ref([
'写一个冒泡排序',
'肉碱的分子量是多少?直接回答',
'简述大蒜的功效是什么?',
'A大于BB小于CA和C哪个大',
'今天天气怎么样?',
'吃饭吃出苍蝇可以索赔吗?',
])
const opts = reactive({
showPanel: false,
openDetail: false,
databases: [],
})
const meta = reactive(JSON.parse(localStorage.getItem('meta')) || {
use_graph: false,
use_web: false,
graph_name: "neo4j",
rewrite_query: true,
selectedKB: null,
stream: true,
summary_title: true,
history_round: 5,
})
const marked = new Marked(
{
gfm: true,
breaks: true,
tables: true,
},
markedHighlight({
langPrefix: 'hljs language-',
highlight(code) {
return hljs.highlightAuto(code).value;
}
})
);
const consoleMsg = (message) => console.log(message)
onClickOutside(panel, () => setTimeout(() => opts.showPanel = false, 30))
const renderMarkdown = (message) => {
if (message.status === 'loading') {
return marked.parse(message.text + '🟢')
} else {
return marked.parse(message.text)
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
} else if (e.key === 'Enter' && e.shiftKey) {
// Insert a newline character at the current cursor position
const textarea = e.target;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
conv.value.inputText.value =
conv.value.inputText.value.substring(0, start) +
'\n' +
conv.value.inputText.value.substring(end);
nextTick(() => {
textarea.setSelectionRange(start + 1, start + 1);
});
}
}
const renameTitle = () => {
if (meta.summary_title) {
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('rename-title', data.response.split("")[0])
})
} else {
emit('rename-title', conv.value.messages[0].text)
}
}
const scrollToBottom = () => {
setTimeout(() => {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight - chatContainer.value.clientHeight
}, 10)
}
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 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,
status: "querying",
model_name: configStore.config.model_name
})
scrollToBottom()
}
const updateMessage = (text, id, refs, status) => {
const message = conv.value.messages.find((message) => message.id === id);
if (message) {
message.refs = refs;
message.status = status;
message.text = text;
} else {
console.error('Message not found');
}
scrollToBottom();
};
const updateStatus = (id, status) => {
const message = conv.value.messages.find((message) => message.id === id)
if (message) {
message.status = status
} else {
console.error('Message not found')
}
if (message.refs.knowledge_base.results.length > 0) {
message.groupedResults = message.refs.knowledge_base.results.reduce((acc, result) => {
const { filename } = result.file;
console.log(acc, result, filename)
if (!acc[filename]) {
acc[filename] = []
}
acc[filename].push(result)
return acc;
}, {})
}
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 loadDatabases = () => {
fetch('/api/database/', { method: "GET", })
.then(response => response.json())
.then(data => {
console.log(data)
opts.databases = data.databases
})
}
const sendMessage = () => {
const user_input = conv.value.inputText.trim()
if (user_input) {
isStreaming.value = true
appendUserMessage(user_input)
appendAiMessage("", null)
const cur_res_id = conv.value.messages[conv.value.messages.length - 1].id
conv.value.inputText = ''
meta.db_name = opts.databases[meta.selectedKB]?.metaname
fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
query: user_input,
history: conv.value.history,
meta: meta
}),
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')
updateStatus(cur_res_id, "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)
updateMessage(data.response, cur_res_id, data.refs, "loading")
conv.value.history = data.history
buffer = ''
} catch (e) {
// console.log(e)
}
return readChunk()
})
}
return readChunk()
})
.catch((error) => {
console.error(error)
updateStatus(cur_res_id, "error")
isStreaming.value = false
})
} else {
console.log('请输入消息')
}
}
const autoSend = (message) => {
conv.value.inputText = message
sendMessage()
}
// const clearChat = () => {
// conv.value.messages = []
// conv.value.history = []
// }
// 从本地存储加载数据
onMounted(() => {
scrollToBottom()
loadDatabases()
const storedMeta = localStorage.getItem('meta');
if (storedMeta) {
const parsedMeta = JSON.parse(storedMeta);
Object.assign(meta, parsedMeta);
}
});
// 监听 meta 对象的变化,并保存到本地存储
watch(
() => meta,
(newMeta) => {
localStorage.setItem('meta', JSON.stringify(newMeta));
},
{ deep: true }
);
</script>
<style lang="less" scoped>
.chat {
position: relative;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
background: white;
position: relative;
box-sizing: border-box;
flex: 5 5 200px;
overflow-y: scroll;
.header {
user-select: none;
position: sticky;
top: 0;
z-index: 10;
background-color: white;
height: var(--header-height);
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
.header__left, .header__right {
display: flex;
align-items: center;
}
}
.nav-btn {
height: 2.5rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
color: var(--c-text-light-1);
cursor: pointer;
font-size: 1rem;
width: auto;
padding: 0.5rem 1rem;
.text {
margin-left: 10px;
}
&:hover {
background-color: var(--main-light-3);
}
}
}
.metas {
display: flex;
gap: 8px;
}
.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: 11;
width: 250px;
.flex-center {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: var(--main-light-3);
}
}
}
.chat-examples {
padding: 0 50px;
text-align: center;
position: absolute;
top: 20%;
width: 100%;
z-index: 9;
h1 {
margin-bottom: 20px;
font-size: 24px;
color: #333;
}
.opts {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
.opt__button {
background-color: white;
color: #222;
padding: 6px 1rem;
border-radius: 1rem;
cursor: pointer;
// border: 2px solid var(--main-light-4);
transition: background-color 0.3s;
box-shadow: 0px 0px 10px 4px var(--main-light-4);
&:hover {
background-color: #fcfcfc;
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.1);
}
}
}
}
.chat-box {
width: 100%;
max-width: 900px;
margin: 0 auto;
flex-grow: 1;
padding: 1rem 2rem;
display: flex;
flex-direction: column;
.message-box {
display: inline-block;
border-radius: 1.5rem;
margin: 0.8rem 0;
padding: 0.625rem 1.25rem;
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;
color: black;
/* 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; */
.err-msg {
color: red;
border: 1px solid red;
padding: 0.2rem 1rem;
border-radius: 8px;
text-align: center;
background: #FFEBEE;
}
}
.message-box.sent {
line-height: 24px;
max-width: 95%;
background: var(--main-light-3);
align-self: flex-end;
}
.message-box.received {
color: initial;
width: fit-content;
text-align: left;
word-wrap: break-word;
margin: 0;
padding-bottom: 0;
padding-top: 16px;
padding-left: 0;
padding-right: 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;
}
}
.bottom {
position: sticky;
bottom: 0;
width: 100%;
margin: 0 auto;
padding: 4px 2rem 0 2rem;
background: white;
.input-box {
display: flex;
width: 100%;
max-width: 900px;
margin: 0 auto;
align-items: flex-end;
background-color: var(--main-light-4);
border-radius: 2rem;
height: auto;
padding: 0.5rem;
&:focus-within {
background-color: #ECF1F2;
}
.user-input {
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;
outline: none;
&:focus {
outline: none;
box-shadow: none;
}
&:active {
outline: none;
}
}
}
.note {
width: 100%;
font-size: small;
text-align: center;
padding: 0rem;
color: #ccc;
margin: 4px 0;
}
}
.ant-dropdown-link {
color: var(--c-text-light-1);
cursor: pointer;
}
.ant-btn-icon-only {
font-size: 16px;
cursor: pointer;
background-color: transparent;
border: none;
height: 2.5rem;
background-color: var(--main-color);
border-radius: 3rem;
color: white;
transition: background-color 0.3s;
&:hover {
color: white;
}
}
button:disabled {
background: #E0E0E0;
cursor: not-allowed;
}
.chat::-webkit-scrollbar {
position: absolute;
width: 4px;
}
.chat::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
.chat::-webkit-scrollbar-thumb {
background: var(--c-text-dark-2);
border-radius: 4px;
}
.chat::-webkit-scrollbar-thumb:hover {
background: rgb(100, 100, 100);
border-radius: 4px;
}
.chat::-webkit-scrollbar-thumb:active {
background: rgb(68, 68, 68);
border-radius: 4px;
}
.loading-dots {
display: inline-flex;
align-items: center;
justify-content: center;
}
.loading-dots div {
width: 10px;
height: 10px;
margin: 0 5px;
background-color: #333;
border-radius: 50%;
animation: loading 0.8s infinite ease-in-out both;
}
.loading-dots div:nth-child(1) {
animation-delay: 0s;
}
.loading-dots div:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots div:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes loading {0%,80%,100%{transform:scale(0.5);}40%{transform:scale(1);}}
.slide-out-left{-webkit-animation:slide-out-left .2s cubic-bezier(.55,.085,.68,.53) both;animation:slide-out-left .5s cubic-bezier(.55,.085,.68,.53) both}
.swing-in-top-fwd{-webkit-animation:swing-in-top-fwd .2s ease-out both;animation:swing-in-top-fwd .2s ease-out both}
@-webkit-keyframes swing-in-top-fwd{0%{-webkit-transform:rotateX(-100deg);transform:rotateX(-100deg);-webkit-transform-origin:top;transform-origin:top;opacity:0}100%{-webkit-transform:rotateX(0deg);transform:rotateX(0deg);-webkit-transform-origin:top;transform-origin:top;opacity:1}}@keyframes swing-in-top-fwd{0%{-webkit-transform:rotateX(-100deg);transform:rotateX(-100deg);-webkit-transform-origin:top;transform-origin:top;opacity:0}100%{-webkit-transform:rotateX(0deg);transform:rotateX(0deg);-webkit-transform-origin:top;transform-origin:top;opacity:1}}
@-webkit-keyframes slide-out-left{0%{-webkit-transform:translateX(0);transform:translateX(0);opacity:1}100%{-webkit-transform:translateX(-1000px);transform:translateX(-1000px);opacity:0}}@keyframes slide-out-left{0%{-webkit-transform:translateX(0);transform:translateX(0);opacity:1}100%{-webkit-transform:translateX(-1000px);transform:translateX(-1000px);opacity:0}}
@media (max-width: 520px) {
.chat {
height: calc(100vh - 60px);
}
.chat-container .chat .header {
background: var(--main-light-4);
.header__left, .header__right {
gap: 20px;
}
.nav-btn {
font-size: 1.5rem;
padding: 0;
&:hover {
background-color: transparent;
color: black;
}
.text {
display: none;
}
}
}
.bottom {
padding: 0.5rem 0.5rem;
.input-box {
border-radius: 8px;
padding: 0.5rem;
textarea.user-input {
padding: 0.5rem 0;
}
}
.note {
display: none;
}
}
}
</style>
<style lang="less">
.message-box pre {
border-radius: 8px;
font-size: 14px;
border: 1px solid var(--main-light-3);
padding: 1rem;
&:has(code.hljs) {
padding: 0;
}
}
</style>