Files
SQLBot/frontend/src/views/chat/index.vue
T

974 lines
27 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.
<template>
<el-container class="chat-container no-padding">
<el-aside v-if="!isAssistant && chatListSideBarShow" class="chat-container-left">
<ChatListContainer
v-model:chat-list="chatList"
v-model:current-chat-id="currentChatId"
v-model:current-chat="currentChat"
v-model:loading="loading"
:in-popover="!chatListSideBarShow"
@go-empty="goEmpty"
@on-chat-created="onChatCreated"
@on-click-history="onClickHistory"
@on-chat-deleted="onChatDeleted"
@on-chat-renamed="onChatRenamed"
@on-click-side-bar-btn="hideSideBar"
/>
</el-aside>
<div v-if="!isAssistant && !chatListSideBarShow" class="hidden-sidebar-btn">
<el-popover
:width="280"
placement="bottom-start"
popper-style="padding: 0;
height: 654px;
border: 1px solid rgba(222, 224, 227, 1);
border-radius: 6px;
"
>
<template #reference>
<el-button link type="primary" class="icon-btn" @click="showSideBar">
<el-icon>
<icon_sidebar_outlined />
</el-icon>
</el-button>
</template>
<ChatListContainer
v-model:chat-list="chatList"
v-model:current-chat-id="currentChatId"
v-model:current-chat="currentChat"
v-model:loading="loading"
:in-popover="!chatListSideBarShow"
@go-empty="goEmpty"
@on-chat-created="onChatCreated"
@on-click-history="onClickHistory"
@on-chat-deleted="onChatDeleted"
@on-chat-renamed="onChatRenamed"
@on-click-side-bar-btn="hideSideBar"
/>
</el-popover>
<el-tooltip effect="dark" :content="t('qa.new_chat')" placement="bottom">
<el-button link type="primary" class="icon-btn" @click="createNewChatSimple">
<el-icon>
<icon_new_chat_outlined />
</el-icon>
</el-button>
</el-tooltip>
</div>
<el-container :loading="loading">
<el-main
class="chat-record-list"
:class="{ 'hide-sidebar': !isAssistant && !chatListSideBarShow }"
>
<div v-if="computedMessages.length == 0 && !loading" class="welcome-content-block">
<div class="welcome-content">
<div class="greeting">
<el-icon size="32">
<logo_fold />
</el-icon>
{{ t('qa.greeting') }}
</div>
<div class="sub">{{ t('qa.hint_description') }}</div>
<el-button
v-if="currentChatId === undefined"
size="large"
type="primary"
class="greeting-btn"
@click="createNewChatSimple"
>
<span class="inner-icon">
<el-icon>
<icon_new_chat_outlined />
</el-icon>
</span>
{{ t('qa.start_sqlbot') }}
</el-button>
</div>
</div>
<div v-else-if="computedMessages.length == 0 && loading" class="welcome-content-block">
<logo />
</div>
<el-scrollbar v-if="computedMessages.length > 0" ref="chatListRef">
<div class="chat-scroll" :class="{ 'no-sidebar': !isAssistant && !chatListSideBarShow }">
<template v-for="(message, _index) in computedMessages" :key="_index">
<ChatRow :current-chat="currentChat" :msg="message" :hide-avatar="message.first_chat">
<RecommendQuestion
v-if="message.role === 'assistant' && message.first_chat"
ref="recommendQuestionRef"
:current-chat="currentChat"
:record-id="message.record?.id"
:questions="message.recommended_question"
:first-chat="message.first_chat"
@click-question="quickAsk"
/>
<UserChat v-if="message.role === 'user'" :message="message" />
<template v-if="message.role === 'assistant' && !message.first_chat">
<ChartAnswer
v-if="
(message?.record?.analysis_record_id === undefined ||
message?.record?.analysis_record_id === null) &&
(message?.record?.predict_record_id === undefined ||
message?.record?.predict_record_id === null)
"
ref="chartAnswerRef"
:chat-list="chatList"
:current-chat="currentChat"
:current-chat-id="currentChatId"
:loading="isTyping"
:message="message"
:reasoning-name="['sql_answer', 'chart_answer']"
@finish="onChartAnswerFinish"
@error="onChartAnswerError"
>
<ChartBlock style="margin-top: 12px" :message="message" />
<div
v-if="message.record?.error && message.record?.error?.trim().length > 0"
class="error-container"
>
{{ message.record?.error }}
</div>
<template #tool>
<ChatToolBar v-if="!message.isTyping" :message="message">
<div class="tool-btns">
<el-tooltip effect="dark" :content="t('qa.ask_again')" placement="top">
<el-button
class="tool-btn"
text
:disabled="isTyping"
@click="askAgain(message)"
>
<el-icon size="18">
<icon_replace_outlined />
</el-icon>
</el-button>
</el-tooltip>
<template v-if="message.record?.chart">
<div class="divider"></div>
<div>
<el-button
class="tool-btn"
text
:disabled="isTyping"
@click="clickAnalysis(message.record?.id)"
>
<span class="tool-btn-inner">
<el-icon size="18">
<icon_screen_outlined />
</el-icon>
<span class="btn-text">
{{ t('chat.data_analysis') }}
</span>
</span>
</el-button>
</div>
<div>
<el-button
class="tool-btn"
text
:disabled="isTyping"
@click="clickPredict(message.record?.id)"
>
<span class="tool-btn-inner">
<el-icon size="18">
<icon_start_outlined />
</el-icon>
<span class="btn-text">
{{ t('chat.data_predict') }}
</span>
</span>
</el-button>
</div>
</template>
</div>
</ChatToolBar>
</template>
<template #footer>
<RecommendQuestion
ref="recommendQuestionRef"
:current-chat="currentChat"
:record-id="message.record?.id"
:questions="message.recommended_question"
:first-chat="message.first_chat"
@click-question="quickAsk"
/>
</template>
</ChartAnswer>
<AnalysisAnswer
v-if="
message?.record?.analysis_record_id !== undefined &&
message?.record?.analysis_record_id !== null
"
ref="analysisAnswerRef"
:chat-list="chatList"
:current-chat="currentChat"
:current-chat-id="currentChatId"
:loading="isTyping"
:message="message"
@finish="onAnalysisAnswerFinish"
@error="onAnalysisAnswerError"
>
<div
v-if="message.record?.error && message.record?.error?.trim().length > 0"
class="error-container"
>
{{ message.record?.error }}
</div>
<template #tool>
<ChatToolBar v-if="!message.isTyping" :message="message" />
</template>
</AnalysisAnswer>
<PredictAnswer
v-if="
message?.record?.predict_record_id !== undefined &&
message?.record?.predict_record_id !== null
"
ref="predictAnswerRef"
:chat-list="chatList"
:current-chat="currentChat"
:current-chat-id="currentChatId"
:loading="isTyping"
:message="message"
@finish="onPredictAnswerFinish"
@error="onPredictAnswerError"
>
<ChartBlock style="margin-top: 12px" :message="message" is-predict />
<div
v-if="message.record?.error && message.record?.error?.trim().length > 0"
class="error-container"
>
{{ message.record?.error }}
</div>
<template #tool>
<ChatToolBar v-if="!message.isTyping" :message="message" />
</template>
</PredictAnswer>
</template>
</ChatRow>
</template>
</div>
</el-scrollbar>
</el-main>
<el-footer
v-if="computedMessages.length > 0 || (isAssistant && currentChatId)"
class="chat-footer"
>
<div class="input-container" @click="clickInput">
<div class="datasource">
<template v-if="currentChat.datasource && currentChat.datasource_name">
{{ t('qa.selected_datasource') }}:
<span class="name">
{{ currentChat.datasource_name }}
</span>
</template>
</div>
<div class="input-wrapper">
<el-input
ref="inputRef"
v-model="inputMessage"
:disabled="isTyping"
class="input-area"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
:placeholder="t('qa.question_placeholder')"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
</div>
<el-button
circle
type="primary"
class="input-icon"
:disabled="isTyping"
@click.stop="sendMessage"
>
<el-icon size="16">
<icon_send_filled />
</el-icon>
</el-button>
</div>
</el-footer>
</el-container>
<ChatCreator v-if="!isAssistant" ref="chatCreatorRef" @on-chat-created="onChatCreatedQuick" />
<ChatCreator ref="hiddenChatCreatorRef" hidden @on-chat-created="onChatCreatedQuick" />
</el-container>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { Chat, chatApi, ChatInfo, type ChatMessage, ChatRecord } from '@/api/chat'
import ChatRow from './ChatRow.vue'
import ChartAnswer from './answer/ChartAnswer.vue'
import AnalysisAnswer from './answer/AnalysisAnswer.vue'
import PredictAnswer from './answer/PredictAnswer.vue'
import UserChat from './chat-block/UserChat.vue'
import ChartBlock from './chat-block/ChartBlock.vue'
import RecommendQuestion from './RecommendQuestion.vue'
import ChatListContainer from './ChatListContainer.vue'
import ChatCreator from '@/views/chat/ChatCreator.vue'
import ChatToolBar from './ChatToolBar.vue'
import { useI18n } from 'vue-i18n'
import { find } from 'lodash-es'
import icon_new_chat_outlined from '@/assets/svg/icon_new_chat_outlined.svg'
import icon_sidebar_outlined from '@/assets/svg/icon_sidebar_outlined.svg'
import icon_replace_outlined from '@/assets/svg/icon_replace_outlined.svg'
import icon_screen_outlined from '@/assets/svg/icon_screen_outlined.svg'
import icon_start_outlined from '@/assets/svg/icon_start_outlined.svg'
import logo_fold from '@/assets/LOGO-fold.svg'
import logo from '@/assets/LOGO.svg'
import icon_send_filled from '@/assets/svg/icon_send_filled.svg'
import { useAssistantStore } from '@/stores/assistant'
const props = defineProps<{
startChatDsId?: number
}>()
const assistantStore = useAssistantStore()
const isAssistant = computed(() => assistantStore.getAssistant)
const { t } = useI18n()
const inputMessage = ref('')
const chatListRef = ref()
const chatCreatorRef = ref()
function scrollToBottom() {
nextTick(() => {
chatListRef.value?.scrollTo({
top: chatListRef.value.wrapRef.scrollHeight,
behavior: 'smooth',
})
})
}
const loading = ref<boolean>(false)
const chatList = ref<Array<ChatInfo>>([])
const currentChatId = ref<number | undefined>()
const currentChat = ref<ChatInfo>(new ChatInfo())
const isTyping = ref<boolean>(false)
const computedMessages = computed<Array<ChatMessage>>(() => {
const messages: Array<ChatMessage> = []
if (currentChatId.value === undefined) {
return messages
}
for (let i = 0; i < currentChat.value.records.length; i++) {
const record = currentChat.value.records[i]
if (record.question !== undefined && !record.first_chat) {
messages.push({
role: 'user',
create_time: record.create_time,
record: record,
content: record.question,
index: i,
})
}
messages.push({
role: 'assistant',
create_time: record.create_time,
record: record,
isTyping: i === currentChat.value.records.length - 1 && isTyping.value,
first_chat: record.first_chat,
recommended_question: record.recommended_question,
index: i,
})
}
console.log(messages)
return messages
})
const goEmpty = () => {
inputMessage.value = ''
}
const createNewChatSimple = async () => {
currentChat.value = new ChatInfo()
currentChatId.value = undefined
await createNewChat()
}
const createNewChat = async () => {
goEmpty()
if (isAssistant.value) {
const assistantChat = await assistantStore.setChat()
if (assistantChat) {
onChatCreatedQuick(assistantChat as any)
}
return
}
chatCreatorRef.value?.showDs()
}
function getChatList() {
loading.value = true
chatApi
.list()
.then((res) => {
chatList.value = chatApi.toChatInfoList(res)
})
.finally(() => {
loading.value = false
})
}
function onClickHistory(chat: Chat) {
console.log('click history', chat)
scrollToBottom()
}
function toAssistantHistory(chat: Chat) {
currentChat.value = new ChatInfo(chat)
if (chat !== undefined && chat.id !== undefined && !loading.value) {
currentChatId.value = chat.id
loading.value = true
chatApi
.get(chat.id)
.then((res) => {
const info = chatApi.toChatInfo(res)
if (info) {
currentChat.value = info
onClickHistory(info)
}
})
.finally(() => {
loading.value = false
})
}
}
function onChatDeleted(id: number) {
console.log('deleted', id)
}
function onChatRenamed(chat: Chat) {
console.log('renamed', chat)
}
const chatListSideBarShow = ref<boolean>(true)
function hideSideBar() {
chatListSideBarShow.value = false
}
function showSideBar() {
chatListSideBarShow.value = true
}
function onChatCreatedQuick(chat: ChatInfo) {
chatList.value.unshift(chat)
currentChatId.value = chat.id
currentChat.value = chat
onChatCreated(chat)
}
function onChatCreated(chat: ChatInfo) {
if (chat.records.length === 1) {
getRecommendQuestions(chat.records[0].id)
}
}
const recommendQuestionRef = ref()
function getRecommendQuestions(id?: number) {
nextTick(() => {
if (recommendQuestionRef.value) {
if (recommendQuestionRef.value instanceof Array) {
for (let i = 0; i < recommendQuestionRef.value.length; i++) {
const _id = recommendQuestionRef.value[i].id()
if (_id === id) {
recommendQuestionRef.value[i].getRecommendQuestions()
break
}
}
} else {
recommendQuestionRef.value.getRecommendQuestions()
}
}
})
}
onMounted(() => {
getChatList()
})
function quickAsk(question: string) {
inputMessage.value = question
nextTick(() => {
sendMessage()
})
}
const chartAnswerRef = ref()
async function onChartAnswerFinish(id: number) {
loading.value = false
isTyping.value = false
getRecommendQuestions(id)
}
function onChartAnswerError() {
loading.value = false
isTyping.value = false
}
const sendMessage = async () => {
if (!inputMessage.value.trim()) return
loading.value = true
isTyping.value = true
const currentRecord = new ChatRecord()
currentRecord.create_time = new Date()
currentRecord.chat_id = currentChatId.value
currentRecord.question = inputMessage.value
currentRecord.sql_answer = ''
currentRecord.sql = ''
currentRecord.chart_answer = ''
currentRecord.chart = ''
currentChat.value.records.push(currentRecord)
inputMessage.value = ''
nextTick(async () => {
const index = currentChat.value.records.length - 1
if (chartAnswerRef.value) {
if (chartAnswerRef.value instanceof Array) {
for (let i = 0; i < chartAnswerRef.value.length; i++) {
const _index = chartAnswerRef.value[i].index
if (index === _index) {
await chartAnswerRef.value[i].sendMessage()
break
}
}
} else {
await chartAnswerRef.value.sendMessage()
}
}
})
}
function getChatPredictData(recordId?: number) {
chatApi.get_chart_predict_data(recordId).then((response) => {
currentChat.value.records.forEach((record) => {
if (record.id === recordId) {
record.predict_data = response
}
})
})
}
const analysisAnswerRef = ref()
async function onAnalysisAnswerFinish(id: number) {
loading.value = false
isTyping.value = false
console.log(id)
//await getRecommendQuestions(id)
}
function onAnalysisAnswerError() {
loading.value = false
isTyping.value = false
}
function askAgain(message: ChatMessage) {
inputMessage.value = message.record?.question ?? ''
nextTick(() => {
sendMessage()
})
}
async function clickAnalysis(id?: number) {
const baseRecord = find(currentChat.value.records, (value) => id === value.id)
if (baseRecord == undefined) {
return
}
loading.value = true
isTyping.value = true
const currentRecord = new ChatRecord()
currentRecord.create_time = new Date()
currentRecord.chat_id = baseRecord.chat_id
currentRecord.question = baseRecord.question
currentRecord.chart = baseRecord.chart
currentRecord.data = baseRecord.data
currentRecord.analysis_record_id = id
currentRecord.analysis = ''
currentChat.value.records.push(currentRecord)
nextTick(async () => {
const index = currentChat.value.records.length - 1
if (analysisAnswerRef.value) {
if (analysisAnswerRef.value instanceof Array) {
for (let i = 0; i < analysisAnswerRef.value.length; i++) {
const _index = analysisAnswerRef.value[i].index
if (index === _index) {
await analysisAnswerRef.value[i].sendMessage()
break
}
}
} else {
await analysisAnswerRef.value.sendMessage()
}
}
})
return
}
const predictAnswerRef = ref()
async function onPredictAnswerFinish(id: number) {
loading.value = false
isTyping.value = false
getChatPredictData(id)
//await getRecommendQuestions(id)
}
function onPredictAnswerError() {
loading.value = false
isTyping.value = false
}
async function clickPredict(id?: number) {
const baseRecord = find(currentChat.value.records, (value) => id === value.id)
if (baseRecord == undefined) {
return
}
loading.value = true
isTyping.value = true
const currentRecord = new ChatRecord()
currentRecord.create_time = new Date()
currentRecord.chat_id = baseRecord.chat_id
currentRecord.question = baseRecord.question
currentRecord.chart = baseRecord.chart
currentRecord.data = baseRecord.data
currentRecord.predict_record_id = id
currentRecord.predict = ''
currentRecord.predict_data = ''
currentChat.value.records.push(currentRecord)
nextTick(async () => {
const index = currentChat.value.records.length - 1
if (predictAnswerRef.value) {
if (predictAnswerRef.value instanceof Array) {
for (let i = 0; i < predictAnswerRef.value.length; i++) {
const _index = predictAnswerRef.value[i].index
if (index === _index) {
await predictAnswerRef.value[i].sendMessage()
break
}
}
} else {
await predictAnswerRef.value.sendMessage()
}
}
})
return
}
const handleCtrlEnter = (e: KeyboardEvent) => {
const textarea = e.target as HTMLTextAreaElement
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
inputMessage.value = value.substring(0, start) + '\n' + value.substring(end)
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1
})
}
const getHistoryList = () => {
return chatList.value
}
const getCurrentChatId = () => {
return currentChatId.value
}
const inputRef = ref()
function clickInput() {
inputRef.value?.focus()
}
defineExpose({
getHistoryList,
toAssistantHistory,
getCurrentChatId,
createNewChat,
})
const hiddenChatCreatorRef = ref()
onMounted(() => {
if (props.startChatDsId) {
const _id = props.startChatDsId
nextTick(() => {
hiddenChatCreatorRef.value?.createChat(_id)
})
const newUrl = window.location.hash.replace(/\?.*$/, '')
history.replaceState({}, '', newUrl)
}
})
</script>
<style lang="less" scoped>
.chat-container {
height: 100%;
position: relative;
border-radius: 12px;
.hidden-sidebar-btn {
z-index: 1;
position: absolute;
padding: 16px;
top: 0;
left: 0;
}
.icon-btn {
min-width: unset;
width: 26px;
height: 26px;
font-size: 18px;
--ed-button-text-color: rgba(31, 35, 41, 1);
--ed-button-hover-text-color: var(--ed-button-text-color);
--ed-button-active-text-color: var(--ed-button-text-color);
--ed-button-hover-link-text-color: var(--ed-button-text-color);
&:hover {
background: rgba(31, 35, 41, 0.1);
}
}
.chat-container-left {
--ed-aside-width: 280px;
border-radius: 12px 0 0 12px;
background: rgba(245, 246, 247, 1);
}
.chat-record-list {
padding: 0 0 20px 0;
border-radius: 0 12px 12px 0;
&.hide-sidebar {
border-radius: 12px;
}
}
.chat-scroll {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-left: 56px;
padding-right: 56px;
&.no-sidebar {
padding-left: 96px;
}
}
.chat-footer {
--ed-footer-height: calc(142px + 16px);
padding-bottom: 16px;
display: flex;
flex-direction: column;
align-items: center;
.input-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 800px;
border-radius: 16px;
border: 1px solid rgba(217, 220, 223, 1);
background: rgba(248, 249, 250, 1);
padding: 12px;
gap: 8px;
position: relative;
.input-icon {
min-width: unset;
position: absolute;
bottom: 12px;
right: 12px;
border-color: unset;
&.is-disabled {
background: rgba(187, 191, 196, 1);
border-color: unset;
}
}
&:hover {
border-color: rgba(28, 186, 144, 1);
}
}
.datasource {
width: 100%;
line-height: 22px;
font-size: 14px;
font-weight: 400;
color: rgba(100, 106, 115, 1);
.name {
color: rgba(31, 35, 41, 1);
}
}
.input-wrapper {
width: 100%;
flex: 1;
.input-area {
height: 100%;
:deep(.ed-textarea__inner) {
height: 100% !important;
background: transparent;
box-shadow: unset;
padding: unset;
}
}
}
}
.send-btn {
min-width: 0;
}
}
.error-container {
margin-top: 12px;
font-weight: 400;
font-size: 16px;
line-height: 24px;
color: rgba(31, 35, 41, 1);
}
.tool-btns {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
column-gap: 16px;
row-gap: 8px;
.tool-btn {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: rgba(100, 106, 115, 1);
.tool-btn-inner {
display: flex;
flex-direction: row;
align-items: center;
}
&:hover {
background: rgba(31, 35, 41, 0.1);
}
&:active {
background: rgba(31, 35, 41, 0.1);
}
}
.btn-text {
margin-left: 4px;
}
.divider {
width: 1px;
height: 16px;
border-left: 1px solid rgba(31, 35, 41, 0.15);
}
}
.welcome-content-block {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
.welcome-content {
width: 100%;
max-width: 800px;
display: flex;
gap: 16px;
align-items: center;
flex-direction: column;
.greeting {
display: flex;
align-items: center;
gap: 16px;
line-height: 32px;
font-size: 24px;
font-weight: 600;
color: rgba(31, 35, 41, 1);
}
.sub {
color: grey;
font-size: 16px;
line-height: 24px;
}
.greeting-btn {
width: 100%;
height: 88px;
border-style: dashed;
.inner-icon {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 6px;
}
font-size: 16px;
line-height: 24px;
font-weight: 500;
--ed-button-text-color: rgba(28, 186, 144, 1);
--ed-button-hover-text-color: rgba(28, 186, 144, 1);
--ed-button-active-text-color: rgba(28, 186, 144, 1);
--ed-button-bg-color: rgba(248, 249, 250, 1);
--ed-button-hover-bg-color: rgba(28, 186, 144, 0.1);
--ed-button-border-color: rgba(217, 220, 223, 1);
--ed-button-hover-border-color: rgba(28, 186, 144, 1);
--ed-button-active-bg-color: rgba(28, 186, 144, 0.2);
--ed-button-active-border-color: rgba(28, 186, 144, 1);
}
}
}
</style>