feat: 增加带格式的复制 (#182)

* feat: 增加带格式的复制

* feat: 移除前端超时设定

* chore: update deps

* feat: 添加权限页面

* feat: 设定页面优化

* feat: 更新 chatgpt 以支持 `gpt-3.5-turbo-0301`

* chore: version 2.9.0
This commit is contained in:
Redon
2023-03-02 12:59:20 +08:00
committed by GitHub
parent 42e320fe35
commit 32ebbec8ad
28 changed files with 689 additions and 306 deletions

View File

@@ -2,8 +2,10 @@
import { NConfigProvider } from 'naive-ui'
import { NaiveProvider } from '@/components/common'
import { useTheme } from '@/hooks/useTheme'
import { useLanguage } from '@/hooks/useLanguage'
const { theme, themeOverrides } = useTheme()
const { language } = useLanguage()
</script>
<template>
@@ -11,6 +13,7 @@ const { theme, themeOverrides } = useTheme()
class="h-full"
:theme="theme"
:theme-overrides="themeOverrides"
:locale="language"
>
<NaiveProvider>
<RouterView />

View File

@@ -1 +0,0 @@
export { }

View File

@@ -0,0 +1,62 @@
<script setup lang='ts'>
import { onMounted, ref } from 'vue'
import { NSpin } from 'naive-ui'
import { fetchChatConfig } from '@/api'
import pkg from '@/../package.json'
interface ConfigState {
timeoutMs?: number
reverseProxy?: string
apiModel?: string
socksProxy?: string
}
const loading = ref(false)
const config = ref<ConfigState>()
async function fetchConfig() {
try {
loading.value = true
const { data } = await fetchChatConfig<ConfigState>()
config.value = data
}
finally {
loading.value = false
}
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<NSpin :show="loading">
<div class="p-4 space-y-4">
<h2 class="text-xl font-bold">
Version - {{ pkg.version }}
</h2>
<div class="p-2 space-y-2 rounded-md bg-neutral-100 dark:bg-neutral-700">
<p>
此项目开源于
<a
class="text-blue-600 dark:text-blue-500"
href="https://github.com/Chanzhaoyu/chatgpt-web"
target="_blank"
>
Github
</a>
免费并且没有任何形式分付费行为
</p>
<p>
如果你觉得此项目对你有帮助请在 Github 帮我点个 Star 或者给予一点赞助谢谢
</p>
</div>
<p>API方式{{ config?.apiModel ?? '-' }}</p>
<p>反向代理{{ config?.reverseProxy ?? '-' }}</p>
<p>超时时间{{ config?.timeoutMs ?? '-' }}</p>
<p>Socks代理{{ config?.socksProxy ?? '-' }}</p>
</div>
</NSpin>
</template>

View File

@@ -0,0 +1,144 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { NButton, NInput, useMessage } from 'naive-ui'
import type { Language, Theme } from '@/store/modules/app/helper'
import { SvgIcon } from '@/components/common'
import { useAppStore, useUserStore } from '@/store'
import type { UserInfo } from '@/store/modules/user/helper'
interface Emit {
(event: 'update'): void
}
const emit = defineEmits<Emit>()
const appStore = useAppStore()
const userStore = useUserStore()
const ms = useMessage()
const theme = computed(() => appStore.theme)
const userInfo = computed(() => userStore.userInfo)
const avatar = ref(userInfo.value.avatar ?? '')
const name = ref(userInfo.value.name ?? '')
const description = ref(userInfo.value.description ?? '')
const language = computed({
get() {
return appStore.language
},
set(value: Language) {
appStore.setLanguage(value)
},
})
const themeOptions: { label: string; key: Theme; icon: string }[] = [
{
label: 'Auto',
key: 'auto',
icon: 'ri:contrast-line',
},
{
label: 'Light',
key: 'light',
icon: 'ri:sun-foggy-line',
},
{
label: 'Dark',
key: 'dark',
icon: 'ri:moon-foggy-line',
},
]
const languageOptions: { label: string; key: Language; value: Language }[] = [
{ label: '中文', key: 'zh-CN', value: 'zh-CN' },
{ label: 'English', key: 'en-US', value: 'en-US' },
]
function updateUserInfo(options: Partial<UserInfo>) {
userStore.updateUserInfo(options)
ms.success('Update success')
}
function handleReset() {
userStore.resetUserInfo()
ms.success('Reset success')
emit('update')
}
</script>
<template>
<div class="p-4 space-y-5 min-h-[200px]">
<div class="space-y-6">
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">Avatar Link</span>
<div class="flex-1">
<NInput v-model:value="avatar" placeholder="" />
</div>
<NButton size="tiny" text type="primary" @click="updateUserInfo({ avatar })">
Save
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">Name</span>
<div class="w-[200px]">
<NInput v-model:value="name" placeholder="" />
</div>
<NButton size="tiny" text type="primary" @click="updateUserInfo({ name })">
Save
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">Description</span>
<div class="flex-1">
<NInput v-model:value="description" placeholder="" />
</div>
<NButton size="tiny" text type="primary" @click="updateUserInfo({ description })">
Save
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">Reset UserInfo</span>
<NButton text type="primary" @click="handleReset">
Reset
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">Theme</span>
<div class="flex items-center space-x-4">
<template v-for="item of themeOptions" :key="item.key">
<a
class="flex items-center justify-center h-8 px-4 border rounded-md cursor-pointer dark:border-neutral-700"
:class="item.key === theme && ['bg-[#4ca85e]', 'border-[#4ca85e]', 'text-white']"
@click="appStore.setTheme(item.key)"
>
<span class="text-xl">
<SvgIcon :icon="item.icon" />
</span>
</a>
</template>
</div>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">Language</span>
<div class="flex items-center space-x-4">
<template v-for="item of languageOptions" :key="item.key">
<a
class="flex items-center justify-center h-8 px-4 border rounded-md cursor-pointer dark:border-neutral-700"
:class="item.key === language && ['bg-[#4ca85e]', 'border-[#4ca85e]', 'text-white']"
@click="appStore.setLanguage(item.key)"
>
<span class="text-sm">
{{ item.label }}
</span>
</a>
</template>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,8 +1,9 @@
<script setup lang='ts'>
import { computed, ref, watch } from 'vue'
import { NCard, NModal } from 'naive-ui'
import pkg from '../../../../package.json'
import { fetchChatConfig } from '@/api'
import { computed, ref } from 'vue'
import { NCard, NModal, NTabPane, NTabs } from 'naive-ui'
import General from './General.vue'
import About from './About.vue'
import { SvgIcon } from '@/components/common'
interface Props {
visible: boolean
@@ -12,17 +13,14 @@ interface Emit {
(e: 'update:visible', visible: boolean): void
}
interface ConfigState {
timeoutMs?: number
reverseProxy?: string
apiModel?: string
socksProxy?: string
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const active = ref('General')
const reload = ref(false)
const show = computed({
get() {
return props.visible
@@ -32,46 +30,35 @@ const show = computed({
},
})
const config = ref<ConfigState>()
async function fetchConfig() {
try {
const { data } = await fetchChatConfig<ConfigState>()
config.value = data
}
catch (error) {
// ...
}
function handleReload() {
reload.value = true
setTimeout(() => {
reload.value = false
}, 0)
}
watch(
() => props.visible,
(val) => {
if (val)
fetchConfig()
},
)
</script>
<template>
<NModal v-model:show="show" style="width: 80%; max-width: 460px;">
<NCard>
<div class="space-y-4">
<h2 class="text-xl font-bold text-center">
Version - {{ pkg.version }}
</h2>
<hr>
<p>
此项目开源于
<a class="text-blue-600" href="https://github.com/Chanzhaoyu/chatgpt-web" target="_blank">Github</a>
如果你觉得此项目对你有帮助请帮我点个 Star谢谢
</p>
<hr>
<p>API方式{{ config?.apiModel ?? '-' }}</p>
<p>反向代理{{ config?.reverseProxy ?? '-' }}</p>
<p>超时时间{{ config?.timeoutMs ?? '-' }}</p>
<p>Socks代理{{ config?.socksProxy ?? '-' }}</p>
</div>
<NModal v-model:show="show">
<NCard role="dialog" aria-modal="true" :bordered="false" style="width: 100%; max-width: 640px">
<NTabs v-model:value="active" type="line" animated>
<NTabPane name="General" tab="General">
<template #tab>
<SvgIcon class="text-lg" icon="ri:file-user-line" />
<span class="ml-2">General</span>
</template>
<div class="min-h-[100px]">
<General v-if="!reload" @update="handleReload" />
</div>
</NTabPane>
<NTabPane name="Config" tab="Config">
<template #tab>
<SvgIcon class="text-lg" icon="ri:list-settings-line" />
<span class="ml-2">Config</span>
</template>
<About />
</NTabPane>
</NTabs>
</NCard>
</NModal>
</template>

View File

@@ -1,18 +1,39 @@
<script setup lang='ts'>
import { GithubSite } from '@/components/custom'
import { computed } from 'vue'
import { NAvatar } from 'naive-ui'
import { useUserStore } from '@/store'
import defaultAvatar from '@/assets/avatar.jpg'
import { isString } from '@/utils/is'
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
</script>
<template>
<div class="flex items-center">
<div class="w-10 h-10 overflow-hidden rounded-full">
<img class="object-cover" src="@/assets/avatar.jpg" alt="avatar">
<template v-if="isString(userInfo.avatar) && userInfo.avatar.length > 0">
<NAvatar
size="large"
round
:src="userInfo.avatar"
:fallback-src="defaultAvatar"
/>
</template>
<template v-else>
<NAvatar size="large" round :src="defaultAvatar" />
</template>
</div>
<div class="ml-2">
<h2 class="font-bold text-md">
ChenZhaoYu
{{ userInfo.name ?? 'ChenZhaoYu' }}
</h2>
<p class="text-xs text-gray-500">
<GithubSite />
<span
v-if="isString(userInfo.description) && userInfo.description !== ''"
v-html="userInfo.description"
/>
</p>
</div>
</div>

16
src/hooks/useLanguage.ts Normal file
View File

@@ -0,0 +1,16 @@
import { computed } from 'vue'
import { enUS, zhCN } from 'naive-ui'
import { useAppStore } from '@/store'
export function useLanguage() {
const appStore = useAppStore()
const language = computed(() => {
if (appStore.language === 'zh-CN')
return zhCN
else
return enUS
})
return { language }
}

1
src/icons/403.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

1
src/icons/404.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -17,6 +17,24 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
path: '/403',
name: '403',
component: () => import('@/views/exception/403/index.vue'),
},
{
path: '/404',
name: '404',
component: () => import('@/views/exception/404/index.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'notFound',
redirect: '/404',
},
]
export const router = createRouter({

View File

@@ -4,13 +4,16 @@ const LOCAL_NAME = 'appSetting'
export type Theme = 'light' | 'dark' | 'auto'
export type Language = 'zh-CN' | 'en-US'
export interface AppState {
siderCollapsed: boolean
theme: Theme
language: Language
}
export function defaultSetting(): AppState {
return { siderCollapsed: false, theme: 'light' }
return { siderCollapsed: false, theme: 'light', language: 'zh-CN' }
}
export function getLocalSetting(): AppState {

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { AppState, Theme } from './helper'
import type { AppState, Language, Theme } from './helper'
import { getLocalSetting, setLocalSetting } from './helper'
export const useAppStore = defineStore('app-store', {
@@ -15,6 +15,13 @@ export const useAppStore = defineStore('app-store', {
this.recordState()
},
setLanguage(language: Language) {
if (this.language !== language) {
this.language = language
this.recordState()
}
},
recordState() {
setLocalSetting(this.$state)
},

View File

@@ -1,2 +1,3 @@
export * from './app'
export * from './chat'
export * from './user'

View File

@@ -0,0 +1,32 @@
import { ss } from '@/utils/storage'
const LOCAL_NAME = 'userStorage'
export interface UserInfo {
avatar: string
name: string
description: string
}
export interface UserState {
userInfo: UserInfo
}
export function defaultSetting(): UserState {
return {
userInfo: {
avatar: 'https://raw.githubusercontent.com/Chanzhaoyu/chatgpt-web/main/src/assets/avatar.jpg',
name: 'ChenZhaoYu',
description: 'Star on <a href="https://github.com/Chanzhaoyu/chatgpt-bot" class="text-blue-500" target="_blank" >Github</a>',
},
}
}
export function getLocalState(): UserState {
const localSetting: UserState | undefined = ss.get(LOCAL_NAME)
return { ...defaultSetting(), ...localSetting }
}
export function setLocalState(setting: UserState): void {
ss.set(LOCAL_NAME, setting)
}

View File

@@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
import type { UserInfo, UserState } from './helper'
import { defaultSetting, getLocalState, setLocalState } from './helper'
export const useUserStore = defineStore('user-store', {
state: (): UserState => getLocalState(),
actions: {
updateUserInfo(userInfo: Partial<UserInfo>) {
this.userInfo = { ...this.userInfo, ...userInfo }
this.recordState()
},
resetUserInfo() {
this.userInfo = { ...defaultSetting().userInfo }
this.recordState()
},
recordState() {
setLocalState(this.$state)
},
},
})

View File

@@ -1,4 +1,7 @@
// 转义 HTML 字符
/**
* 转义 HTML 字符
* @param source
*/
export function encodeHTML(source: string) {
return source
.replace(/&/g, '&amp;')
@@ -8,17 +11,31 @@ export function encodeHTML(source: string) {
.replace(/'/g, '&#39;')
}
// 判断是否为代码块
/**
* 判断是否为代码块
* @param text
*/
export function includeCode(text: string | null | undefined) {
const regexp = /^(?:\s{4}|\t).+/gm
return !!(text?.includes(' = ') || text?.match(regexp))
}
// 复制文本
export function copyText(text: string) {
const input = document.createElement('input')
/**
* 复制文本
* @param options
*/
export function copyText(options: { text: string; origin?: boolean }) {
const props = { origin: true, ...options }
let input: HTMLInputElement | HTMLTextAreaElement
if (props.origin)
input = document.createElement('textarea')
else
input = document.createElement('input')
input.setAttribute('readonly', 'readonly')
input.setAttribute('value', text)
input.value = props.text
document.body.appendChild(input)
input.select()
if (document.execCommand('copy'))

View File

@@ -2,7 +2,6 @@ import axios, { type AxiosResponse } from 'axios'
const service = axios.create({
baseURL: import.meta.env.VITE_GLOB_API_URL,
timeout: !isNaN(+import.meta.env.VITE_GLOB_API_TIMEOUT) ? Number(import.meta.env.VITE_GLOB_API_TIMEOUT) : 60 * 1000,
})
service.interceptors.request.use(

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
import { useBasicLayout } from '@/hooks/useBasicLayout'
@@ -18,6 +18,8 @@ const { isMobile } = useBasicLayout()
const renderer = new marked.Renderer()
const textRef = ref<HTMLElement>()
renderer.html = (html) => {
return `<p>${encodeHTML(html)}</p>`
}
@@ -54,6 +56,8 @@ const text = computed(() => {
return marked(value)
return value
})
defineExpose({ textRef })
</script>
<template>
@@ -62,7 +66,7 @@ const text = computed(() => {
<span class="dark:text-white w-[4px] h-[20px] block animate-blink" />
</template>
<template v-else>
<div class="leading-relaxed break-all">
<div ref="textRef" class="leading-relaxed break-all">
<div v-if="!inversion" class="markdown-body" v-html="text" />
<div v-else class="whitespace-pre-wrap" v-text="text" />
</div>

View File

@@ -1,4 +1,5 @@
<script setup lang='ts'>
import { ref } from 'vue'
import { NDropdown, useMessage } from 'naive-ui'
import AvatarComponent from './Avatar.vue'
import TextComponent from './Text.vue'
@@ -27,32 +28,41 @@ const ms = useMessage()
const { iconRender } = useIconRender()
const textRef = ref<HTMLElement>()
const options = [
{
label: 'Copy',
key: 'copy',
label: 'Copy Raw',
key: 'copyRaw',
icon: iconRender({ icon: 'ri:file-copy-2-line' }),
}, {
},
{
label: 'Copy Text',
key: 'copyText',
icon: iconRender({ icon: 'ri:file-copy-line' }),
},
{
label: 'Delete',
key: 'delete',
icon: iconRender({ icon: 'ri:delete-bin-line' }),
},
]
function handleSelect(key: 'copy' | 'delete') {
if (key === 'copy')
handleCopy()
else
handleDelete()
}
function handleDelete() {
emit('delete')
}
function handleCopy() {
copyText(props.text ?? '')
ms.success('Copied')
function handleSelect(key: 'copyRaw' | 'copyText' | 'delete') {
switch (key) {
case 'copyRaw':
if (textRef.value && (textRef.value as any).textRef) {
copyText({ text: (textRef.value as any).textRef.innerText })
ms.success('Copied Raw')
}
return
case 'copyText':
copyText({ text: props.text ?? '', origin: false })
ms.success('Copied Text')
return
case 'delete':
emit('delete')
}
}
function handleRegenerate() {
@@ -73,10 +83,11 @@ function handleRegenerate() {
{{ dateTime }}
</p>
<div
class="flex items-end gap-2 mt-2"
class="flex items-end gap-1 mt-2"
:class="[inversion ? 'flex-row-reverse' : 'flex-row']"
>
<TextComponent
ref="textRef"
:inversion="inversion"
:error="error"
:text="text"
@@ -85,14 +96,14 @@ function handleRegenerate() {
<div class="flex flex-col">
<button
v-if="!inversion"
class="mb-2 transition text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
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 :options="options" @select="handleSelect">
<NDropdown :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:function-line" />
<SvgIcon icon="ri:more-2-fill" />
</button>
</NDropdown>
</div>

View File

@@ -1,61 +1,22 @@
<script setup lang='ts'>
import { computed, ref } from 'vue'
import { NDropdown } from 'naive-ui'
import { HoverButton, Setting, SvgIcon, UserAvatar } from '@/components/common'
import { useAppStore } from '@/store'
import { useIconRender } from '@/hooks/useIconRender'
import { defineAsyncComponent, ref } from 'vue'
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
const appStore = useAppStore()
const { iconRender } = useIconRender()
const Setting = defineAsyncComponent(() => import('@/components/common/Setting/index.vue'))
const show = ref(false)
const theme = computed(() => appStore.theme)
const options = [
{
label: 'Dark',
key: 'dark',
icon: iconRender({ icon: 'ri:moon-foggy-line' }),
},
{
label: 'Light',
key: 'light',
icon: iconRender({ icon: 'ri:sun-foggy-line' }),
},
{
label: 'Auto',
key: 'auto',
icon: iconRender({ icon: 'ri:contrast-line' }),
},
]
function handleThemeChange(key: 'light' | 'dark' | 'auto') {
appStore.setTheme(key)
}
</script>
<template>
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t dark:border-neutral-800">
<UserAvatar />
<NDropdown :options="options" placement="top" trigger="click" @select="handleThemeChange">
<HoverButton>
<span class="text-xl text-[#4f555e] dark:text-white">
<SvgIcon v-if="theme === 'dark'" icon="ri:moon-foggy-line" />
<SvgIcon v-if="theme === 'light'" icon="ri:sun-foggy-line" />
<SvgIcon v-if="theme === 'auto'" icon="ri:contrast-line" />
</span>
</HoverButton>
</NDropdown>
<HoverButton tooltip="Setting" @click="show = true">
<span class="text-xl text-[#4f555e] dark:text-white">
<SvgIcon icon="ri:settings-4-line" />
</span>
</HoverButton>
<Setting v-model:visible="show" />
<Setting v-if="show" v-model:visible="show" />
</footer>
</template>

View File

@@ -0,0 +1,34 @@
<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">
No permission
</h1>
<p class="text-base text-slate-500 dark:text-neutral-400">
The page you're trying access has restricted access.
Please refer to your system administrator
</p>
<div class="flex items-center justify-center text-center">
<div class="w-[300px]">
<div class="w-[300px]">
<img src="../../../icons/403.svg" alt="404">
</div>
</div>
</div>
<NButton type="primary" @click="goHome">
Go to Home
</NButton>
</div>
</div>
</template>

View 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 couldnt find the page youre looking for. Perhaps youve 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>