frontend 兼容
This commit is contained in:
78
ai-chat-web/src/views/chat/components/Header/index.vue
Normal file
78
ai-chat-web/src/views/chat/components/Header/index.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { HoverButton, SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
|
||||
interface Props {
|
||||
usingContext: boolean
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(ev: 'export'): void
|
||||
(ev: 'toggleUsingContext'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
const currentChatHistory = computed(() => chatStore.getChatHistoryByCurrentActive)
|
||||
|
||||
function handleUpdateCollapsed() {
|
||||
appStore.setSiderCollapsed(!collapsed.value)
|
||||
}
|
||||
|
||||
function onScrollToTop() {
|
||||
const scrollRef = document.querySelector('#scrollRef')
|
||||
if (scrollRef)
|
||||
nextTick(() => scrollRef.scrollTop = 0)
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
emit('export')
|
||||
}
|
||||
|
||||
function toggleUsingContext() {
|
||||
emit('toggleUsingContext')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 left-0 right-0 z-30 border-b dark:border-neutral-800 bg-white/80 dark:bg-black/20 backdrop-blur"
|
||||
>
|
||||
<div class="relative flex items-center justify-between min-w-0 overflow-hidden h-14">
|
||||
<div class="flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
<h1
|
||||
class="flex-1 px-4 pr-6 overflow-hidden cursor-pointer select-none text-ellipsis whitespace-nowrap"
|
||||
@dblclick="onScrollToTop"
|
||||
>
|
||||
{{ currentChatHistory?.title ?? '' }}
|
||||
</h1>
|
||||
<div class="flex items-center space-x-2">
|
||||
<HoverButton @click="toggleUsingContext">
|
||||
<span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
|
||||
<SvgIcon icon="ri:chat-history-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<HoverButton @click="handleExport">
|
||||
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||
<SvgIcon icon="ri:download-2-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
26
ai-chat-web/src/views/chat/components/Message/Avatar.vue
Normal file
26
ai-chat-web/src/views/chat/components/Message/Avatar.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { NAvatar } from 'naive-ui'
|
||||
import { useUserStore } from '@/store'
|
||||
import { isString } from '@/utils/is'
|
||||
import defaultAvatar from '@/assets/avatar.jpg'
|
||||
|
||||
interface Props {
|
||||
image?: boolean
|
||||
}
|
||||
defineProps<Props>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const avatar = computed(() => userStore.userInfo.avatar)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="image">
|
||||
<NAvatar v-if="isString(avatar) && avatar.length > 0" :src="avatar" :fallback-src="defaultAvatar" />
|
||||
<NAvatar v-else round :src="defaultAvatar" />
|
||||
</template>
|
||||
<span v-else class="n-avatar text-[28px] dark:text-white">
|
||||
<img src="/src/assets/0voice-avatar.jpg" />
|
||||
</span>
|
||||
</template>
|
||||
86
ai-chat-web/src/views/chat/components/Message/Text.vue
Normal file
86
ai-chat-web/src/views/chat/components/Message/Text.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdKatex from '@traptitech/markdown-it-katex'
|
||||
import mila from 'markdown-it-link-attributes'
|
||||
import hljs from 'highlight.js'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { t } from '@/locales'
|
||||
|
||||
interface Props {
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
text?: string
|
||||
loading?: boolean
|
||||
asRawText?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const textRef = ref<HTMLElement>()
|
||||
|
||||
const mdi = new MarkdownIt({
|
||||
linkify: true,
|
||||
highlight(code, language) {
|
||||
const validLang = !!(language && hljs.getLanguage(language))
|
||||
if (validLang) {
|
||||
const lang = language ?? ''
|
||||
return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
|
||||
}
|
||||
return highlightBlock(hljs.highlightAuto(code).value, '')
|
||||
},
|
||||
})
|
||||
|
||||
mdi.use(mila, { attrs: { target: '_blank', rel: 'noopener' } })
|
||||
mdi.use(mdKatex, { blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000' })
|
||||
|
||||
const wrapClass = computed(() => {
|
||||
return [
|
||||
'text-wrap',
|
||||
'min-w-[20px]',
|
||||
'rounded-md',
|
||||
isMobile.value ? 'p-2' : 'px-3 py-2',
|
||||
props.inversion ? 'bg-[#d2f9d1]' : 'bg-[#f4f6f8]',
|
||||
props.inversion ? 'dark:bg-[#a1dc95]' : 'dark:bg-[#1e1e20]',
|
||||
props.inversion ? 'message-request' : 'message-reply',
|
||||
{ 'text-red-500': props.error },
|
||||
]
|
||||
})
|
||||
|
||||
const text = computed(() => {
|
||||
const value = props.text ?? ''
|
||||
if (!props.asRawText)
|
||||
return mdi.render(value)
|
||||
return value
|
||||
})
|
||||
|
||||
function highlightBlock(str: string, lang?: string) {
|
||||
return `<pre class="code-block-wrapper"><div class="code-block-header"><span class="code-block-header__lang">${lang}</span><span class="code-block-header__copy">${t('chat.copyCode')}</span></div><code class="hljs code-block-body ${lang}">${str}</code></pre>`
|
||||
}
|
||||
|
||||
defineExpose({ textRef })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-black" :class="wrapClass">
|
||||
<template v-if="loading">
|
||||
<span class="dark:text-white w-[4px] h-[20px] block animate-blink" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div ref="textRef" class="leading-relaxed break-words">
|
||||
<div v-if="!inversion">
|
||||
<div v-if="!asRawText" class="markdown-body" v-html="text" />
|
||||
<div v-else class="whitespace-pre-wrap" v-text="text" />
|
||||
<p style="color:#6e7781;font-size:12px;">由零声教学AI助手生成</p>
|
||||
</div>
|
||||
<div v-else class="whitespace-pre-wrap" v-text="text" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import url(./style.less);
|
||||
</style>
|
||||
133
ai-chat-web/src/views/chat/components/Message/index.vue
Normal file
133
ai-chat-web/src/views/chat/components/Message/index.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref } from 'vue'
|
||||
import { NDropdown } from 'naive-ui'
|
||||
import AvatarComponent from './Avatar.vue'
|
||||
import TextComponent from './Text.vue'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { copyText } from '@/utils/format'
|
||||
import { useIconRender } from '@/hooks/useIconRender'
|
||||
import { t } from '@/locales'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
|
||||
interface Props {
|
||||
dateTime?: string
|
||||
text?: string
|
||||
inversion?: boolean
|
||||
error?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(ev: 'regenerate'): void
|
||||
(ev: 'delete'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const { iconRender } = useIconRender()
|
||||
|
||||
const textRef = ref<HTMLElement>()
|
||||
|
||||
const asRawText = ref(props.inversion)
|
||||
|
||||
const messageRef = ref<HTMLElement>()
|
||||
|
||||
const options = computed(() => {
|
||||
const common = [
|
||||
{
|
||||
label: t('chat.copy'),
|
||||
key: 'copyText',
|
||||
icon: iconRender({ icon: 'ri:file-copy-2-line' }),
|
||||
},
|
||||
{
|
||||
label: t('common.delete'),
|
||||
key: 'delete',
|
||||
icon: iconRender({ icon: 'ri:delete-bin-line' }),
|
||||
},
|
||||
]
|
||||
|
||||
if (!props.inversion) {
|
||||
common.unshift({
|
||||
label: asRawText.value ? t('chat.preview') : t('chat.showRawText'),
|
||||
key: 'toggleRenderType',
|
||||
icon: iconRender({ icon: asRawText.value ? 'ic:outline-code-off' : 'ic:outline-code' }),
|
||||
})
|
||||
}
|
||||
|
||||
return common
|
||||
})
|
||||
|
||||
function handleSelect(key: 'copyText' | 'delete' | 'toggleRenderType') {
|
||||
switch (key) {
|
||||
case 'copyText':
|
||||
copyText({ text: props.text ?? '' })
|
||||
return
|
||||
case 'toggleRenderType':
|
||||
asRawText.value = !asRawText.value
|
||||
return
|
||||
case 'delete':
|
||||
emit('delete')
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
messageRef.value?.scrollIntoView()
|
||||
emit('regenerate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="messageRef"
|
||||
class="flex w-full mb-6 overflow-hidden"
|
||||
:class="[{ 'flex-row-reverse': inversion }]"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center flex-shrink-0 h-8 overflow-hidden rounded-full basis-8"
|
||||
:class="[inversion ? 'ml-2' : 'mr-2']"
|
||||
>
|
||||
<AvatarComponent :image="inversion" />
|
||||
</div>
|
||||
<div class="overflow-hidden text-sm " :class="[inversion ? 'items-end' : 'items-start']">
|
||||
<p class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']">
|
||||
{{ dateTime }}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-end gap-1 mt-2"
|
||||
:class="[inversion ? 'flex-row-reverse' : 'flex-row']"
|
||||
>
|
||||
<TextComponent
|
||||
ref="textRef"
|
||||
:inversion="inversion"
|
||||
:error="error"
|
||||
:text="text"
|
||||
:loading="loading"
|
||||
:as-raw-text="asRawText"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
v-if="!inversion"
|
||||
class="mb-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<SvgIcon icon="ri:restart-line" />
|
||||
</button>
|
||||
<NDropdown
|
||||
:trigger="isMobile ? 'click' : 'hover'"
|
||||
:placement="!inversion ? 'right' : 'left'"
|
||||
:options="options"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<button class="transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-200">
|
||||
<SvgIcon icon="ri:more-2-fill" />
|
||||
</button>
|
||||
</NDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
75
ai-chat-web/src/views/chat/components/Message/style.less
Normal file
75
ai-chat-web/src/views/chat/components/Message/style.less
Normal file
@@ -0,0 +1,75 @@
|
||||
.markdown-body {
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
pre code,
|
||||
pre tt {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.highlight pre,
|
||||
pre {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
&-wrapper {
|
||||
position: relative;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
&-header {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
color: #b3b3b3;
|
||||
|
||||
&__copy {
|
||||
cursor: pointer;
|
||||
margin-left: 0.5rem;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #65a665;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
html.dark {
|
||||
|
||||
.message-reply {
|
||||
.whitespace-pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight pre,
|
||||
pre {
|
||||
background-color: #282c34;
|
||||
}
|
||||
}
|
||||
3
ai-chat-web/src/views/chat/components/index.ts
Normal file
3
ai-chat-web/src/views/chat/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Message from './Message/index.vue'
|
||||
|
||||
export { Message }
|
||||
28
ai-chat-web/src/views/chat/hooks/useChat.ts
Normal file
28
ai-chat-web/src/views/chat/hooks/useChat.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useChatStore } from '@/store'
|
||||
|
||||
export function useChat() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const getChatByUuidAndIndex = (uuid: number, index: number) => {
|
||||
return chatStore.getChatByUuidAndIndex(uuid, index)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const updateChatSome = (uuid: number, index: number, chat: Partial<Chat.Chat>) => {
|
||||
chatStore.updateChatSomeByUuid(uuid, index, chat)
|
||||
}
|
||||
|
||||
return {
|
||||
addChat,
|
||||
updateChat,
|
||||
updateChatSome,
|
||||
getChatByUuidAndIndex,
|
||||
}
|
||||
}
|
||||
24
ai-chat-web/src/views/chat/hooks/useCopyCode.ts
Normal file
24
ai-chat-web/src/views/chat/hooks/useCopyCode.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { copyText } from '@/utils/format'
|
||||
|
||||
export function useCopyCode() {
|
||||
function copyCodeBlock() {
|
||||
const codeBlockWrapper = document.querySelectorAll('.code-block-wrapper')
|
||||
codeBlockWrapper.forEach((wrapper) => {
|
||||
const copyBtn = wrapper.querySelector('.code-block-header__copy')
|
||||
const codeBlock = wrapper.querySelector('.code-block-body')
|
||||
if (copyBtn && codeBlock) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
if (navigator.clipboard?.writeText)
|
||||
navigator.clipboard.writeText(codeBlock.textContent ?? '')
|
||||
else
|
||||
copyText({ text: codeBlock.textContent ?? '', origin: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => copyCodeBlock())
|
||||
|
||||
onUpdated(() => copyCodeBlock())
|
||||
}
|
||||
44
ai-chat-web/src/views/chat/hooks/useScroll.ts
Normal file
44
ai-chat-web/src/views/chat/hooks/useScroll.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
scrollToBottomIfAtBottom: () => 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
|
||||
}
|
||||
|
||||
const scrollToBottomIfAtBottom = async () => {
|
||||
await nextTick()
|
||||
if (scrollRef.value) {
|
||||
const threshold = 100 // 阈值,表示滚动条到底部的距离阈值
|
||||
const distanceToBottom = scrollRef.value.scrollHeight - scrollRef.value.scrollTop - scrollRef.value.clientHeight
|
||||
if (distanceToBottom <= threshold)
|
||||
scrollRef.value.scrollTop = scrollRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
scrollToBottom,
|
||||
scrollToTop,
|
||||
scrollToBottomIfAtBottom,
|
||||
}
|
||||
}
|
||||
23
ai-chat-web/src/views/chat/hooks/useUsingContext.ts
Normal file
23
ai-chat-web/src/views/chat/hooks/useUsingContext.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { t } from '@/locales'
|
||||
import { useChatStore } from '@/store'
|
||||
|
||||
export function useUsingContext() {
|
||||
const ms = useMessage()
|
||||
const chatStore = useChatStore()
|
||||
const usingContext = computed<boolean>(() => chatStore.usingContext)
|
||||
|
||||
function toggleUsingContext() {
|
||||
chatStore.setUsingContext(!usingContext.value)
|
||||
if (usingContext.value)
|
||||
ms.success(t('chat.turnOnContext'))
|
||||
else
|
||||
ms.warning(t('chat.turnOffContext'))
|
||||
}
|
||||
|
||||
return {
|
||||
usingContext,
|
||||
toggleUsingContext,
|
||||
}
|
||||
}
|
||||
562
ai-chat-web/src/views/chat/index.vue
Normal file
562
ai-chat-web/src/views/chat/index.vue
Normal file
@@ -0,0 +1,562 @@
|
||||
<script setup lang='ts'>
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { NAutoComplete, NButton, NInput, useDialog, useMessage } from 'naive-ui'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { Message } from './components'
|
||||
import { useScroll } from './hooks/useScroll'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import { useCopyCode } from './hooks/useCopyCode'
|
||||
import { useUsingContext } from './hooks/useUsingContext'
|
||||
import HeaderComponent from './components/Header/index.vue'
|
||||
import { HoverButton, SvgIcon } from '@/components/common'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { useChatStore, usePromptStore } from '@/store'
|
||||
import { fetchChatAPIProcess } from '@/api'
|
||||
import { t } from '@/locales'
|
||||
|
||||
let controller = new AbortController()
|
||||
|
||||
const openLongReply = import.meta.env.VITE_GLOB_OPEN_LONG_REPLY === 'true'
|
||||
|
||||
const route = useRoute()
|
||||
const dialog = useDialog()
|
||||
const ms = useMessage()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
useCopyCode()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
|
||||
const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll()
|
||||
const { usingContext, toggleUsingContext } = useUsingContext()
|
||||
|
||||
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)
|
||||
const inputRef = ref<Ref | null>(null)
|
||||
|
||||
// 添加PromptStore
|
||||
const promptStore = usePromptStore()
|
||||
|
||||
// 使用storeToRefs,保证store修改后,联想部分能够重新渲染
|
||||
const { promptList: promptTemplate } = storeToRefs<any>(promptStore)
|
||||
|
||||
// 未知原因刷新页面,loading 状态不会重置,手动重置
|
||||
dataSources.value.forEach((item, index) => {
|
||||
if (item.loading)
|
||||
updateChatSome(+uuid, index, { loading: false })
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
onConversation()
|
||||
}
|
||||
|
||||
async function onConversation() {
|
||||
let 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 && usingContext.value)
|
||||
options = { ...lastContext }
|
||||
|
||||
addChat(
|
||||
+uuid,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: '',
|
||||
loading: true,
|
||||
inversion: false,
|
||||
error: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
let lastText = ''
|
||||
const fetchChatAPIOnce = async () => {
|
||||
await fetchChatAPIProcess<Chat.ConversationResponse>({
|
||||
prompt: message,
|
||||
options,
|
||||
signal: controller.signal,
|
||||
onDownloadProgress: ({ event }) => {
|
||||
const xhr = event.target
|
||||
const { responseText } = xhr
|
||||
// Always process the final line
|
||||
const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2)
|
||||
let chunk = responseText
|
||||
if (lastIndex !== -1)
|
||||
chunk = responseText.substring(lastIndex)
|
||||
try {
|
||||
const data = JSON.parse(chunk)
|
||||
updateChat(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: lastText + data.text ?? '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||
requestOptions: { prompt: message, options: { ...options } },
|
||||
},
|
||||
)
|
||||
|
||||
if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
|
||||
options.parentMessageId = data.id
|
||||
lastText = data.text
|
||||
message = ''
|
||||
return fetchChatAPIOnce()
|
||||
}
|
||||
|
||||
scrollToBottomIfAtBottom()
|
||||
}
|
||||
catch (error) {
|
||||
//
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await fetchChatAPIOnce()
|
||||
}
|
||||
catch (error: any) {
|
||||
const errorMessage = error?.message ?? t('common.wrong')
|
||||
|
||||
if (error.message === 'canceled') {
|
||||
updateChatSome(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
scrollToBottomIfAtBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const currentChat = getChatByUuidAndIndex(+uuid, dataSources.value.length - 1)
|
||||
|
||||
if (currentChat?.text && currentChat.text !== '') {
|
||||
updateChatSome(
|
||||
+uuid,
|
||||
dataSources.value.length - 1,
|
||||
{
|
||||
text: `${currentChat.text}\n[${errorMessage}]`,
|
||||
error: false,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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 } },
|
||||
},
|
||||
)
|
||||
scrollToBottomIfAtBottom()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate(index: number) {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
controller = new AbortController()
|
||||
|
||||
const { requestOptions } = dataSources.value[index]
|
||||
|
||||
let message = requestOptions?.prompt ?? ''
|
||||
|
||||
let options: Chat.ConversationRequest = {}
|
||||
|
||||
if (requestOptions.options)
|
||||
options = { ...requestOptions.options }
|
||||
|
||||
loading.value = true
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: true,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
let lastText = ''
|
||||
const fetchChatAPIOnce = async () => {
|
||||
await fetchChatAPIProcess<Chat.ConversationResponse>({
|
||||
prompt: message,
|
||||
options,
|
||||
signal: controller.signal,
|
||||
onDownloadProgress: ({ event }) => {
|
||||
const xhr = event.target
|
||||
const { responseText } = xhr
|
||||
// Always process the final line
|
||||
const lastIndex = responseText.lastIndexOf('\n', responseText.length - 2)
|
||||
let chunk = responseText
|
||||
if (lastIndex !== -1)
|
||||
chunk = responseText.substring(lastIndex)
|
||||
try {
|
||||
const data = JSON.parse(chunk)
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: lastText + data.text ?? '',
|
||||
inversion: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
|
||||
if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
|
||||
options.parentMessageId = data.id
|
||||
lastText = data.text
|
||||
message = ''
|
||||
return fetchChatAPIOnce()
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
//
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
await fetchChatAPIOnce()
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error.message === 'canceled') {
|
||||
updateChatSome(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = error?.message ?? t('common.wrong')
|
||||
|
||||
updateChat(
|
||||
+uuid,
|
||||
index,
|
||||
{
|
||||
dateTime: new Date().toLocaleString(),
|
||||
text: errorMessage,
|
||||
inversion: false,
|
||||
error: true,
|
||||
loading: false,
|
||||
conversationOptions: null,
|
||||
requestOptions: { prompt: message, ...options },
|
||||
},
|
||||
)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
const d = dialog.warning({
|
||||
title: t('chat.exportImage'),
|
||||
content: t('chat.exportImageConfirm'),
|
||||
positiveText: t('common.yes'),
|
||||
negativeText: t('common.no'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
d.loading = true
|
||||
const ele = document.getElementById('image-wrapper')
|
||||
const canvas = await html2canvas(ele as HTMLDivElement, {
|
||||
useCORS: true,
|
||||
})
|
||||
const imgUrl = canvas.toDataURL('image/png')
|
||||
const tempLink = document.createElement('a')
|
||||
tempLink.style.display = 'none'
|
||||
tempLink.href = imgUrl
|
||||
tempLink.setAttribute('download', 'chat-shot.png')
|
||||
if (typeof tempLink.download === 'undefined')
|
||||
tempLink.setAttribute('target', '_blank')
|
||||
|
||||
document.body.appendChild(tempLink)
|
||||
tempLink.click()
|
||||
document.body.removeChild(tempLink)
|
||||
window.URL.revokeObjectURL(imgUrl)
|
||||
d.loading = false
|
||||
ms.success(t('chat.exportSuccess'))
|
||||
Promise.resolve()
|
||||
}
|
||||
catch (error: any) {
|
||||
ms.error(t('chat.exportFailed'))
|
||||
}
|
||||
finally {
|
||||
d.loading = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete(index: number) {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
dialog.warning({
|
||||
title: t('chat.deleteMessage'),
|
||||
content: t('chat.deleteMessageConfirm'),
|
||||
positiveText: t('common.yes'),
|
||||
negativeText: t('common.no'),
|
||||
onPositiveClick: () => {
|
||||
chatStore.deleteChatByUuid(+uuid, index)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (loading.value)
|
||||
return
|
||||
|
||||
dialog.warning({
|
||||
title: t('chat.clearChat'),
|
||||
content: t('chat.clearChatConfirm'),
|
||||
positiveText: t('common.yes'),
|
||||
negativeText: t('common.no'),
|
||||
onPositiveClick: () => {
|
||||
chatStore.clearChatByUuid(+uuid)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleEnter(event: KeyboardEvent) {
|
||||
if (!isMobile.value) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (event.key === 'Enter' && event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
if (loading.value) {
|
||||
controller.abort()
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 可优化部分
|
||||
// 搜索选项计算,这里使用value作为索引项,所以当出现重复value时渲染异常(多项同时出现选中效果)
|
||||
// 理想状态下其实应该是key作为索引项,但官方的renderOption会出现问题,所以就需要value反renderLabel实现
|
||||
const searchOptions = computed(() => {
|
||||
if (prompt.value.startsWith('/')) {
|
||||
return promptTemplate.value.filter((item: { key: string }) => item.key.toLowerCase().includes(prompt.value.substring(1).toLowerCase())).map((obj: { value: any }) => {
|
||||
return {
|
||||
label: obj.value,
|
||||
value: obj.value,
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// value反渲染key
|
||||
const renderOption = (option: { label: string }) => {
|
||||
for (const i of promptTemplate.value) {
|
||||
if (i.value === option.label)
|
||||
return [i.key]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (isMobile.value)
|
||||
return t('chat.placeholderMobile')
|
||||
return t('chat.placeholder')
|
||||
})
|
||||
|
||||
const buttonDisabled = computed(() => {
|
||||
return loading.value || !prompt.value || prompt.value.trim() === ''
|
||||
})
|
||||
|
||||
const footerClass = computed(() => {
|
||||
let classes = ['p-4']
|
||||
if (isMobile.value)
|
||||
classes = ['sticky', 'left-0', 'bottom-0', 'right-0', 'p-2', 'pr-3', 'overflow-hidden']
|
||||
return classes
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
if (inputRef.value && !isMobile.value)
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loading.value)
|
||||
controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full">
|
||||
<HeaderComponent
|
||||
v-if="isMobile"
|
||||
:using-context="usingContext"
|
||||
@export="handleExport"
|
||||
@toggle-using-context="toggleUsingContext"
|
||||
/>
|
||||
<main class="flex-1 overflow-hidden">
|
||||
<div
|
||||
id="scrollRef"
|
||||
ref="scrollRef"
|
||||
class="h-full overflow-hidden overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
id="image-wrapper"
|
||||
class="w-full max-w-screen-xl m-auto dark:bg-[#101014]"
|
||||
:class="[isMobile ? 'p-2' : 'p-4']"
|
||||
>
|
||||
<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)"
|
||||
@delete="handleDelete(index)"
|
||||
/>
|
||||
<div class="sticky bottom-0 left-0 flex justify-center">
|
||||
<NButton v-if="loading" type="warning" @click="handleStop">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:stop-circle-line" />
|
||||
</template>
|
||||
Stop Responding
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer :class="footerClass">
|
||||
<div class="w-full max-w-screen-xl m-auto">
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<HoverButton @click="handleClear">
|
||||
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||
<SvgIcon icon="ri:delete-bin-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<HoverButton v-if="!isMobile" @click="handleExport">
|
||||
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||
<SvgIcon icon="ri:download-2-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<HoverButton v-if="!isMobile" @click="toggleUsingContext">
|
||||
<span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
|
||||
<SvgIcon icon="ri:chat-history-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
<NAutoComplete v-model:value="prompt" :options="searchOptions" :render-label="renderOption">
|
||||
<template #default="{ handleInput, handleBlur, handleFocus }">
|
||||
<NInput
|
||||
ref="inputRef"
|
||||
v-model:value="prompt"
|
||||
type="textarea"
|
||||
:placeholder="placeholder"
|
||||
:autosize="{ minRows: 1, maxRows: isMobile ? 4 : 8 }"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keypress="handleEnter"
|
||||
/>
|
||||
</template>
|
||||
</NAutoComplete>
|
||||
<NButton type="primary" :disabled="buttonDisabled" @click="handleSubmit">
|
||||
<template #icon>
|
||||
<span class="dark:text-black">
|
||||
<SvgIcon icon="ri:send-plane-fill" />
|
||||
</span>
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
61
ai-chat-web/src/views/chat/layout/Layout.vue
Normal file
61
ai-chat-web/src/views/chat/layout/Layout.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import { NLayout, NLayoutContent } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
// import Sider from './sider/index.vue'
|
||||
// import SiderRight from './SiderRight/index.vue'
|
||||
// import Permission from './Permission.vue'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
// const authStore = useAuthStore()
|
||||
|
||||
router.replace({ name: 'Chat', params: { uuid: chatStore.active } })
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
/*
|
||||
onBeforeMount(() => {
|
||||
const access_token = getCookieValue('sso_0voice_access_token')
|
||||
if (!access_token)
|
||||
window.location.href = 'https://user.0voice.com?sys=ai'
|
||||
})
|
||||
|
||||
*/
|
||||
// const needPermission = computed(() => !!authStore.session?.auth && !authStore.token)
|
||||
// const needPermission = !localStorage.access_token
|
||||
const getMobileClass = computed(() => {
|
||||
if (isMobile.value)
|
||||
return ['rounded-none', 'shadow-none']
|
||||
return ['border', 'rounded-md', 'shadow-md', 'dark:border-neutral-800']
|
||||
})
|
||||
|
||||
const getContainerClass = computed(() => {
|
||||
return [
|
||||
'h-full',
|
||||
// { 'pl-[260px]': !isMobile.value && !collapsed.value },
|
||||
// { 'right-[0]': !isMobile.value && !collapsed.value },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full dark:bg-[#24272e] transition-all" :class="[isMobile ? 'p-0' : 'p-4']">
|
||||
<div class="h-full overflow-hidden" :class="getMobileClass">
|
||||
<NLayout class="z-40 transition" :class="getContainerClass" has-sider>
|
||||
<!-- <Sider /> -->
|
||||
<NLayoutContent class="h-full">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</RouterView>
|
||||
</NLayoutContent>
|
||||
<!-- <SiderRight /> -->
|
||||
</NLayout>
|
||||
</div>
|
||||
<!-- <Permission :visible="needPermission" /> -->
|
||||
</div>
|
||||
</template>
|
||||
144
ai-chat-web/src/views/chat/layout/Permission.vue
Normal file
144
ai-chat-web/src/views/chat/layout/Permission.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref } from 'vue'
|
||||
import { NButton, NInput, NModal, useMessage } from 'naive-ui'
|
||||
import { useAuthStore } from '@/store'
|
||||
import Icon403 from '@/icons/403.vue'
|
||||
import { fetchCode, login } from '@/api'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const ms = useMessage()
|
||||
authStore.setToken('helloxx')
|
||||
// authStore.getSession().then((data) => {
|
||||
// })
|
||||
// console.log('authStore.getSession() ', authStore.getSession())
|
||||
const phoneRe = /1\d{10}$/
|
||||
const codeRe = /\d{4}/
|
||||
|
||||
const token = ref('')
|
||||
const loading = ref(false)
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const codeBtnMsg = ref('发送验证码')
|
||||
const codeSended = ref(false)
|
||||
const codeLoading = ref(false)
|
||||
|
||||
const sendCodeDisabled = computed(() => !phoneRe.test(phone.value.trim()) || codeSended.value === true)
|
||||
const loginDisabled = computed(() => !phoneRe.test(phone.value.trim()) || !codeRe.test(code.value.trim()))
|
||||
|
||||
async function sendCode() {
|
||||
fetchCode(phone.value).then((data) => {
|
||||
ms.success('验证码已发送')
|
||||
}).catch((err) => {
|
||||
// console.log('err: ', err)
|
||||
ms.error(err.error)
|
||||
})
|
||||
|
||||
let time = 120
|
||||
codeSended.value = true
|
||||
codeBtnMsg.value = `已发送 ( ${time} )`
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
codeBtnMsg.value = `已发送 ( ${--time} )`
|
||||
if (time === 0) {
|
||||
codeBtnMsg.value = '发送验证码'
|
||||
codeSended.value = false
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await login(phone.value.trim(), code.value.trim())
|
||||
// authStore.setToken(data.token)
|
||||
// console.log('datasss: ', data)
|
||||
localStorage.access_token = data.access_token
|
||||
ms.success('登录成功')
|
||||
window.location.reload()
|
||||
}
|
||||
catch (error: any) {
|
||||
ms.error(error.message ?? 'error')
|
||||
// authStore.removeToken()
|
||||
localStorage.access_token = ''
|
||||
token.value = ''
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCodePress(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handlePhonePress(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey)
|
||||
event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="visible" style="width: 90%; max-width: 640px">
|
||||
<div class="p-10 bg-white rounded dark:bg-slate-800">
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-2">
|
||||
<h2 class="text-2xl font-bold text-center text-slate-800 dark:text-neutral-200">
|
||||
请先登录(试运营)
|
||||
</h2>
|
||||
<p class="text-base text-center text-slate-500 dark:text-slate-500">
|
||||
{{ $t('common.unauthorizedTips') }}
|
||||
</p>
|
||||
<Icon403 class="w-[200px] m-auto" />
|
||||
</header>
|
||||
<NInput v-model:value="phone" placeholder="请输入手机号码" @keypress="handlePhonePress" />
|
||||
<div class="code-warp">
|
||||
<NInput v-model:value="code" class="code" type="text" placeholder="请输入验证码" @keypress="handleCodePress" />
|
||||
<div class="send-code-btn">
|
||||
<NButton
|
||||
block
|
||||
type="primary"
|
||||
:disabled="sendCodeDisabled"
|
||||
:loading="codeLoading"
|
||||
@click="sendCode"
|
||||
>
|
||||
{{ codeBtnMsg }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<NButton
|
||||
block
|
||||
type="primary"
|
||||
:disabled="loginDisabled"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ $t('common.verify') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-warp {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.code-warp .code {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-warp .send-code-btn {
|
||||
width: 130px;
|
||||
margin-left: 15px;
|
||||
flex: 0 0 130px;
|
||||
}
|
||||
</style>
|
||||
24
ai-chat-web/src/views/chat/layout/SiderRight/Footer.vue
Normal file
24
ai-chat-web/src/views/chat/layout/SiderRight/Footer.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang='ts'>
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
|
||||
|
||||
const Setting = defineAsyncComponent(() => import('@/components/common/Setting/index.vue'))
|
||||
|
||||
const show = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t dark:border-neutral-800">
|
||||
<div class="flex-1 flex-shrink-0 overflow-hidden">
|
||||
<UserAvatar />
|
||||
</div>
|
||||
|
||||
<HoverButton @click="show = true">
|
||||
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||
<SvgIcon icon="ri:settings-4-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
|
||||
<Setting v-if="show" v-model:visible="show" />
|
||||
</footer>
|
||||
</template>
|
||||
103
ai-chat-web/src/views/chat/layout/SiderRight/List.vue
Normal file
103
ai-chat-web/src/views/chat/layout/SiderRight/List.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import { NInput, NPopconfirm, NScrollbar } from 'naive-ui'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const dataSources = computed(() => chatStore.history)
|
||||
|
||||
async function handleSelect({ uuid }: Chat.History) {
|
||||
if (isActive(uuid))
|
||||
return
|
||||
|
||||
if (chatStore.active)
|
||||
chatStore.updateHistory(chatStore.active, { isEdit: false })
|
||||
await chatStore.setActive(uuid)
|
||||
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
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)
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
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>{{ $t('common.noData') }}</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 dark:border-neutral-800 dark:hover:bg-[#24272e]"
|
||||
:class="isActive(item.uuid) && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'dark:bg-[#24272e]', 'dark:border-[#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>
|
||||
{{ $t('chat.deleteHistoryConfirm') }}
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
||||
107
ai-chat-web/src/views/chat/layout/SiderRight/index.vue
Normal file
107
ai-chat-web/src/views/chat/layout/SiderRight/index.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NLayoutSider } from 'naive-ui'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { PromptStore } from '@/components/common'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
const show = ref(false)
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
function handleAdd() {
|
||||
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
function handleUpdateCollapsed() {
|
||||
appStore.setSiderCollapsed(!collapsed.value)
|
||||
}
|
||||
|
||||
const getMobileClass = computed<CSSProperties>(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
width: 260,
|
||||
}
|
||||
}
|
||||
return {
|
||||
}
|
||||
})
|
||||
|
||||
const getWidth = () => isMobile.value ? 280 : 500
|
||||
|
||||
const mobileSafeArea = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
watch(
|
||||
isMobile,
|
||||
(val) => {
|
||||
appStore.setSiderCollapsed(val)
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
flush: 'post',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLayoutSider
|
||||
:collapsed="collapsed" :collapsed-width="0" collapse-mode="transform" position="static" :width="isMobile ? 260 : 500"
|
||||
bordered :style="getMobileClass" class="bottom-0 top-0" @update-collapsed="handleUpdateCollapsed"
|
||||
>
|
||||
<div class="flex flex-col h-full p-6 bottom-0" :style="mobileSafeArea">
|
||||
<main class="flex flex-col flex-1 min-h-0">
|
||||
<div class="_sidebar_79eze_17">
|
||||
<h3>
|
||||
声明:本项目主要是零声教育内部使用,探索AI与c/c++职业技能教育的结合。
|
||||
</h3>
|
||||
<br>
|
||||
|
||||
<p>项目基于开源,感谢以下开源项目的贡献:</p>
|
||||
<p>后端开源:https://github.com/Arvintian/chatgpt-web.git</p>
|
||||
<p>前端开源:https://github.com/Chanzhaoyu/chatgpt-web</p>
|
||||
<p>敏感词汇过滤:https://github.com/importcjj/sensitive</p>
|
||||
<p>喂养方案:https://github.com/03Anmol/-openai_chatbot</p>
|
||||
<br>
|
||||
|
||||
<p>云主机使用腾讯云(硅谷地区),chatgpt付费api消耗 $50/day,超出限量会有相关错误提示。 回复的观点,来源chatgpt官方,不代表开源项目,也不代表项目参与者观点。chatgpt属于ai,使用时,内容仅供参考。</p>
|
||||
<p>注: 目前本项目有为openai喂养一个关于c/c++开发开源技能点的zvoice_chatbot.json,会提高答案的可供参考的准确性。</p>
|
||||
<br>
|
||||
|
||||
<p>
|
||||
零声,专注于C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,
|
||||
fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,
|
||||
TCP/IP,协程,DPDK, SPDK, bpf/ebpf等等相关技术探索与分享。
|
||||
</p>
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<div>零声交流群:<a href="https://jq.qq.com/?_wv=1027&k=sEIbk6yO" target="blank">762073882</a></div>
|
||||
<div>每晚八点直播:<a href="https://ke.qq.com/course/417774?flowToken=1044591" target="_blank">https://ke.qq.com/course/417774</a></div>
|
||||
<div>ChatGPT项目实战教程:<a href="https://cox.xet.tech/s/16n7fK" target="_blank">https://cox.xet.tech/s/16n7fK</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- <Footer /> -->
|
||||
</div>
|
||||
</NLayoutSider>
|
||||
<template v-if="isMobile">
|
||||
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
|
||||
</template>
|
||||
<PromptStore v-model:visible="show" />
|
||||
</template>
|
||||
95
ai-chat-web/src/views/chat/layout/SiderRight/index1.vue
Normal file
95
ai-chat-web/src/views/chat/layout/SiderRight/index1.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang='ts'>
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NButton, NLayoutSider } from 'naive-ui'
|
||||
import List from './List.vue'
|
||||
import Footer from './Footer.vue'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { PromptStore } from '@/components/common'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
const show = ref(false)
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
function handleAdd() {
|
||||
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
function handleUpdateCollapsed() {
|
||||
appStore.setSiderCollapsed(!collapsed.value)
|
||||
}
|
||||
|
||||
const getMobileClass = computed<CSSProperties>(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const mobileSafeArea = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}
|
||||
}
|
||||
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" :style="mobileSafeArea">
|
||||
<main class="flex flex-col flex-1 min-h-0">
|
||||
<div class="p-4">
|
||||
<NButton dashed block @click="handleAdd">
|
||||
New chat
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 pb-4 overflow-hidden">
|
||||
<List />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<NButton block @click="show = true">
|
||||
Prompt Store
|
||||
</NButton>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</NLayoutSider>
|
||||
<template v-if="isMobile">
|
||||
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
|
||||
</template>
|
||||
<PromptStore v-model:visible="show" />
|
||||
</template>
|
||||
3
ai-chat-web/src/views/chat/layout/index.ts
Normal file
3
ai-chat-web/src/views/chat/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ChatLayout from './Layout.vue'
|
||||
|
||||
export { ChatLayout }
|
||||
24
ai-chat-web/src/views/chat/layout/sider/Footer.vue
Normal file
24
ai-chat-web/src/views/chat/layout/sider/Footer.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang='ts'>
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
|
||||
|
||||
const Setting = defineAsyncComponent(() => import('@/components/common/Setting/index.vue'))
|
||||
|
||||
const show = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t dark:border-neutral-800">
|
||||
<div class="flex-1 flex-shrink-0 overflow-hidden">
|
||||
<UserAvatar />
|
||||
</div>
|
||||
|
||||
<HoverButton @click="show = true">
|
||||
<span class="text-xl text-[#4f555e] dark:text-white">
|
||||
<SvgIcon icon="ri:settings-4-line" />
|
||||
</span>
|
||||
</HoverButton>
|
||||
|
||||
<Setting v-if="show" v-model:visible="show" />
|
||||
</footer>
|
||||
</template>
|
||||
103
ai-chat-web/src/views/chat/layout/sider/List.vue
Normal file
103
ai-chat-web/src/views/chat/layout/sider/List.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import { NInput, NPopconfirm, NScrollbar } from 'naive-ui'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const dataSources = computed(() => chatStore.history)
|
||||
|
||||
async function handleSelect({ uuid }: Chat.History) {
|
||||
if (isActive(uuid))
|
||||
return
|
||||
|
||||
if (chatStore.active)
|
||||
chatStore.updateHistory(chatStore.active, { isEdit: false })
|
||||
await chatStore.setActive(uuid)
|
||||
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
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)
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
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>{{ $t('common.noData') }}</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 dark:border-neutral-800 dark:hover:bg-[#24272e]"
|
||||
:class="isActive(item.uuid) && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'dark:bg-[#24272e]', 'dark:border-[#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>
|
||||
{{ $t('chat.deleteHistoryConfirm') }}
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
||||
95
ai-chat-web/src/views/chat/layout/sider/index.vue
Normal file
95
ai-chat-web/src/views/chat/layout/sider/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang='ts'>
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NButton, NLayoutSider } from 'naive-ui'
|
||||
import List from './List.vue'
|
||||
import Footer from './Footer.vue'
|
||||
import { useAppStore, useChatStore } from '@/store'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { PromptStore } from '@/components/common'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
const show = ref(false)
|
||||
|
||||
const collapsed = computed(() => appStore.siderCollapsed)
|
||||
|
||||
function handleAdd() {
|
||||
chatStore.addHistory({ title: 'New Chat', uuid: Date.now(), isEdit: false })
|
||||
if (isMobile.value)
|
||||
appStore.setSiderCollapsed(true)
|
||||
}
|
||||
|
||||
function handleUpdateCollapsed() {
|
||||
appStore.setSiderCollapsed(!collapsed.value)
|
||||
}
|
||||
|
||||
const getMobileClass = computed<CSSProperties>(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const mobileSafeArea = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}
|
||||
}
|
||||
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" :style="mobileSafeArea">
|
||||
<main class="flex flex-col flex-1 min-h-0">
|
||||
<div class="p-4">
|
||||
<NButton dashed block @click="handleAdd">
|
||||
New chat
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 pb-4 overflow-hidden">
|
||||
<List />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<NButton block @click="show = true">
|
||||
Prompt Store
|
||||
</NButton>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</NLayoutSider>
|
||||
<template v-if="isMobile">
|
||||
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
|
||||
</template>
|
||||
<PromptStore v-model:visible="show" />
|
||||
</template>
|
||||
31
ai-chat-web/src/views/exception/404/index.vue
Normal file
31
ai-chat-web/src/views/exception/404/index.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goHome() {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<div class="px-4 m-auto space-y-4 text-center max-[400px]">
|
||||
<h1 class="text-4xl text-slate-800 dark:text-neutral-200">
|
||||
Sorry, page not found!
|
||||
</h1>
|
||||
<p class="text-base text-slate-500 dark:text-neutral-400">
|
||||
Sorry, we couldn’t find the page you’re looking for. Perhaps you’ve mistyped the URL? Be sure to check your spelling.
|
||||
</p>
|
||||
<div class="flex items-center justify-center text-center">
|
||||
<div class="w-[300px]">
|
||||
<img src="../../../icons/404.svg" alt="404">
|
||||
</div>
|
||||
</div>
|
||||
<NButton type="primary" @click="goHome">
|
||||
Go to Home
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
32
ai-chat-web/src/views/exception/500/index.vue
Normal file
32
ai-chat-web/src/views/exception/500/index.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon500 from '@/icons/500.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goHome() {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full dark:bg-neutral-800">
|
||||
<div class="px-4 m-auto space-y-4 text-center max-[400px]">
|
||||
<header class="space-y-2">
|
||||
<h2 class="text-2xl font-bold text-center text-slate-800 dark:text-neutral-200">
|
||||
500
|
||||
</h2>
|
||||
<p class="text-base text-center text-slate-500 dark:text-slate-500">
|
||||
Server error
|
||||
</p>
|
||||
<div class="flex items-center justify-center text-center">
|
||||
<Icon500 class="w-[300px]" />
|
||||
</div>
|
||||
</header>
|
||||
<NButton type="primary" @click="goHome">
|
||||
Go to Home
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user