ForcePilot/web/src/components/ChatComponent.vue
2024-07-31 20:22:05 +08:00

823 lines
20 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">新对话</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" ref="panel">
<div class="graphbase 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="graphbase 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="graphbase 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="graphbase 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="graphbase flex-center" @click="meta.rewrite_query = !meta.rewrite_query">
流式输出 <div @click.stop><a-switch v-model:checked="meta.stream" /></div>
</div>
<div class="graphbase flex-center" @click="meta.summary_title = !meta.summary_title">
总结对话标题 <div @click.stop><a-switch v-model:checked="meta.summary_title" /></div>
</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>
<p v-else
v-html="renderMarkdown(message.text)"
class="message-md"
@click="consoleMsg(message)"></p>
<div class="refs" v-if="message.role=='received' && message.groupedResults && message.status=='finished'">
<a-tag
class="filetag"
v-for="(results, filename) in message.groupedResults"
:key="filename"
@click="opts.openDetail = true"
:bordered="false"
>
{{ filename }}
<a-drawer
v-model:open="opts.openDetail"
title="检索详情"
width="800"
:contentWrapperStyle="{ maxWidth: '100%'}"
placement="right"
class="retrieval-detail"
rootClassName="root"
>
<div class="fileinfo">
<p><strong>文件名:</strong> {{ results[0].file.filename }}</p>
<p><strong>文件类型:</strong> {{ results[0].file.type }}</p>
<p><strong>创建时间:</strong> {{ new Date(results[0].file.created_at * 1000).toLocaleString() }}</p>
</div>
<div v-for="(res, idx) in results" :key="idx" class="result-item">
<p class="result-id"><strong>ID:</strong> #{{ res.id }}</p>
<p class="result-distance"><strong>相似度距离:</strong> {{ res.distance }}</p>
<p class="result-rerank-score"><strong>重排序分数:</strong> {{ res.rerank_score }}</p>
<p class="result-text">{{ res.entity.text }}</p>
</div>
</a-drawer>
</a-tag>
</div>
</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,
} from '@ant-design/icons-vue'
import { onClickOutside } from '@vueuse/core'
import { marked } from 'marked';
import { useConfigStore } from '@/stores/config'
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,
})
// 更多选项可以在 marked 文档中找到https://marked.js.org/
marked.setOptions({
gfm: true,
breaks: true,
tables: true,
});
const renderMarkdown = (text) => marked(text)
const consoleMsg = (message) => console.log(message)
onClickOutside(panel, () => setTimeout(() => opts.showPanel = false, 30))
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"
})
scrollToBottom()
}
const updateMessage = (text, id, refs, status) => {
const message = conv.value.messages.find((message) => message.id === id)
if (message) {
message.text = text
message.refs = refs
message.status = status
} 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')
}
console.log("updateStatus", message, message.refs.knowledge_base.results.length > 0)
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;
}, {})
}
}
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()
})
} 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;
}
.graphbase {
padding: 8px 16px;
border-radius: 12px;
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: 1100px;
margin: 0 auto;
flex-grow: 1;
padding: 1rem;
display: flex;
flex-direction: column;
.message-box {
max-width: 95%;
display: inline-block;
border-radius: 0.8rem;
margin: 0.8rem 0;
padding: 1rem;
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: #0D0D0D;
/* 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;
background: var(--main-light-3);
align-self: flex-end;
}
.message-box.received {
color: initial;
width: fit-content;
padding-top: 16px;
background-color: #F5F7F8;
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;
}
.refs {
margin-bottom: 20px;
.filetag:hover {
cursor: pointer;
}
}
}
.retrieval-detail {
.fileinfo {
margin-bottom: 20px;
padding: 10px;
background: var(--main-light-3);
border-radius: 8px;
p {
margin: 0;
line-height: 1.5;
}
}
.result-item {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: var(--main-light-4);
.result-id,
.result-distance,
.result-rerank-score,
.result-text-label,
.result-text {
margin: 5px 0;
}
}
}
.bottom {
position: sticky;
bottom: 0;
width: 100%;
margin: 0 auto;
padding: 0.5rem 2rem;
background: white;
.input-box {
display: flex;
width: 100%;
max-width: 1100px;
margin: 0 auto;
align-items: flex-end;
background-color: #F5F7F8;
border-radius: 2rem;
height: auto;
padding: 0.5rem;
.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: black;
border-radius: 3rem;
color: white;
&:hover {
color: white;
}
}
button:disabled {
background: #D7D7D7;
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;
}
@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>