Compare commits

..

1 Commits

Author SHA1 Message Date
ChenZhaoYu
7a8dc393b5 fix: 移动端新建会话关闭侧边栏 2023-03-23 16:22:06 +08:00
17 changed files with 106 additions and 164 deletions

View File

@@ -4,4 +4,3 @@ 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://127.0.0.1:3002/
VITE_APP_API_BASE_URL=http://localhost:3002/
# Whether long replies are supported, which may result in higher API fees
VITE_GLOB_OPEN_LONG_REPLY=false

View File

@@ -304,10 +304,6 @@ 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.churchless.tech/api/conversation`,这不是后门也不是监听,除非你有能力自己翻过 `CF` 验证,用前请知悉。[社区代理](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
4. 使用 `accessToken` 时,不管你是国内还是国外的机器,都会使用代理。默认代理为 [acheong08](https://github.com/acheong08) 大佬的 `https://bypass.duti.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.churchless.tech/api/conversation`[社区](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
- `API_REVERSE_PROXY` 设置反向代理,可选,默认:`https://bypass.duti.tech/api/conversation`[社区](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
通用:
@@ -219,8 +219,7 @@ services:
OPENAI_ACCESS_TOKEN: xxx
# API接口地址可选设置 OPENAI_API_KEY 时可用
OPENAI_API_BASE_URL: xxx
# 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
# API模型可选设置 OPENAI_API_KEY 时可用
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": "node ./build/index.mjs",
"prod": "esno ./build/index.js",
"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,8 +11,6 @@ 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,21 +25,12 @@ 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) => {
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
}
res.write(firstChunk ? JSON.stringify(chat) : `\n${JSON.stringify(chat)}`)
firstChunk = false
},
systemMessage,
})
@@ -85,12 +76,11 @@ router.post('/verify', async (req, res) => {
res.send({ status: 'Success', message: 'Verify successfully', data: null })
}
catch (error) {
res.send({ status: 'Fail', message: error.messagen, data: null })
res.send({ status: 'Fail', message: error.message, 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: ['esm'],
format: ['cjs'],
splitting: false,
sourcemap: true,
minify: false,

View File

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

View File

@@ -28,7 +28,6 @@ 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',
@@ -71,7 +70,6 @@ export default {
balance: 'API Balance',
},
store: {
siderButton: 'Prompt Store',
local: 'Local',
online: 'Online',
title: 'Title',

View File

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

View File

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

View File

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

View File

@@ -107,9 +107,7 @@ async function onConversation() {
scrollToBottom()
try {
const magicSplit = 't1h1i4s5i1s4a1s9i1l9l8y1s0plit'
let renderText = ''
let firstTime = true
let lastText = ''
const fetchChatAPIOnce = async () => {
await fetchChatAPIProcess<Chat.ConversationResponse>({
prompt: message,
@@ -119,49 +117,42 @@ 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 } },
},
)
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) {
//
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) {
@@ -246,9 +237,7 @@ async function onRegenerate(index: number) {
)
try {
const magicSplit = 't1h1i4s5i1s4a1s9i1l9l8y1s0plit'
let renderText = ''
let firstTime = true
let lastText = ''
const fetchChatAPIOnce = async () => {
await fetchChatAPIProcess<Chat.ConversationResponse>({
prompt: message,
@@ -258,46 +247,36 @@ 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 },
},
)
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) {
//
if (openLongReply && data.detail.choices[0].finish_reason === 'length') {
options.parentMessageId = data.id
lastText = data.text
message = ''
return fetchChatAPIOnce()
}
}
catch (error) {
//
}
},
})
}
@@ -475,7 +454,7 @@ const footerClass = computed(() => {
onMounted(() => {
scrollToBottom()
if (inputRef.value && !isMobile.value)
if (inputRef.value)
inputRef.value?.focus()
})
@@ -488,13 +467,20 @@ 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">
@@ -506,8 +492,14 @@ 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">
@@ -544,9 +536,15 @@ 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,7 +4,6 @@ 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()
@@ -33,12 +32,8 @@ 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')
@@ -72,7 +67,8 @@ 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>
@@ -87,7 +83,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="handleDeleteDebounce(index, $event)">
<NPopconfirm placement="bottom" @positive-click="handleDelete(index, $event)">
<template #trigger>
<button class="p-1">
<SvgIcon icon="ri:delete-bin-line" />

View File

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