feat: typed

This commit is contained in:
dataeaseShu
2025-06-05 10:41:16 +08:00
parent 21a5587e33
commit 663ad456f0
2 changed files with 302 additions and 182 deletions
+235 -182
View File
@@ -1,18 +1,25 @@
<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"/>
<ChatList
:current-chat-id="currentChatId"
v-model:loading="loading"
:chat-list="chatList"
@chat-selected="onClickHistory"
@chat-deleted="onChatDeleted"
@chat-renamed="onChatRenamed"
/>
</el-main>
</el-container>
</el-aside>
@@ -20,22 +27,38 @@
<el-main class="chat-record-list">
<el-scrollbar>
<template v-for="(message, _index) in computedMessages" :key="_index">
<ChatRow :current-chat="currentChat" v-model:datasource="currentChat.datasource" :msg="message">
<ChatRow
: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 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 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 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>
<div v-if="message.record.error" style="color: red">{{ message.record.error }}</div>
</template>
</template>
</ChatRow>
@@ -43,126 +66,143 @@
</el-scrollbar>
</el-main>
<el-footer class="chat-footer">
<div style="height: 24px;">
<template v-if="currentChat.datasource && currentChat.datasource_name">
<div style="height: 24px">
<template
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">
<el-button
link
type="primary"
class="input-icon"
@click="sendMessage"
:disabled="isTyping"
>
<el-icon size="20">
<Position/>
<Position />
</el-icon>
</el-button>
</div>
</el-footer>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import {computed, nextTick, onMounted, ref} from 'vue'
import {Plus, Position} from '@element-plus/icons-vue'
import {Chat, chatApi, ChatInfo, type ChatMessage, ChatRecord, questionApi} from '@/api/chat'
import ChatList from './ChatList.vue'
import ChatRow from './ChatRow.vue'
import { computed, nextTick, onMounted, ref } from "vue";
import { Plus, Position } from "@element-plus/icons-vue";
import {
Chat,
chatApi,
ChatInfo,
type ChatMessage,
ChatRecord,
questionApi,
} from "@/api/chat";
import ChatList from "./ChatList.vue";
import ChatRow from "./ChatRow.vue";
import useTypedEffect from "./typed";
const inputMessage = ref('')
const inputMessage = ref("");
const loading = ref<boolean>(false);
const chatList = ref<Array<ChatInfo>>([])
const chatList = ref<Array<ChatInfo>>([]);
const currentChatId = ref<number | undefined>()
const currentChat = ref<ChatInfo>(new ChatInfo())
const isTyping = ref<boolean>(false)
const currentChatId = ref<number | undefined>();
const currentChat = ref<ChatInfo>(new ChatInfo());
const isTyping = ref<boolean>(false);
const computedMessages = computed<Array<ChatMessage>>(() => {
const welcome: ChatMessage = {
role: 'assistant',
role: "assistant",
create_time: currentChat.value?.create_time,
content: currentChat.value?.datasource,
isTyping: false,
isWelcome: true,
}
const messages: Array<ChatMessage> = [welcome]
};
const messages: Array<ChatMessage> = [welcome];
if (currentChatId.value === undefined) {
return messages
return messages;
}
for (let i = 0; i < currentChat.value.records.length; i++) {
const record = currentChat.value.records[i]
const record = currentChat.value.records[i];
if (record.question !== undefined) {
messages.push({
role: 'user',
role: "user",
create_time: record.create_time,
content: record.question,
})
});
}
messages.push({
role: 'assistant',
role: "assistant",
create_time: record.create_time,
record: record,
isTyping: i === currentChat.value.records.length - 1 && isTyping.value
})
isTyping: i === currentChat.value.records.length - 1 && isTyping.value,
});
}
console.log(messages)
console.log(messages);
return messages
})
return messages;
});
const createNewChat = () => {
currentChat.value = new ChatInfo()
currentChatId.value = undefined
inputMessage.value = ''
}
currentChat.value = new ChatInfo();
currentChatId.value = undefined;
inputMessage.value = "";
};
function getChatList() {
loading.value = true
chatApi.list()
.then((res) => {
chatList.value = chatApi.toChatInfoList(res)
})
.finally(() => {
loading.value = false
})
loading.value = true;
chatApi
.list()
.then((res) => {
chatList.value = chatApi.toChatInfoList(res);
})
.finally(() => {
loading.value = false;
});
}
function onClickHistory(chat: Chat) {
currentChat.value = new ChatInfo(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)
console.log(info)
if (info) {
currentChat.value = info
}
})
.finally(() => {
loading.value = false
})
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;
});
}
}
function onChatDeleted(id: number) {
for (let i = 0; i < chatList.value.length; i++) {
if (chatList.value[i].id === id) {
chatList.value.splice(i, 1)
return
chatList.value.splice(i, 1);
return;
}
}
}
@@ -170,148 +210,164 @@ function onChatDeleted(id: number) {
function onChatRenamed(chat: Chat) {
chatList.value.forEach((c: Chat) => {
if (c.id === chat.id) {
c.brief = chat.brief
c.brief = chat.brief;
}
})
});
if (currentChat.value.id === chat.id) {
currentChat.value.brief = chat.brief
currentChat.value.brief = chat.brief;
}
}
onMounted(() => {
getChatList()
})
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
if (computedMessages.value[0].content === undefined) return
if (!inputMessage.value.trim()) return;
if (computedMessages.value[0].content === undefined) return;
loading.value = true
isTyping.value = true
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 = ''
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 = ''
currentChat.value.records.push(currentRecord);
inputMessage.value = "";
let error = false
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 {
error = true
}
})
.catch(e => {
isTyping.value = false
error = true
console.error(e)
})
.finally(() => {
loading.value = false
})
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 {
error = true;
}
})
.catch((e) => {
isTyping.value = false;
error = true;
console.error(e);
})
.finally(() => {
loading.value = false;
});
}
if (error) return
if (error) return;
try {
const response = await questionApi.add({question: currentRecord.question, chat_id: currentChatId.value})
const reader = response.body.getReader()
const decoder = new TextDecoder()
const response = await questionApi.add({
question: currentRecord.question,
chat_id: currentChatId.value,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {done, value} = await reader.read()
console.log(done)
const { done, value } = await reader.read();
console.log(done);
if (done) {
isTyping.value = false
break
isTyping.value = false;
break;
}
const chunk = decoder.decode(value)
console.log(chunk)
const data = JSON.parse(chunk)
const chunk = decoder.decode(value);
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
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;
}
})
});
}
} catch (error) {
if (!currentRecord.error) {
currentRecord.error = ''
currentRecord.error = "";
}
if (currentRecord.error.trim().length !== 0) {
currentRecord.error = currentRecord.error + '\n'
currentRecord.error = currentRecord.error + "\n";
}
currentRecord.error = currentRecord.error + 'Error:' + error
console.error('Error:', error)
isTyping.value = false
currentRecord.error = currentRecord.error + "Error:" + error;
console.error("Error:", error);
isTyping.value = false;
}
}
};
//@ts-ignore
const formatMessage = (content: string) => {
if (!content) return ''
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) => {
const textarea = e.target as HTMLTextAreaElement
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
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)
inputMessage.value = value.substring(0, start) + "\n" + value.substring(end);
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1
})
}
textarea.selectionStart = textarea.selectionEnd = start + 1;
});
};
</script>
<style lang="less" scoped>
@@ -319,7 +375,6 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
height: 100%;
.chat-container-left {
--el-aside-width: 260px;
border-right: solid 1px rgba(0, 0, 0, 0.3);
@@ -338,7 +393,6 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
.chat-list {
padding: 0 0 20px 0;
}
}
}
@@ -372,12 +426,11 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
bottom: 14px;
right: 8px;
}
}
}
.send-btn {
min-width: 0;
}
}
</style>
</style>
+67
View File
@@ -0,0 +1,67 @@
import { computed, onWatcherCleanup, unref, watch, ref } from 'vue';
import type { Ref, VNode } from 'vue'
function isString(str: any): str is string {
return typeof str === 'string';
}
function useState<T, R = Ref<T>>(
defaultStateValue?: T | (() => T),
): [R, (val: T) => void] {
const initValue: T =
typeof defaultStateValue === 'function' ? (defaultStateValue as any)() : defaultStateValue;
const innerValue = ref(initValue) as Ref<T>;
function triggerChange(newValue: T) {
innerValue.value = newValue;
}
return [innerValue as unknown as R, triggerChange];
}
/**
* Return typed content and typing status when typing is enabled.
* Or return content directly.
*/
const useTypedEffect = (
content: Ref<VNode | object | string>,
typingEnabled: Ref<boolean>,
typingStep: Ref<number>,
typingInterval: Ref<number>,
): [typedContent: Ref<VNode | object | string>, isTyping: Ref<boolean>] => {
const [prevContent, setPrevContent] = useState<VNode | object | string>('');
const [typingIndex, setTypingIndex] = useState<number>(1);
const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
// Reset typing index when content changed
watch(content, () => {
const prevContentValue = unref(prevContent);
setPrevContent(content.value);
if (!mergedTypingEnabled.value && isString(content.value)) {
setTypingIndex(content.value.length);
} else if (isString(content.value) && isString(prevContentValue) && content.value.indexOf(prevContentValue) !== 0) {
setTypingIndex(1);
}
});
// Start typing
watch([typingIndex, typingEnabled, content], () => {
if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) {
const id = setTimeout(() => {
setTypingIndex(unref(typingIndex) + typingStep.value);
}, typingInterval.value);
onWatcherCleanup(() => {
clearTimeout(id);
});
}
}, { immediate: true });
const mergedTypingContent = computed(() => mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value);
return [mergedTypingContent, computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length)];
};
export default useTypedEffect;