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 deps
This commit is contained in:
Redon
2023-02-20 14:10:51 +08:00
committed by GitHub
parent 6216d84ecd
commit fda6c6bb6a
35 changed files with 1101 additions and 691 deletions

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
interface Props {
image?: boolean
}
defineProps<Props>()
</script>
<template>
<img v-if="image" src="@/assets/avatar.jpg" class="object-cover w-full h-full " alt="avatar">
<span v-else class="text-[27px]">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true" width="1em" height="1em">
<path d="M29.71,13.09A8.09,8.09,0,0,0,20.34,2.68a8.08,8.08,0,0,0-13.7,2.9A8.08,8.08,0,0,0,2.3,18.9,8,8,0,0,0,3,25.45a8.08,8.08,0,0,0,8.69,3.87,8,8,0,0,0,6,2.68,8.09,8.09,0,0,0,7.7-5.61,8,8,0,0,0,5.33-3.86A8.09,8.09,0,0,0,29.71,13.09Zm-12,16.82a6,6,0,0,1-3.84-1.39l.19-.11,6.37-3.68a1,1,0,0,0,.53-.91v-9l2.69,1.56a.08.08,0,0,1,.05.07v7.44A6,6,0,0,1,17.68,29.91ZM4.8,24.41a6,6,0,0,1-.71-4l.19.11,6.37,3.68a1,1,0,0,0,1,0l7.79-4.49V22.8a.09.09,0,0,1,0,.08L13,26.6A6,6,0,0,1,4.8,24.41ZM3.12,10.53A6,6,0,0,1,6.28,7.9v7.57a1,1,0,0,0,.51.9l7.75,4.47L11.85,22.4a.14.14,0,0,1-.09,0L5.32,18.68a6,6,0,0,1-2.2-8.18Zm22.13,5.14-7.78-4.52L20.16,9.6a.08.08,0,0,1,.09,0l6.44,3.72a6,6,0,0,1-.9,10.81V16.56A1.06,1.06,0,0,0,25.25,15.67Zm2.68-4-.19-.12-6.36-3.7a1,1,0,0,0-1.05,0l-7.78,4.49V9.2a.09.09,0,0,1,0-.09L19,5.4a6,6,0,0,1,8.91,6.21ZM11.08,17.15,8.38,15.6a.14.14,0,0,1-.05-.08V8.1a6,6,0,0,1,9.84-4.61L18,3.6,11.61,7.28a1,1,0,0,0-.53.91ZM12.54,14,16,12l3.47,2v4L16,20l-3.47-2Z" />
</svg>
</span>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
import Message from './Message/index.vue'
export { Message }

View File

@@ -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,
}
}

View File

@@ -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,
}
}

292
src/views/chat/index.vue Normal file
View File

@@ -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>

View File

@@ -0,0 +1,44 @@
<script setup lang='ts'>
import { computed } from 'vue'
import { NLayout, NLayoutContent } from 'naive-ui'
import Sider from './sider/index.vue'
import Header from './header/index.vue'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const { isMobile } = useBasicLayout()
const collapsed = computed(() => appStore.siderCollapsed)
const getMobileClass = computed(() => {
if (isMobile.value)
return ['rounded-none', 'shadow-none']
return ['border', 'rounded-md', 'shadow-md']
})
const getContainerClass = computed(() => {
return [
'h-full',
{ 'pt-14': isMobile.value },
{ 'pl-[260px]': !isMobile.value && !collapsed.value },
]
})
</script>
<template>
<div class="h-screen" :class="[isMobile ? 'p-0' : 'p-4']">
<div class="h-full overflow-hidden" :class="getMobileClass">
<NLayout class="z-40 transition" :class="getContainerClass" has-sider>
<Sider />
<Header v-if="isMobile" />
<NLayoutContent class="h-full">
<RouterView v-slot="{ Component, route }">
<component :is="Component" :key="route.fullPath" />
</RouterView>
</NLayoutContent>
</NLayout>
</div>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { SvgIcon } from '@/components/common'
import { useAppStore, useChatStore } from '@/store'
const appStore = useAppStore()
const chatStore = useChatStore()
const collapsed = computed(() => appStore.siderCollapsed)
function handleAdd() {
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
}
function handleUpdateCollapsed() {
appStore.setSiderCollapsed(!collapsed.value)
}
</script>
<template>
<header class="fixed top-0 left-0 right-0 z-50 border-b bg-white/80 backdrop-blur">
<div class="relative flex items-center justify-between h-14">
<button class="flex items-center justify-center w-11 h-11" @click="handleUpdateCollapsed">
<SvgIcon v-if="collapsed" class="text-2xl" icon="ri:align-justify" />
<SvgIcon v-else class="text-2xl" icon="ri:align-right" />
</button>
<button class="flex items-center justify-center w-11 h-11" @click="handleAdd">
<SvgIcon class="text-2xl" icon="ri:add-fill" />
</button>
</div>
</header>
</template>

View File

@@ -0,0 +1,3 @@
import ChatLayout from './Layout.vue'
export { ChatLayout }

View File

@@ -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>

View File

@@ -0,0 +1,81 @@
<script setup lang='ts'>
import type { CSSProperties } from 'vue'
import { computed, watch } from 'vue'
import { NButton, NLayoutSider } from 'naive-ui'
import List from './List.vue'
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
import { useAppStore, useChatStore } from '@/store'
import { useBasicLayout } from '@/hooks/useBasicLayout'
const appStore = useAppStore()
const chatStore = useChatStore()
const { isMobile } = useBasicLayout()
const collapsed = computed(() => appStore.siderCollapsed)
function handleAdd() {
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
}
function handleUpdateCollapsed() {
appStore.setSiderCollapsed(!collapsed.value)
}
const getMobileClass = computed<CSSProperties>(() => {
if (isMobile.value) {
return {
position: 'fixed',
zIndex: 50,
}
}
return {}
})
watch(
isMobile,
(val) => {
appStore.setSiderCollapsed(val)
},
{
immediate: true,
flush: 'post',
},
)
</script>
<template>
<NLayoutSider
:collapsed="collapsed"
:collapsed-width="0"
:width="260"
:show-trigger="isMobile ? false : 'arrow-circle'"
collapse-mode="transform"
position="absolute"
bordered
:style="getMobileClass"
@update-collapsed="handleUpdateCollapsed"
>
<div class="flex flex-col h-full" :class="[{ 'pt-14': isMobile }]">
<main class="flex-1 min-h-0 overflow-hidden">
<div class="p-4">
<NButton dashed block @click="handleAdd">
New chat
</NButton>
</div>
<List />
</main>
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t">
<UserAvatar />
<HoverButton tooltip="Setting">
<span class="text-xl text-[#4f555e]">
<SvgIcon icon="ri:settings-4-line" />
</span>
</HoverButton>
</footer>
</div>
</NLayoutSider>
<template v-if="isMobile">
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
</template>
</template>

View File

@@ -1,7 +0,0 @@
<script setup lang='ts'>
import { Chat } from '@/components/business'
</script>
<template>
<Chat />
</template>