feat: chat view

This commit is contained in:
ulleo
2025-06-05 19:12:02 +08:00
parent 20ec595120
commit a4e456ce2e
9 changed files with 349 additions and 243 deletions
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import type {ChatMessage} from "@/api/chat.ts";
import {computed, ref} from "vue";
import type {TabsPaneContext} from 'element-plus'
import {Loading} from "@element-plus/icons-vue";
const props = defineProps<{
message?: ChatMessage
}>()
const settings = ref<{
type: "chart" | "sql" | "data"
showAnswer: boolean
}>({
type: "chart",
showAnswer: false,
})
const renderSqlThinking = computed(() => {
//todo md render?
return props.message?.record?.sql_answer
})
const renderChartThinking = computed(() => {
//todo md render?
return props.message?.record?.chart_answer
})
const handleClick = (tab: TabsPaneContext, event: Event) => {
console.log(tab, event)
}
const dataObject = computed<{
fields: Array<string>,
data: Array<{ [key: string]: any }>,
}>(() => {
if (props.message?.record?.data) {
return JSON.parse(props.message.record.data)
}
return {}
})
const chartObject = computed<{
type: "table" | "bar" | "line" | "pie"
title: string
axis: { x: { name: string, value: string }, y: { name: string, value: string } }
columns: Array<{ name: string, value: string }>
}>(() => {
if (props.message?.record?.chart) {
return JSON.parse(props.message.record.chart)
}
return {}
})
const currentChartType = ref<"table" | "bar" | "line" | "pie" | undefined>(undefined)
const chartType = computed<"table" | "bar" | "line" | "pie">({
get() {
if (currentChartType.value) {
return currentChartType.value
}
return chartObject.value.type ?? "table"
},
set(v) {
currentChartType.value = v
}
})
</script>
<template>
<div v-if="message">
<div>
<div v-if="message.isTyping">Thinking ...</div>
<div v-if="chartObject.title">{{ chartObject.title }}</div>
<el-tabs v-model="settings.type" class="demo-tabs" @tab-click="handleClick" tab-position="top">
<el-tab-pane label="Chart" name="chart">
<template #label>
<el-select v-model="chartType" style="width: 80px">
<el-option value="table">table</el-option>
<el-option value="bar">bar</el-option>
<el-option value="line">line</el-option>
<el-option value="pie">pie</el-option>
</el-select>
</template>
</el-tab-pane>
<el-tab-pane label="SQL" name="sql"></el-tab-pane>
<el-tab-pane label="Data" name="data"></el-tab-pane>
</el-tabs>
</div>
<template v-if="message.record">
<el-collapse expand-icon-position="left">
<el-collapse-item name="1">
<template #title>
Inference process
<el-icon v-if="props.message.isTyping">
<Loading/>
</el-icon>
</template>
<div>
<template v-if="message.record.sql_answer">
<div>SQL Generation:</div>
<div v-if="message.record.sql_answer" v-html="renderSqlThinking"></div>
</template>
<template v-if="message.record.chart_answer">
<el-divider></el-divider>
<div>Chart Generation:</div>
<div v-html="renderChartThinking"></div>
</template>
</div>
</el-collapse-item>
</el-collapse>
<div>
<template v-if="settings.type === 'sql'">
<div>
<div v-if="message.record.sql">
{{ message.record.sql }}
</div>
</div>
</template>
<template v-else-if="settings.type === 'chart'">
<div>
<div v-if="message.record.chart">
{{ chartObject }}
</div>
</div>
<div v-if="message.record.error" style="color: red">
{{ message.record.error }}
</div>
</template>
<template v-else>
<div>
<div v-if="dataObject.fields">
{{ dataObject.fields }}
{{ dataObject.data }}
</div>
</div>
</template>
</div>
</template>
</div>
</template>
<style scoped lang="less">
:deep(.ed-tabs__nav-scroll) {
display: flex;
justify-content: flex-end;
}
</style>
+21 -10
View File
@@ -9,19 +9,30 @@ defineProps<{
</script>
<template>
<div class="chat-block">
<slot>
<div v-html="msg?.content"></div>
</slot>
<div class="chat-block-outer">
<div class="chat-block">
<slot>
<div v-html="msg?.content"></div>
</slot>
</div>
<div class="chat-block-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<style scoped lang="less">
.chat-block {
border-radius: 2px;
background-color: white;
padding: 12px;
word-wrap: break-word;
white-space: pre-wrap;
.chat-block-outer {
.chat-block {
border-radius: 2px;
background-color: white;
padding: 12px;
word-wrap: break-word;
white-space: pre-wrap;
}
.chat-block-footer {
}
}
</style>
+4
View File
@@ -31,6 +31,9 @@ const _datasource = computed({
<el-avatar shape="square" v-if="msg.role === 'user'"/>
<ChatBlock v-if="!msg.isWelcome" :msg="msg" :class="{'row-full': msg.role === 'assistant'}">
<slot></slot>
<template #footer>
<slot name="footer"></slot>
</template>
</ChatBlock>
<WelcomeBlock v-else v-model="_datasource" :current-chat="currentChat" class="row-full"/>
</div>
@@ -51,6 +54,7 @@ const _datasource = computed({
.row-full {
flex: 1;
width: 0;
}
}
+111 -145
View File
@@ -1,24 +1,23 @@
<template>
<el-container class="chat-container">
{{ typedContent }} {{ isTypings }}
<el-aside class="chat-container-left">
<el-container class="chat-container-right-container">
<el-header class="chat-list-header">
<el-button type="primary" @click="createNewChat">
<el-icon>
<Plus />
<Plus/>
</el-icon>
New Conversation
</el-button>
</el-header>
<el-main class="chat-list">
<ChatList
:current-chat-id="currentChatId"
v-model:loading="loading"
:chat-list="chatList"
@chat-selected="onClickHistory"
@chat-deleted="onChatDeleted"
@chat-renamed="onChatRenamed"
:current-chat-id="currentChatId"
v-model:loading="loading"
:chat-list="chatList"
@chat-selected="onClickHistory"
@chat-deleted="onChatDeleted"
@chat-renamed="onChatRenamed"
/>
</el-main>
</el-container>
@@ -28,38 +27,15 @@
<el-scrollbar>
<template v-for="(message, _index) in computedMessages" :key="_index">
<ChatRow
:current-chat="currentChat"
v-model:datasource="currentChat.datasource"
:msg="message"
:current-chat="currentChat"
v-model:datasource="currentChat.datasource"
:msg="message"
>
<template v-if="message.role === 'assistant'">
<div v-if="message.isTyping">Thinking ...</div>
<template v-if="message.record">
<div>
<div v-if="message.record.sql_answer">
{{ message.record.sql_answer }}
</div>
<div v-if="message.record.sql">
{{ message.record.sql }}
</div>
</div>
<div>
<div v-if="message.record.data">
{{ message.record.data }}
</div>
</div>
<div>
<div v-if="message.record.chart_answer">
{{ message.record.chart_answer }}
</div>
<div v-if="message.record.chart">
{{ message.record.chart }}
</div>
</div>
<div v-if="message.record.error" style="color: red">
{{ message.record.error }}
</div>
</template>
<ChatAnswer :message="message"/>
</template>
<template #footer v-if="message.role === 'assistant'">
<!--<div>Suggestion</div>-->
</template>
</ChatRow>
</template>
@@ -68,32 +44,32 @@
<el-footer class="chat-footer">
<div style="height: 24px">
<template
v-if="currentChat.datasource && currentChat.datasource_name"
v-if="currentChat.datasource && currentChat.datasource_name"
>
使用数据源:{{ currentChat.datasource_name }}
</template>
</div>
<div class="input-wrapper">
<el-input
:disabled="isTyping"
class="input-area"
v-model="inputMessage"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
:disabled="isTyping"
class="input-area"
v-model="inputMessage"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
<el-button
link
type="primary"
class="input-icon"
@click="sendMessage"
:disabled="isTyping"
link
type="primary"
class="input-icon"
@click="sendMessage"
:disabled="isTyping"
>
<el-icon size="20">
<Position />
<Position/>
</el-icon>
</el-button>
</div>
@@ -103,8 +79,8 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import { Plus, Position } from "@element-plus/icons-vue";
import {computed, nextTick, onMounted, ref} from "vue";
import {Plus, Position} from "@element-plus/icons-vue";
import {
Chat,
chatApi,
@@ -115,7 +91,7 @@ import {
} from "@/api/chat";
import ChatList from "./ChatList.vue";
import ChatRow from "./ChatRow.vue";
import useTypedEffect from "./typed";
import ChatAnswer from "./ChatAnswer.vue";
const inputMessage = ref("");
@@ -155,8 +131,6 @@ const computedMessages = computed<Array<ChatMessage>>(() => {
});
}
console.log(messages);
return messages;
});
@@ -169,13 +143,13 @@ const createNewChat = () => {
function getChatList() {
loading.value = true;
chatApi
.list()
.then((res) => {
chatList.value = chatApi.toChatInfoList(res);
})
.finally(() => {
loading.value = false;
});
.list()
.then((res) => {
chatList.value = chatApi.toChatInfoList(res);
})
.finally(() => {
loading.value = false;
});
}
function onClickHistory(chat: Chat) {
@@ -184,17 +158,16 @@ function onClickHistory(chat: Chat) {
currentChatId.value = chat.id;
loading.value = true;
chatApi
.get(chat.id)
.then((res) => {
const info = chatApi.toChatInfo(res);
console.log(info);
if (info) {
currentChat.value = info;
}
})
.finally(() => {
loading.value = false;
});
.get(chat.id)
.then((res) => {
const info = chatApi.toChatInfo(res);
if (info) {
currentChat.value = info;
}
})
.finally(() => {
loading.value = false;
});
}
}
@@ -222,16 +195,6 @@ onMounted(() => {
getChatList();
});
const content = ref("");
const typingEnabled = ref(true);
const typingStep = ref(1);
const typingInterval = ref(50);
const [typedContent, isTypings] = useTypedEffect(
content,
typingEnabled,
typingStep,
typingInterval
);
const sendMessage = async () => {
if (!inputMessage.value.trim()) return;
@@ -255,29 +218,29 @@ const sendMessage = async () => {
let error = false;
if (currentChatId.value === undefined) {
await chatApi
.startChat({
question: currentRecord.question.trim(),
datasource: currentChat.value.datasource,
})
.then((res) => {
const chat = chatApi.toChatInfo(res);
if (chat !== undefined) {
chatList.value.unshift(chat);
currentChatId.value = chat.id;
chat.records.push(currentRecord);
currentChat.value = chat;
} else {
.startChat({
question: currentRecord.question.trim(),
datasource: currentChat.value.datasource,
})
.then((res) => {
const chat = chatApi.toChatInfo(res);
if (chat !== undefined) {
chatList.value.unshift(chat);
currentChatId.value = chat.id;
chat.records.push(currentRecord);
currentChat.value = chat;
} else {
error = true;
}
})
.catch((e) => {
isTyping.value = false;
error = true;
}
})
.catch((e) => {
isTyping.value = false;
error = true;
console.error(e);
})
.finally(() => {
loading.value = false;
});
console.error(e);
})
.finally(() => {
loading.value = false;
});
}
if (error) return;
@@ -289,8 +252,11 @@ const sendMessage = async () => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let sql_answer = ''
let chart_answer = ''
while (true) {
const { done, value } = await reader.read();
const {done, value} = await reader.read();
console.log(done);
if (done) {
isTyping.value = false;
@@ -301,36 +267,36 @@ const sendMessage = async () => {
console.log(chunk);
const data = JSON.parse(chunk);
await nextTick(() => {
switch (data.type) {
case "info":
console.log(data.msg);
break;
case "error":
currentRecord.error = data.content;
isTyping.value = false;
break;
case "sql-result":
currentRecord.sql_answer = currentRecord.sql_answer + data.content;
break;
case "sql":
currentRecord.sql = data.content;
break;
case "sql-data":
currentRecord.data = data.content;
break;
case "chart-result":
currentRecord.chart_answer =
currentRecord.chart_answer + data.content;
break;
case "chart":
currentRecord.chart = data.content;
break;
case "finish":
isTyping.value = false;
break;
}
});
switch (data.type) {
case "info":
console.log(data.msg);
break;
case "error":
currentRecord.error = data.content;
isTyping.value = false;
break;
case "sql-result":
sql_answer += data.content;
currentChat.value.records[currentChat.value.records.length - 1].sql_answer = sql_answer;
break;
case "sql":
currentChat.value.records[currentChat.value.records.length - 1].sql = data.content;
break;
case "sql-data":
currentChat.value.records[currentChat.value.records.length - 1].data = data.content;
break;
case "chart-result":
chart_answer += data.content;
currentChat.value.records[currentChat.value.records.length - 1].chart_answer = chart_answer;
break;
case "chart":
currentChat.value.records[currentChat.value.records.length - 1].chart = data.content;
break;
case "finish":
isTyping.value = false;
break;
}
await nextTick()
}
} catch (error) {
if (!currentRecord.error) {
@@ -349,11 +315,11 @@ const sendMessage = async () => {
const formatMessage = (content: string) => {
if (!content) return "";
return content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>")
.replace(/ {2}/g, "&nbsp;&nbsp;");
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>")
.replace(/ {2}/g, "&nbsp;&nbsp;");
};
const handleCtrlEnter = (e: KeyboardEvent) => {