Compare commits

21 Commits

Author SHA1 Message Date
ChenZhaoYu
88740c13f0 test: output 2023-03-28 16:11:52 +08:00
ChenZhaoYu
76cef650b4 chore: build rollback 2023-03-28 16:06:11 +08:00
assassinliujie
78bcf7f4ce perf: optimized output (#962)
* Update index.ts

* Update index.vue

* Update index.ts
2023-03-28 15:47:40 +08:00
ChenZhaoYu
c0a9fd5208 fix: 快速按下删除会话导致的问题 #917 2023-03-28 13:48:49 +08:00
ChenZhaoYu
07123b70ad chore: rolled back to clear the impact 2023-03-28 13:20:55 +08:00
assassinliujie
b579d24d19 pref: message output optimization (#935)
* Update index.ts

修改后端,让保留打字机效果的同时优化前后端之间传输的内容,节省流量和性能

* Update index.vue

修改前端,和之前修改的后端匹配,保留打字机效果同时优化性能和流量传输

* chore: lint fix

---------

Co-authored-by: ChenZhaoYu <790348264@qq.com>
2023-03-28 09:40:20 +08:00
KingTwinkle
e2eeee455a fix: local import error and NModal not as expected (#938) 2023-03-28 09:34:15 +08:00
weifeng
32ad20416c chore: Update README.md (#880)
* Update README.md

docker-compose 文件中注释掉未使用的配置节点,因为 留xxx 会导代码读取节点内容值为xxx,而不是期望的默认值,导致代码运行异常
增加 对模型可选参数的备注

* style: eslint fix

---------

Co-authored-by: ChenZhaoYu <790348264@qq.com>
2023-03-27 15:58:54 +08:00
吴杉(Shan Wu)
799af86739 perf: add localization for sider (#911) 2023-03-27 15:49:42 +08:00
ChenZhaoYu
c3f431118b chore: update docs 2023-03-26 10:08:01 +08:00
zaimoe
a3944f86b7 fix: missing VITE_GLOB_API_URL when docker build, fixed #690 #717 # (#877) 2023-03-25 17:23:57 +08:00
Yige
365a7df1ff Merge pull request #883 from yi-ge/dev-main
docs: 更新README.md,添加长回复使用方法
2023-03-25 17:06:20 +08:00
yi-ge
639152f987 docs: update README.md, add long reply feature 2023-03-25 17:01:12 +08:00
gitgitgogogo
fb8ad3790d fix: 反向代理限流失效 (#863)
https://docs.colyseus.io/zh_hk/colyseus/how-to/rate-limit/

使用nginx限流会只识别为服务器ip,需启用trust proxy
2023-03-25 08:30:15 +08:00
ChenZhaoYu
902321026b perf: 本地地址 2023-03-24 08:14:51 +08:00
ChenZhaoYu
9b0d7dbee8 fix: 移动端焦点不触发的问题 2023-03-23 21:12:36 +08:00
ChenZhaoYu
634c879108 perf: 自动焦点时移动端上的不便 2023-03-23 20:51:20 +08:00
Kid
57a1d6e3cd fix: ESM error (#826) 2023-03-23 20:43:47 +08:00
ChenZhaoYu
9081b22ce9 perf: 移动端删除 chat 时,侧边栏应该收起 2023-03-23 16:56:59 +08:00
Redon
73e12b1fdd fix: 移动端新建会话关闭侧边栏 (#813) 2023-03-23 16:23:57 +08:00
Bing
5fca2e9fa3 fix: PWA 未添加的问题 (#807)
Co-authored-by: bingo235 <>
2023-03-23 15:55:51 +08:00
18 changed files with 171 additions and 113 deletions

View File

@@ -4,3 +4,4 @@ node_modules
Dockerfile
.*
*/.*
!.env

2
.env
View File

@@ -1,7 +1,7 @@
# Glob API URL
VITE_GLOB_API_URL=/api
VITE_APP_API_BASE_URL=http://localhost:3002/
VITE_APP_API_BASE_URL=http://127.0.0.1:3002/
# Whether long replies are supported, which may result in higher API fees
VITE_GLOB_OPEN_LONG_REPLY=false

View File

@@ -304,6 +304,10 @@ Q: Why doesn't the frontend have a typewriter effect?
A: One possible reason is that after Nginx reverse proxying, buffering is turned on, and Nginx will try to buffer a certain amount of data from the backend before sending it to the browser. Please try adding `proxy_buffering off;` after the reverse proxy parameter and then reloading Nginx. Other web server configurations are similar.
Q: The content returned is incomplete?
A: There is a length limit for the content returned by the API each time. You can modify the `VITE_GLOB_OPEN_LONG_REPLY` field in the `.env` file under the root directory, set it to `true`, and rebuild the front-end to enable the long reply feature, which can return the full content. It should be noted that using this feature may bring more API usage fees.
## Contributing
Please read the [Contributing Guidelines](./CONTRIBUTING.en.md) before contributing.

View File

@@ -56,7 +56,7 @@
1. 你应该首先使用 `API` 方式
2. 使用 `API` 时,如果网络不通,那是国内被墙了,你需要自建代理,绝对不要使用别人的公开代理,那是危险的。
3. 使用 `accessToken` 方式时反向代理将向第三方暴露您的访问令牌,这样做应该不会产生任何不良影响,但在使用这种方法之前请考虑风险。
4. 使用 `accessToken` 时,不管你是国内还是国外的机器,都会使用代理。默认代理为 [acheong08](https://github.com/acheong08) 大佬的 `https://bypass.duti.tech/api/conversation`,这不是后门也不是监听,除非你有能力自己翻过 `CF` 验证,用前请知悉。[社区代理](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
4. 使用 `accessToken` 时,不管你是国内还是国外的机器,都会使用代理。默认代理为 [acheong08](https://github.com/acheong08) 大佬的 `https://bypass.churchless.tech/api/conversation`,这不是后门也不是监听,除非你有能力自己翻过 `CF` 验证,用前请知悉。[社区代理](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
5. 把项目发布到公共网络时,你应该设置 `AUTH_SECRET_KEY` 变量添加你的密码访问权限,你也应该修改 `index.html` 中的 `title`,防止被关键词搜索到。
切换方式:
@@ -165,7 +165,7 @@ pnpm dev
`ACCESS_TOKEN` 可用:
- `OPENAI_ACCESS_TOKEN``OPENAI_API_KEY` 二选一,同时存在时,`OPENAI_API_KEY` 优先
- `API_REVERSE_PROXY` 设置反向代理,可选,默认:`https://bypass.duti.tech/api/conversation`[社区](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
- `API_REVERSE_PROXY` 设置反向代理,可选,默认:`https://bypass.churchless.tech/api/conversation`[社区](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
通用:
@@ -219,7 +219,8 @@ services:
OPENAI_ACCESS_TOKEN: xxx
# API接口地址可选设置 OPENAI_API_KEY 时可用
OPENAI_API_BASE_URL: xxx
# API模型可选设置 OPENAI_API_KEY 时可用
# API模型可选设置 OPENAI_API_KEY 时可用https://platform.openai.com/docs/models
# gpt-4, gpt-4-0314, gpt-4-32k, gpt-4-32k-0314, gpt-3.5-turbo, gpt-3.5-turbo-0301, text-davinci-003, text-davinci-002, code-davinci-002
OPENAI_API_MODEL: xxx
# 反向代理,可选
API_REVERSE_PROXY: xxx

View File

@@ -16,7 +16,7 @@
"scripts": {
"start": "esno ./src/index.ts",
"dev": "esno watch ./src/index.ts",
"prod": "esno ./build/index.js",
"prod": "node ./build/index.mjs",
"build": "pnpm clean && tsup",
"clean": "rimraf build",
"lint": "eslint .",

View File

@@ -3,7 +3,7 @@ import 'isomorphic-fetch'
import type { ChatGPTAPIOptions, ChatMessage, SendMessageOptions } from 'chatgpt'
import { ChatGPTAPI, ChatGPTUnofficialProxyAPI } from 'chatgpt'
import { SocksProxyAgent } from 'socks-proxy-agent'
import { HttpsProxyAgent } from 'https-proxy-agent'
import httpsProxyAgent from 'https-proxy-agent'
import fetch from 'node-fetch'
import axios from 'axios'
import { sendResponse } from '../utils'
@@ -11,6 +11,8 @@ import { isNotEmptyString } from '../utils/is'
import type { ApiModel, ChatContext, ChatGPTUnofficialProxyAPIOptions, ModelConfig } from '../types'
import type { RequestOptions } from './types'
const { HttpsProxyAgent } = httpsProxyAgent
dotenv.config()
const ErrorCodeMessage: Record<string, string> = {

View File

@@ -25,12 +25,21 @@ router.post('/chat-process', [auth, limiter], async (req, res) => {
try {
const { prompt, options = {}, systemMessage } = req.body as RequestProps
let firstChunk = true
let chatLength = 0
let newChatLength = 0
await chatReplyProcess({
message: prompt,
lastContext: options,
process: (chat: ChatMessage) => {
res.write(firstChunk ? JSON.stringify(chat) : `\n${JSON.stringify(chat)}`)
firstChunk = false
if (firstChunk) {
res.write(`${JSON.stringify(chat)}t1h1i4s5i1s4a1s9i1l9l8y1s0plit`)
firstChunk = false
}
else if (chatLength !== chat.text.length) {
newChatLength = chat.text.length
res.write(chat.text.substring(chatLength, newChatLength))
chatLength = newChatLength
}
},
systemMessage,
})
@@ -76,11 +85,12 @@ router.post('/verify', async (req, res) => {
res.send({ status: 'Success', message: 'Verify successfully', data: null })
}
catch (error) {
res.send({ status: 'Fail', message: error.message, data: null })
res.send({ status: 'Fail', message: error.messagen, data: null })
}
})
app.use('', router)
app.use('/api', router)
app.set('trust proxy', 1)
app.listen(3002, () => globalThis.console.log('Server is running on port 3002'))

View File

@@ -4,7 +4,7 @@ export default defineConfig({
entry: ['src/index.ts'],
outDir: 'build',
target: 'es2020',
format: ['cjs'],
format: ['esm'],
splitting: false,
sourcemap: true,
minify: false,

View File

@@ -147,7 +147,7 @@ const clearPromptTemplate = () => {
message.success(t('common.clearSuccess'))
}
const importPromptTemplate = () => {
const importPromptTemplate = (from = 'online') => {
try {
const jsonData = JSON.parse(tempPromptValue.value)
let key = ''
@@ -168,7 +168,7 @@ const importPromptTemplate = () => {
}
for (const i of jsonData) {
if (!('key' in i) || !('value' in i))
if (!(key in i) || !(value in i))
throw new Error(t('store.importError'))
let safe = true
for (const j of promptList.value) {
@@ -191,6 +191,8 @@ const importPromptTemplate = () => {
catch {
message.error('JSON 格式错误,请检查 JSON 格式')
}
if (from === 'local')
showModal.value = !showModal.value
}
// 模板导出
@@ -469,7 +471,7 @@ const dataSource = computed(() => {
block
type="primary"
:disabled="inputStatus"
@click="() => { importPromptTemplate() }"
@click="() => { importPromptTemplate('local') }"
>
{{ t('common.import') }}
</NButton>

View File

@@ -28,6 +28,7 @@ export default {
unauthorizedTips: 'Unauthorized, please verify first.',
},
chat: {
newChatButton: 'New Chat',
placeholder: 'Ask me anything...(Shift + Enter = line break)',
placeholderMobile: 'Ask me anything...',
copy: 'Copy',
@@ -70,6 +71,7 @@ export default {
balance: 'API Balance',
},
store: {
siderButton: 'Prompt Store',
local: 'Local',
online: 'Online',
title: 'Title',

View File

@@ -28,6 +28,7 @@ export default {
unauthorizedTips: '未经授权,请先进行验证。',
},
chat: {
newChatButton: '新建聊天',
placeholder: '来说点什么吧...Shift + Enter = 换行)',
placeholderMobile: '来说点什么...',
copy: '复制',
@@ -70,6 +71,7 @@ export default {
balance: 'API余额',
},
store: {
siderButton: '提示词商店',
local: '本地',
online: '在线',
title: '标题',

View File

@@ -28,6 +28,7 @@ export default {
unauthorizedTips: '未經授權,請先進行驗證。',
},
chat: {
newChatButton: '新建對話',
placeholder: '來說點什麼...Shift + Enter = 換行)',
placeholderMobile: '來說點什麼...',
copy: '複製',
@@ -70,6 +71,7 @@ export default {
balance: 'API余額',
},
store: {
siderButton: '提示詞商店',
local: '本機',
online: '線上',
title: '標題',

View File

@@ -0,0 +1,18 @@
type CallbackFunc<T extends unknown[]> = (...args: T) => void
export function debounce<T extends unknown[]>(
func: CallbackFunc<T>,
wait: number,
): (...args: T) => void {
let timeoutId: ReturnType<typeof setTimeout> | undefined
return (...args: T) => {
const later = () => {
clearTimeout(timeoutId)
func(...args)
}
clearTimeout(timeoutId)
timeoutId = setTimeout(later, wait)
}
}

View File

@@ -7,6 +7,7 @@ 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
@@ -25,6 +26,8 @@ const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const { isMobile } = useBasicLayout()
const { iconRender } = useIconRender()
const textRef = ref<HTMLElement>()
@@ -113,7 +116,12 @@ function handleRegenerate() {
>
<SvgIcon icon="ri:restart-line" />
</button>
<NDropdown :placement="!inversion ? 'right' : 'left'" :options="options" @select="handleSelect">
<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>

View File

@@ -107,7 +107,9 @@ async function onConversation() {
scrollToBottom()
try {
let lastText = ''
const magicSplit = 't1h1i4s5i1s4a1s9i1l9l8y1s0plit'
let renderText = ''
let firstTime = true
const fetchChatAPIOnce = async () => {
await fetchChatAPIProcess<Chat.ConversationResponse>({
prompt: message,
@@ -117,42 +119,49 @@ async function onConversation() {
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()
const splitIndexBegin = responseText.search(magicSplit)
if (splitIndexBegin !== -1) {
const splitIndexEnd = splitIndexBegin + magicSplit.length
const firstChunk = responseText.substring(0, splitIndexBegin)
const deltaText = responseText.substring(splitIndexEnd)
try {
const data = JSON.parse(firstChunk)
if (firstTime) {
firstTime = false
renderText = data.text ?? ''
}
else {
renderText = deltaText ?? ''
}
updateChat(
+uuid,
dataSources.value.length - 1,
{
dateTime: new Date().toLocaleString(),
text: renderText,
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
message = ''
return fetchChatAPIOnce()
}
}
catch (error) {
//
}
scrollToBottomIfAtBottom()
}
catch (error) {
//
}
},
})
}
await fetchChatAPIOnce()
}
catch (error: any) {
@@ -237,7 +246,9 @@ async function onRegenerate(index: number) {
)
try {
let lastText = ''
const magicSplit = 't1h1i4s5i1s4a1s9i1l9l8y1s0plit'
let renderText = ''
let firstTime = true
const fetchChatAPIOnce = async () => {
await fetchChatAPIProcess<Chat.ConversationResponse>({
prompt: message,
@@ -247,35 +258,45 @@ async function onRegenerate(index: number) {
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()
const splitIndexBegin = responseText.search(magicSplit)
if (splitIndexBegin !== -1) {
const splitIndexEnd = splitIndexBegin + magicSplit.length
const firstChunk = responseText.substring(0, splitIndexBegin)
const deltaText = responseText.substring(splitIndexEnd)
try {
const data = JSON.parse(firstChunk)
if (firstTime) {
firstTime = false
renderText = data.text ?? ''
}
else {
renderText = deltaText ?? ''
}
updateChat(
+uuid,
index,
{
dateTime: new Date().toLocaleString(),
text: renderText,
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
message = ''
return fetchChatAPIOnce()
}
}
catch (error) {
//
}
}
catch (error) {
//
}
},
})
@@ -454,7 +475,7 @@ const footerClass = computed(() => {
onMounted(() => {
scrollToBottom()
if (inputRef.value)
if (inputRef.value && !isMobile.value)
inputRef.value?.focus()
})
@@ -467,20 +488,13 @@ onUnmounted(() => {
<template>
<div class="flex flex-col w-full h-full">
<HeaderComponent
v-if="isMobile"
:using-context="usingContext"
@export="handleExport"
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="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]"
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">
@@ -492,14 +506,8 @@ onUnmounted(() => {
<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)"
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">
@@ -536,15 +544,9 @@ onUnmounted(() => {
<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"
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>

View File

@@ -4,6 +4,7 @@ import { NInput, NPopconfirm, NScrollbar } from 'naive-ui'
import { SvgIcon } from '@/components/common'
import { useAppStore, useChatStore } from '@/store'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { debounce } from '@/utils/functions/debounce'
const { isMobile } = useBasicLayout()
@@ -32,8 +33,12 @@ function handleEdit({ uuid }: Chat.History, isEdit: boolean, event?: MouseEvent)
function handleDelete(index: number, event?: MouseEvent | TouchEvent) {
event?.stopPropagation()
chatStore.deleteHistory(index)
if (isMobile.value)
appStore.setSiderCollapsed(true)
}
const handleDeleteDebounce = debounce(handleDelete, 600)
function handleEnter({ uuid }: Chat.History, isEdit: boolean, event: KeyboardEvent) {
event?.stopPropagation()
if (event.key === 'Enter')
@@ -67,8 +72,7 @@ function isActive(uuid: number) {
<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"
v-model:value="item.title" size="tiny"
@keypress="handleEnter(item, false, $event)"
/>
<span v-else>{{ item.title }}</span>
@@ -83,7 +87,7 @@ function isActive(uuid: number) {
<button class="p-1">
<SvgIcon icon="ri:edit-line" @click="handleEdit(item, true, $event)" />
</button>
<NPopconfirm placement="bottom" @positive-click="handleDelete(index, $event)">
<NPopconfirm placement="bottom" @positive-click="handleDeleteDebounce(index, $event)">
<template #trigger>
<button class="p-1">
<SvgIcon icon="ri:delete-bin-line" />

View File

@@ -18,6 +18,8 @@ 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() {
@@ -71,7 +73,7 @@ watch(
<main class="flex flex-col flex-1 min-h-0">
<div class="p-4">
<NButton dashed block @click="handleAdd">
New chat
{{ $t('chat.newChatButton') }}
</NButton>
</div>
<div class="flex-1 min-h-0 pb-4 overflow-hidden">
@@ -79,7 +81,7 @@ watch(
</div>
<div class="p-4">
<NButton block @click="show = true">
Prompt Store
{{ $t('store.siderButton') }}
</NButton>
</div>
</main>

View File

@@ -5,10 +5,9 @@ import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
function setupPlugins(env: ImportMetaEnv): PluginOption[] {
const plugins = [vue()]
if (env.VITE_GLOB_APP_PWA === 'true') {
VitePWA({
return [
vue(),
env.VITE_GLOB_APP_PWA === 'true' && VitePWA({
injectRegister: 'auto',
manifest: {
name: 'chatGPT',
@@ -18,9 +17,8 @@ function setupPlugins(env: ImportMetaEnv): PluginOption[] {
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
],
},
})
}
return plugins
}),
]
}
export default defineConfig((env) => {