chore: v2.5.0 整体优化 (#70)
* feat: locale language * refactor: 页面暂存 * feat: 逻辑判断 * feat: 分组消息 * feat: 实验场 * feat: 重新请求结果 * feat: 基础问答逻辑和重新询问 * feat: 上下文消息删除确认 * feat: 处理类型报错 * chore: 更新 deps 和移除 i18n * feat: 路由页面切换终止请求 * feat: let me think * feat: 信息更新代码高亮匹配 * feat: 加载时添加光标 * feat: 错误提示 * feat: 历史记录删除确认 * fix: 侧边栏高度不正确的问题 * chore: version 2.5.0 * chore: update depsmain
parent
6216d84ecd
commit
fda6c6bb6a
@ -1,22 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
reversal?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 mt-2 rounded-md" :class="[reversal ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]']">
|
||||
<span v-highlight class="leading-relaxed whitespace-pre-wrap">
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.hljs {
|
||||
background-color: #fff0 !important;
|
||||
}
|
||||
</style>
|
@ -1,32 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import Avatar from './Avatar.vue'
|
||||
import Text from './Text.vue'
|
||||
|
||||
interface Props {
|
||||
message?: string
|
||||
dateTime?: string
|
||||
reversal?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full mb-6" :class="[{ 'flex-row-reverse': reversal }]">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full overflow-hidden w-[32px] h-[32px]"
|
||||
:class="[reversal ? 'ml-3' : 'mr-3']"
|
||||
>
|
||||
<Avatar :image="reversal" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 text-sm" :class="[reversal ? 'items-end' : 'items-start']">
|
||||
<span class="text-xs text-[#b4bbc4]">
|
||||
{{ dateTime }}
|
||||
</span>
|
||||
<Text :reversal="reversal" :error="error">
|
||||
{{ message }}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,26 +0,0 @@
|
||||
import { useHistoryStore } from '@/store'
|
||||
|
||||
export function useChat() {
|
||||
const historyStore = useHistoryStore()
|
||||
|
||||
function addChat(
|
||||
message: string,
|
||||
args?: { reversal?: boolean; error?: boolean; options?: Chat.ChatOptions },
|
||||
) {
|
||||
historyStore.addChat(
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
message,
|
||||
reversal: args?.reversal ?? false,
|
||||
error: args?.error ?? false,
|
||||
options: args?.options ?? undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
historyStore.clearChat()
|
||||
}
|
||||
|
||||
return { addChat, clearChat }
|
||||
}
|
@ -1,208 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { MessageReactive } from 'naive-ui'
|
||||
import { NButton, NInput, useMessage } from 'naive-ui'
|
||||
import { Message } from './components'
|
||||
import { Layout } from './layout'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import { fetchChatAPI } from '@/api'
|
||||
import { HoverButton, SvgIcon } from '@/components/common'
|
||||
import { useHistoryStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
|
||||
let controller = new AbortController()
|
||||
|
||||
const ms = useMessage()
|
||||
|
||||
const historyStore = useHistoryStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
let messageReactive: MessageReactive | null = null
|
||||
|
||||
const scrollRef = ref<HTMLDivElement>()
|
||||
|
||||
const { addChat, clearChat } = useChat()
|
||||
|
||||
const prompt = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const currentActive = computed(() => historyStore.active)
|
||||
const heartbeat = computed(() => historyStore.heartbeat)
|
||||
|
||||
const list = computed<Chat.Chat[]>(() => historyStore.getCurrentChat)
|
||||
const chatList = computed<Chat.Chat[]>(() => list.value.filter(item => (!item.reversal && !item.error)))
|
||||
|
||||
const footerMobileStyle = computed(() => {
|
||||
if (isMobile.value)
|
||||
return ['pl-2', 'pt-2', 'pb-6', 'fixed', 'bottom-0', 'left-0', 'right-0', 'z-30']
|
||||
return []
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
controller = new AbortController()
|
||||
|
||||
const message = prompt.value.trim()
|
||||
|
||||
if (!message || !message.length) {
|
||||
ms.warning('Please enter a message')
|
||||
return
|
||||
}
|
||||
|
||||
addMessage(message, { reversal: true })
|
||||
prompt.value = ''
|
||||
|
||||
let options: Chat.ChatOptions = {}
|
||||
const lastContext = chatList.value[chatList.value.length - 1]?.options
|
||||
|
||||
if (lastContext)
|
||||
options = { ...lastContext }
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
createMessage()
|
||||
const { data } = await fetchChatAPI(message, options, controller.signal)
|
||||
addMessage(data?.text ?? '', { options: { conversationId: data.conversationId, parentMessageId: data.id } })
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error.message !== 'canceled')
|
||||
addMessage(`${error.message ?? 'Request failed, please try again later.'}`, { error: true })
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
removeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(
|
||||
message: string,
|
||||
args?: { reversal?: boolean; error?: boolean; options?: Chat.ChatOptions },
|
||||
) {
|
||||
addChat(message, args)
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => scrollRef.value && (scrollRef.value.scrollTop = scrollRef.value.scrollHeight))
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
handleCancel()
|
||||
clearChat()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
controller.abort()
|
||||
controller = new AbortController()
|
||||
loading.value = false
|
||||
removeMessage()
|
||||
}
|
||||
|
||||
function createMessage() {
|
||||
if (!messageReactive) {
|
||||
messageReactive = ms.loading('Thinking...', {
|
||||
duration: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function removeMessage() {
|
||||
if (messageReactive) {
|
||||
messageReactive.destroy()
|
||||
messageReactive = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
handleCancel()
|
||||
})
|
||||
|
||||
watch(
|
||||
heartbeat,
|
||||
() => {
|
||||
handleCancel()
|
||||
scrollToBottom()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
currentActive,
|
||||
(_, oldActive) => {
|
||||
if (oldActive !== null) {
|
||||
handleCancel()
|
||||
scrollToBottom()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="flex flex-col h-full">
|
||||
<main class="flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="h-full p-4 overflow-hidden overflow-y-auto"
|
||||
:class="[{ 'p-2': isMobile }]"
|
||||
>
|
||||
<template v-if="!list.length">
|
||||
<div class="flex items-center justify-center mt-4 text-center text-neutral-300">
|
||||
<SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
|
||||
<span>Aha~</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Message
|
||||
v-for="(item, index) of list"
|
||||
:key="index"
|
||||
:date-time="item.dateTime"
|
||||
:message="item.message"
|
||||
:reversal="item.reversal"
|
||||
:error="item.error"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
class="p-4"
|
||||
:class="footerMobileStyle"
|
||||
>
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<HoverButton tooltip="Clear conversations">
|
||||
<span class="text-xl text-[#4f555e]" @click="handleClear">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<NInput
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 2 }"
|
||||
placeholder="Ask me anything..."
|
||||
@keypress="handleEnter"
|
||||
/>
|
||||
<NButton type="primary" :disabled="loading" @click="handleSubmit">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:send-plane-fill" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Layout>
|
||||
</template>
|
@ -1,3 +0,0 @@
|
||||
import Layout from './Layout.vue'
|
||||
|
||||
export { Layout }
|
@ -1,79 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import { ref } from 'vue'
|
||||
import { NInput, NScrollbar } from 'naive-ui'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useHistoryStore } from '@/store'
|
||||
|
||||
const historyStore = useHistoryStore()
|
||||
|
||||
const dataSources = ref(historyStore.historyChat)
|
||||
|
||||
function handleSelect(index: number) {
|
||||
historyStore.chooseHistory(index)
|
||||
}
|
||||
|
||||
function handleEdit(index: number, isEdit: boolean, event?: MouseEvent) {
|
||||
historyStore.editHistory(index, isEdit)
|
||||
event?.stopPropagation()
|
||||
}
|
||||
|
||||
function handleRemove(index: number, event?: MouseEvent) {
|
||||
historyStore.removeHistory(index)
|
||||
event?.stopPropagation()
|
||||
}
|
||||
|
||||
function handleEnter(index: number, isEdit: boolean, event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleEdit(index, isEdit)
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NScrollbar class="px-4">
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<template v-if="!dataSources.length">
|
||||
<div class="flex flex-col items-center mt-4 text-center text-neutral-300">
|
||||
<SvgIcon icon="ri:inbox-line" class="mb-2 text-3xl" />
|
||||
<span>No history</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(item, index) of dataSources" :key="index">
|
||||
<a
|
||||
class="relative flex items-center gap-3 px-3 py-3 break-all border rounded-md cursor-pointer hover:bg-neutral-100 group"
|
||||
:class="historyStore.active === index && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'pr-14']"
|
||||
@click="handleSelect(index)"
|
||||
>
|
||||
<span>
|
||||
<SvgIcon icon="ri:message-3-line" />
|
||||
</span>
|
||||
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap">
|
||||
<NInput
|
||||
v-if="item.isEdit" v-model:value="item.title" size="tiny"
|
||||
@keypress="handleEnter(index, false, $event)"
|
||||
/>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</div>
|
||||
<div v-if="historyStore.active === index" class="absolute z-10 flex visible right-1">
|
||||
<template v-if="item.isEdit">
|
||||
<button class="p-1" @click="handleEdit(index, false, $event)">
|
||||
<SvgIcon icon="ri:save-line" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="p-1">
|
||||
<SvgIcon icon="ri:edit-line" @click="handleEdit(index, true, $event)" />
|
||||
</button>
|
||||
<button class="p-1" @click="handleRemove(index, $event)">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
@ -1,3 +1 @@
|
||||
import Chat from './Chat/index.vue'
|
||||
|
||||
export { Chat }
|
||||
export { }
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { ss } from '@/utils/storage'
|
||||
|
||||
const LOCAL_NAME = 'chatStorage'
|
||||
|
||||
export function defaultState(): Chat.ChatState {
|
||||
const uuid = Date.now()
|
||||
return { active: uuid, history: [{ uuid, title: 'New Chat', isEdit: false }], chat: [{ uuid, data: [] }] }
|
||||
}
|
||||
|
||||
export function getLocalState(): Chat.ChatState {
|
||||
const localState = ss.get(LOCAL_NAME)
|
||||
return localState ?? defaultState()
|
||||
}
|
||||
|
||||
export function setLocalState(state: Chat.ChatState) {
|
||||
ss.set(LOCAL_NAME, state)
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getLocalState, setLocalState } from './helper'
|
||||
import { router } from '@/router'
|
||||
|
||||
export const useChatStore = defineStore('chat-store', {
|
||||
state: (): Chat.ChatState => getLocalState(),
|
||||
|
||||
getters: {
|
||||
getChatByUuid(state: Chat.ChatState) {
|
||||
return (uuid?: number) => {
|
||||
if (uuid)
|
||||
return state.chat.find(item => item.uuid === uuid)?.data ?? []
|
||||
return state.chat.find(item => item.uuid === state.active)?.data ?? []
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
addHistory(history: Chat.History, chatData: Chat.Chat[] = []) {
|
||||
this.history.push(history)
|
||||
this.chat.push({ uuid: history.uuid, data: chatData })
|
||||
this.active = history.uuid
|
||||
this.reloadRoute(history.uuid)
|
||||
},
|
||||
|
||||
updateHistory(uuid: number, edit: Partial<Chat.History>) {
|
||||
const index = this.history.findIndex(item => item.uuid === uuid)
|
||||
if (index !== -1) {
|
||||
this.history[index] = { ...this.history[index], ...edit }
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
async deleteHistory(index: number) {
|
||||
this.history.splice(index, 1)
|
||||
this.chat.splice(index, 1)
|
||||
|
||||
if (this.history.length === 0) {
|
||||
this.active = null
|
||||
this.reloadRoute()
|
||||
return
|
||||
}
|
||||
|
||||
if (index > 0 && index <= this.history.length) {
|
||||
const uuid = this.history[index - 1].uuid
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
return
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
if (this.history.length > 0) {
|
||||
const uuid = this.history[0].uuid
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
if (index > this.history.length) {
|
||||
const uuid = this.history[this.history.length - 1].uuid
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
}
|
||||
},
|
||||
|
||||
setActive(uuid: number) {
|
||||
this.active = uuid
|
||||
this.reloadRoute(uuid)
|
||||
},
|
||||
|
||||
addChatByUuid(uuid: number, chat: Chat.Chat) {
|
||||
if (!uuid || uuid === 0) {
|
||||
if (this.history.length === 0) {
|
||||
const uuid = Date.now()
|
||||
this.history.push({ uuid, title: chat.text, isEdit: false })
|
||||
this.chat.push({ uuid, data: [chat] })
|
||||
this.active = uuid
|
||||
this.recordState()
|
||||
}
|
||||
else {
|
||||
this.chat[0].data.push(chat)
|
||||
if (this.history[0].title === 'New Chat')
|
||||
this.history[0].title = chat.text
|
||||
this.recordState()
|
||||
}
|
||||
}
|
||||
|
||||
const index = this.chat.findIndex(item => item.uuid === uuid)
|
||||
if (index !== -1) {
|
||||
this.chat[index].data.push(chat)
|
||||
if (this.history[index].title === 'New Chat')
|
||||
this.history[index].title = chat.text
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
updateChatByUuid(uuid: number, index: number, chat: Chat.Chat) {
|
||||
if (!uuid || uuid === 0) {
|
||||
if (this.chat.length) {
|
||||
this.chat[0].data[index] = chat
|
||||
this.recordState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const chatIndex = this.chat.findIndex(item => item.uuid === uuid)
|
||||
if (chatIndex !== -1) {
|
||||
this.chat[chatIndex].data[index] = chat
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
clearChatByUuid(uuid: number) {
|
||||
if (!uuid || uuid === 0) {
|
||||
if (this.chat.length) {
|
||||
this.chat[0].data = []
|
||||
this.recordState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const index = this.chat.findIndex(item => item.uuid === uuid)
|
||||
if (index !== -1) {
|
||||
this.chat[index].data = []
|
||||
this.recordState()
|
||||
}
|
||||
},
|
||||
|
||||
async reloadRoute(uuid?: number) {
|
||||
this.recordState()
|
||||
await router.push({ name: 'Chat', params: { uuid } })
|
||||
},
|
||||
|
||||
recordState() {
|
||||
setLocalState(this.$state)
|
||||
},
|
||||
},
|
||||
})
|
@ -1,22 +0,0 @@
|
||||
import { ss } from '@/utils/storage'
|
||||
|
||||
const LOCAL_NAME = 'historyChat'
|
||||
|
||||
export interface HistoryState {
|
||||
historyChat: Chat.HistoryChat[]
|
||||
active: number | null
|
||||
heartbeat: boolean
|
||||
}
|
||||
|
||||
export function defaultSetting() {
|
||||
return { historyChat: [], active: null, heartbeat: false }
|
||||
}
|
||||
|
||||
export function getLocalHistory() {
|
||||
const localSetting: HistoryState | undefined = ss.get(LOCAL_NAME)
|
||||
return localSetting ?? defaultSetting()
|
||||
}
|
||||
|
||||
export function setLocalHistory(data: HistoryState) {
|
||||
ss.set(LOCAL_NAME, data)
|
||||
}
|
@ -1,83 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { HistoryState } from './helper'
|
||||
import { getLocalHistory, setLocalHistory } from './helper'
|
||||
|
||||
export const useHistoryStore = defineStore('history-store', {
|
||||
state: (): HistoryState => getLocalHistory(),
|
||||
getters: {
|
||||
getCurrentHistory(state): Chat.HistoryChat {
|
||||
if (state.historyChat.length) {
|
||||
if (state.active === null || state.active >= state.historyChat.length || state.active < 0)
|
||||
state.active = 0
|
||||
return state.historyChat[state.active] ?? { title: '', isEdit: false, data: [] }
|
||||
}
|
||||
state.active = null
|
||||
return { title: '', isEdit: false, data: [] }
|
||||
},
|
||||
getCurrentChat(): Chat.Chat[] {
|
||||
return this.getCurrentHistory.data ?? []
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
addChat(data: Chat.Chat) {
|
||||
if (this.active === null) {
|
||||
this.historyChat.push({ title: data.message, isEdit: false, data: [data] })
|
||||
this.active = this.historyChat.length - 1
|
||||
}
|
||||
else {
|
||||
if (this.historyChat[this.active].title === 'New Chat')
|
||||
this.historyChat[this.active].title = data.message
|
||||
|
||||
this.historyChat[this.active].data.push(data)
|
||||
}
|
||||
setLocalHistory(this.$state)
|
||||
},
|
||||
|
||||
clearChat() {
|
||||
if (this.active !== null) {
|
||||
this.historyChat[this.active].data = []
|
||||
setLocalHistory(this.$state)
|
||||
}
|
||||
},
|
||||
|
||||
addHistory(data: Chat.HistoryChat) {
|
||||
this.historyChat.push(data)
|
||||
this.active = this.historyChat.length - 1
|
||||
setLocalHistory(this.$state)
|
||||
},
|
||||
|
||||
editHistory(index: number, isEdit: boolean) {
|
||||
this.historyChat[index].isEdit = isEdit
|
||||
setLocalHistory(this.$state)
|
||||
},
|
||||
|
||||
removeHistory(index: number) {
|
||||
this.historyChat.splice(index, 1)
|
||||
|
||||
if (this.active === index) {
|
||||
if (this.historyChat.length === 0)
|
||||
this.active = null
|
||||
else if (this.active === this.historyChat.length)
|
||||
this.active = this.historyChat.length - 1
|
||||
}
|
||||
|
||||
if (this.historyChat.length === 0)
|
||||
this.active = null
|
||||
|
||||
this.toggleHeartbeat()
|
||||
|
||||
setLocalHistory(this.$state)
|
||||
},
|
||||
|
||||
chooseHistory(index: number) {
|
||||
if (this.active === index)
|
||||
return
|
||||
this.active = index
|
||||
setLocalHistory(this.$state)
|
||||
},
|
||||
|
||||
toggleHeartbeat() {
|
||||
this.heartbeat = !this.heartbeat
|
||||
},
|
||||
},
|
||||
})
|
@ -1,2 +1,2 @@
|
||||
export * from './app'
|
||||
export * from './history'
|
||||
export * from './chat'
|
||||
|
@ -1,20 +1,45 @@
|
||||
declare namespace Chat {
|
||||
interface ChatOptions {
|
||||
conversationId?: string
|
||||
parentMessageId?: string
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
dateTime: string
|
||||
message: string
|
||||
reversal?: boolean
|
||||
text: string
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
options?: ChatOptions
|
||||
loading?: boolean
|
||||
conversationOptions?: ConversationRequest | null
|
||||
requestOptions: { prompt: string; options?: ConversationRequest | null }
|
||||
}
|
||||
|
||||
interface HistoryChat {
|
||||
interface History {
|
||||
title: string
|
||||
isEdit: boolean
|
||||
data: Chat[]
|
||||
uuid: number
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
active: number | null
|
||||
history: History[]
|
||||
chat: { uuid: number; data: Chat[] }[]
|
||||
}
|
||||
|
||||
interface ConversationRequest {
|
||||
conversationId?: string
|
||||
parentMessageId?: string
|
||||
}
|
||||
|
||||
interface ConversationResponse {
|
||||
conversationId: string
|
||||
detail: {
|
||||
choices: { finish_reason: string; index: number; logprobs: any; text: string }[]
|
||||
created: number
|
||||
id: string
|
||||
model: string
|
||||
object: string
|
||||
usage: { completion_tokens: number; prompt_tokens: number; total_tokens: number }
|
||||
}
|
||||
id: string
|
||||
parentMessageId: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-[20px] p-2 rounded-md"
|
||||
:class="[inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]', { 'text-red-500': error }]"
|
||||
>
|
||||
<span
|
||||
v-highlight
|
||||
class="leading-relaxed whitespace-pre-wrap"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.hljs {
|
||||
background-color: #fff0 !important;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,54 @@
|
||||
<script setup lang='ts'>
|
||||
import Avatar from './Avatar.vue'
|
||||
import Text from './Text.vue'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
|
||||
interface Props {
|
||||
dateTime?: string
|
||||
text?: string
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(ev: 'regenerate'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
function handleRegenerate() {
|
||||
emit('regenerate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full mb-6" :class="[{ 'flex-row-reverse': inversion }]">
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full overflow-hidden w-[32px] h-[32px]"
|
||||
:class="[inversion ? 'ml-3' : 'mr-3']"
|
||||
>
|
||||
<Avatar :image="inversion" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 text-sm" :class="[inversion ? 'items-end' : 'items-start']">
|
||||
<span class="text-xs text-[#b4bbc4]">
|
||||
{{ dateTime }}
|
||||
</span>
|
||||
<div class="flex items-end mt-2">
|
||||
<Text :inversion="inversion" :error="error">
|
||||
<span v-if="loading" class="w-[3px] h-[20px] block animate-blink" />
|
||||
<span v-else>{{ text }}</span>
|
||||
</Text>
|
||||
<button
|
||||
v-if="!inversion && !loading"
|
||||
class="mb-2 ml-2 transition text-neutral-400 hover:text-neutral-800"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<SvgIcon icon="ri:restart-line" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,18 @@
|
||||
import { useChatStore } from '@/store'
|
||||
|
||||
export function useChat() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const addChat = (uuid: number, chat: Chat.Chat) => {
|
||||
chatStore.addChatByUuid(uuid, chat)
|
||||
}
|
||||
|
||||
const updateChat = (uuid: number, index: number, chat: Chat.Chat) => {
|
||||
chatStore.updateChatByUuid(uuid, index, chat)
|
||||
}
|
||||
|
||||
return {
|
||||
addChat,
|
||||
updateChat,
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
type ScrollElement = HTMLDivElement | null
|
||||
|
||||
interface ScrollReturn {
|
||||
scrollRef: Ref<ScrollElement>
|
||||
scrollToBottom: () => Promise<void>
|
||||
scrollToTop: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useScroll(): ScrollReturn {
|
||||
const scrollRef = ref<ScrollElement>(null)
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (scrollRef.value)
|
||||
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
|
||||
}
|
||||
|
||||
const scrollToTop = async () => {
|
||||
await nextTick()
|
||||
if (scrollRef.value)
|
||||
scrollRef.value.scrollTop = 0
|
||||
}
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
}
|
||||
}
|
@ -0,0 +1,292 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NButton, NInput, useDialog } from 'naive-ui'
|
||||
import { Message } from './components'
|
||||
import { useScroll } from './hooks/useScroll'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import { HoverButton, SvgIcon } from '@/components/common'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { useChatStore } from '@/store'
|
||||
import { fetchChatAPI } from '@/api'
|
||||
|
||||
let controller = new AbortController()
|
||||
|
||||
const route = useRoute()
|
||||
const dialog = useDialog()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
const { addChat, updateChat } = useChat()
|
||||
const { scrollRef, scrollToBottom } = useScroll()
|
||||
|
||||
const { uuid } = route.params as { uuid: string }
|
||||
|
||||
const dataSources = computed(() => chatStore.getChatByUuid(+uuid))
|
||||
const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !item.error)))
|
||||
|
||||
const prompt = ref<string>('')
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
function handleSubmit() {
|
||||
onConversation()
|
||||
}
|
||||
|
||||
async function onConversation() {
|
||||
const message = prompt.value
|
||||
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
if (!message || message.trim() === '')
|
||||
return
|
||||
|
||||
controller = new AbortController()
|
||||
|
||||
addChat(
|
||||
+uuid,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: message,
|
||||
inversion: true,
|
||||
error: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: null },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
loading.value = true
|
||||
prompt.value = ''
|
||||
|
||||
let options: Chat.ConversationRequest = {}
|
||||
const lastContext = conversationList.value[conversationList.value.length - 1]?.conversationOptions
|
||||
|
||||
if (lastContext)
|
||||
options = { ...lastContext }
|
||||
|
||||
addChat(
|
||||
+uuid,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: 'Aha, Thinking...',
|
||||
loading: true,
|
||||
inversion: false,
|
||||
error: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const { data } = await fetchChatAPI<Chat.ConversationResponse>(message, options, controller.signal)
|
||||
updateChat(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: data.text ?? '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
catch (error: any) {
|
||||
let errorMessage = error?.message ?? 'Something went wrong, please try again later.'
|
||||
|
||||
if (error.message === 'canceled')
|
||||
errorMessage = 'Request canceled. Please try again.'
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: errorMessage,
|
||||
inversion: false,
|
||||
error: true,
|
||||
loading: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate(index: number) {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
controller = new AbortController()
|
||||
|
||||
const { requestOptions } = dataSources.value[index]
|
||||
|
||||
const message = requestOptions?.prompt ?? ''
|
||||
|
||||
let options: Chat.ConversationRequest = {}
|
||||
|
||||
if (requestOptions.options)
|
||||
options = { ...requestOptions.options }
|
||||
|
||||
loading.value = true
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: 'Aha, Let me think again...',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: true,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const { data } = await fetchChatAPI<Chat.ConversationResponse>(message, options, controller.signal)
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: data.text ?? '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
catch (error: any) {
|
||||
let errorMessage = 'Something went wrong, please try again later.'
|
||||
|
||||
if (error.message === 'canceled')
|
||||
errorMessage = 'Request canceled. Please try again.'
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: errorMessage,
|
||||
inversion: false,
|
||||
error: true,
|
||||
loading: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
dialog.warning({
|
||||
title: 'Clear Chat',
|
||||
content: 'Are you sure to clear this chat?',
|
||||
positiveText: 'Yes',
|
||||
negativeText: 'No',
|
||||
onPositiveClick: () => {
|
||||
chatStore.clearChatByUuid(+uuid)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const buttonDisabled = computed(() => {
|
||||
return loading.value || !prompt.value || prompt.value.trim() === ''
|
||||
})
|
||||
|
||||
const footerClass = computed(() => {
|
||||
let classes = ['p-4']
|
||||
if (isMobile.value)
|
||||
classes = [...classes, 'pl-2', 'pt-2', 'pb-2', 'fixed', 'bottom-0', 'left-0', 'right-0', 'z-30']
|
||||
return classes
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loading.value)
|
||||
controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<main class="flex-1 overflow-hidden">
|
||||
<div ref="scrollRef" class="h-full p-4 overflow-hidden overflow-y-auto" :class="[{ 'p-2': isMobile }]">
|
||||
<template v-if="!dataSources.length">
|
||||
<div class="flex items-center justify-center mt-4 text-center text-neutral-300">
|
||||
<SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
|
||||
<span>Aha~</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Message
|
||||
v-for="(item, index) of dataSources"
|
||||
:key="index"
|
||||
:date-time="item.dateTime"
|
||||
:text="item.text"
|
||||
:inversion="item.inversion"
|
||||
:error="item.error"
|
||||
:loading="item.loading"
|
||||
@regenerate="onRegenerate(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
<footer :class="footerClass">
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<HoverButton @click="handleClear">
|
||||
<span class="text-xl text-[#4f555e]">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<NInput
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 2 }"
|
||||
placeholder="Ask me anything..."
|
||||
@keypress="handleEnter"
|
||||
/>
|
||||
<NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:send-plane-fill" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
@ -1,19 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useHistoryStore } from '@/store'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const historyStore = useHistoryStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
function handleAdd() {
|
||||
historyStore.addHistory({
|
||||
title: 'New Chat',
|
||||
isEdit: false,
|
||||
data: [],
|
||||
})
|
||||
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
|
||||
}
|
||||
|
||||
function handleUpdateCollapsed() {
|
@ -0,0 +1,3 @@
|
||||
import ChatLayout from './Layout.vue'
|
||||
|
||||
export { ChatLayout }
|
@ -0,0 +1,92 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import { NInput, NPopconfirm, NScrollbar } from 'naive-ui'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useChatStore } from '@/store'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const dataSources = computed(() => chatStore.history)
|
||||
|
||||
async function handleSelect({ uuid }: Chat.History) {
|
||||
if (isActive(uuid))
|
||||
return
|
||||
|
||||
chatStore.setActive(uuid)
|
||||
}
|
||||
|
||||
function handleEdit({ uuid }: Chat.History, isEdit: boolean, event?: MouseEvent) {
|
||||
event?.stopPropagation()
|
||||
chatStore.updateHistory(uuid, { isEdit })
|
||||
}
|
||||
|
||||
function handleDelete(index: number, event?: MouseEvent | TouchEvent) {
|
||||
event?.stopPropagation()
|
||||
chatStore.deleteHistory(index)
|
||||
}
|
||||
|
||||
function handleEnter({ uuid }: Chat.History, isEdit: boolean, event: KeyboardEvent) {
|
||||
event?.stopPropagation()
|
||||
if (event.key === 'Enter')
|
||||
chatStore.updateHistory(uuid, { isEdit })
|
||||
}
|
||||
|
||||
function isActive(uuid: number) {
|
||||
return chatStore.active === uuid
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NScrollbar class="px-4">
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<template v-if="!dataSources.length">
|
||||
<div class="flex flex-col items-center mt-4 text-center text-neutral-300">
|
||||
<SvgIcon icon="ri:inbox-line" class="mb-2 text-3xl" />
|
||||
<span>No history</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(item, index) of dataSources" :key="index">
|
||||
<a
|
||||
class="relative flex items-center gap-3 px-3 py-3 break-all border rounded-md cursor-pointer hover:bg-neutral-100 group"
|
||||
:class="isActive(item.uuid) && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'pr-14']"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<span>
|
||||
<SvgIcon icon="ri:message-3-line" />
|
||||
</span>
|
||||
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap">
|
||||
<NInput
|
||||
v-if="item.isEdit"
|
||||
v-model:value="item.title"
|
||||
size="tiny"
|
||||
@keypress="handleEnter(item, false, $event)"
|
||||
/>
|
||||
<span v-else>{{ item.title }}</span>
|
||||
</div>
|
||||
<div v-if="isActive(item.uuid)" class="absolute z-10 flex visible right-1">
|
||||
<template v-if="item.isEdit">
|
||||
<button class="p-1" @click="handleEdit(item, false, $event)">
|
||||
<SvgIcon icon="ri:save-line" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="p-1">
|
||||
<SvgIcon icon="ri:edit-line" @click="handleEdit(item, true, $event)" />
|
||||
</button>
|
||||
<NPopconfirm placement="bottom" @positive-click="handleDelete(index, $event)">
|
||||
<template #trigger>
|
||||
<button class="p-1">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</button>
|
||||
</template>
|
||||
Are you sure to clear this history?
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
@ -1,7 +0,0 @@
|
||||
<script setup lang='ts'>
|
||||
import { Chat } from '@/components/business'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Chat />
|
||||
</template>
|
Loading…
Reference in New Issue