Compare commits

..

41 Commits

Author SHA1 Message Date
ChenZhaoYu
a6605e8a57 chore: v2.11.1 2023-10-11 16:17:19 +08:00
ChenZhaoYu
3482565481 chore: doc 2023-10-07 08:33:01 +08:00
ChenZhaoYu
a6f670101a fix: 不规范的引入 2023-09-26 11:53:18 +08:00
ChenZhaoYu
2683977e21 chore: 2.11.1 2023-09-26 11:52:38 +08:00
ChenZhaoYu
e1d8f5ff56 feat: 清空聊天历史 2023-09-26 11:50:38 +08:00
ChenZhaoYu
4f9232c130 chore: 格式化代码 2023-09-26 11:39:28 +08:00
ChenZhaoYu
c226d07368 fix: 修复打字机动画 2023-09-26 11:38:58 +08:00
Carson Yang
2b2efe2f15 Update README.md (#1864) 2023-08-09 11:45:11 +08:00
march
c2ce7009e6 fix: store循环引用 (#1880) 2023-08-09 11:43:57 +08:00
Peter Dave Hello
b651ef8373 chore: Improve zh-TW locale (#1837) 2023-07-15 11:19:45 +08:00
shansing
b8f2a0e849 feat: 允许temperature调到2 (#1797)
ref: https://platform.openai.com/docs/api-reference/chat/create#chat/create-temperature
2023-06-26 18:17:11 +08:00
Tin Nguyen Trong
c6e1663a39 Create vi-VN.ts (#1798) 2023-06-26 18:16:47 +08:00
怀瑾
847a2d4d8c style: 优化移动端代码展示 (#1752) 2023-06-19 14:28:10 +08:00
BertramRay
6e272bb343 feat: 支持最新的gpt-3.5-turbo-16k模型 (#1789)
* fix: 增加 16k 模型支持

* fix: 修改判断逻辑

* fix: 修改 gpt-3.5-turbo tokens 判断逻辑

---------

Co-authored-by: ziyang <ziyang@dora.design>
2023-06-19 14:26:22 +08:00
舜岳
bc390ef09d feat: "Stop Responding" 国际化,使用 chatgpt 翻译 (#1735) 2023-05-31 14:05:53 +08:00
tranhungonline
1cb5393b91 fix: 移动端新建会话关闭侧边栏 (#1661)
Co-authored-by: ChenZhaoYu <790348264@qq.com>
2023-05-06 10:58:06 +08:00
ChenZhaoYu
0f51e51827 fix: 移动端塌缩 2023-05-04 11:19:25 +08:00
ChenZhaoYu
3dff5bd4c8 chore: version 2.11.0 2023-04-26 11:07:37 +08:00
ChenZhaoYu
dbb57d8894 perf: 优化复制逻辑 2023-04-26 08:27:46 +08:00
ChenZhaoYu
3b033d0ed4 feat: 补充语言列表 2023-04-26 08:26:50 +08:00
ChenZhaoYu
dd20e9aea6 chore: 移除无用文件 2023-04-26 08:18:19 +08:00
aquaratixc
838679f837 chore: Update index.ts (#1570)
* Update index.ts

Added russian translation

* fix: locale name

---------

Co-authored-by: Redon <790348264@qq.com>
2023-04-24 20:15:28 +08:00
夜法之书(appotry)
89f78bd4c7 添加nginx防止爬虫爬取配置 (#1187)
* 添加nginx防止爬虫爬取配置

* Update nginx.conf

* Update README.md
2023-04-24 20:08:30 +08:00
24min
d598dc65ce fix(proxy url): change default proxy url (#1567) 2023-04-24 20:07:46 +08:00
aquaratixc
ac8b69dfd4 Create ru-RU.ts (#1571)
Added russian localization
2023-04-24 20:05:41 +08:00
wanglong001
226ce2bded [fix] markdown 表格内没有换行 (#1492) 2023-04-17 21:10:36 +08:00
Peter Dave Hello
7583985f44 chore: Improve zh-TW locale (#1460) 2023-04-16 15:18:53 +08:00
Peter Dave Hello
537872968c Let's make GitHub happy (#1453) 2023-04-15 16:35:33 +08:00
Kamilake
eed33fbb0e feat: Added Korean translation (#1372)
* add ko-KR

* oops

* type-check & delete unused comments
2023-04-12 09:34:36 +08:00
idawnwon
da04383772 fix: default return in useLanguange() hook (#1352)
if `setLocale('zh-CN')`, `return` should be `zhCN`, not `enUS`.
2023-04-10 21:29:03 +08:00
ChenZhaoYu
527c8613b2 chore: mark days 2023-04-10 15:51:19 +08:00
ChenZhaoYu
05a241408e fix: conversationOptions 2023-04-10 08:24:16 +08:00
舜岳
20aa35f209 chore: optimize code (#1328) 2023-04-09 22:14:33 +08:00
ChenZhaoYu
ddc7066f4e chore: 默认 100 秒 2023-04-08 11:57:41 +08:00
ChenZhaoYu
44f00c95cd fix: 调整光标到底部 2023-04-08 11:49:41 +08:00
ZuoNing
439104f195 fix: 查询使用量支持代理&修正使用量文案 (#1296)
* fix: 查询使用量支持代理&修正使用量文案

* fix: 修复默认错误

* chore: 移除打印

---------

Co-authored-by: ChenZhaoYu <790348264@qq.com>
2023-04-08 11:47:00 +08:00
quzard
86bba7d8f3 feat: 添加自定义 temperature 和 top_p (#1260)
* 在设置的高级面板里自定义temperature和top_p

* change default temperature from 0.8 to 0.5

* pref: 检查代码,增加仅 api 的接口判断

* chore: 锁定 pnpm-lock.yaml

---------

Co-authored-by: ChenZhaoYu <790348264@qq.com>
2023-04-07 14:32:09 +08:00
wangxi
1187d88593 fix: 修复API余额查询 (#1174)
* fix: 使API余额查询可用

* chore: 调整计算方式

* perf: 余额描述变更

---------

Co-authored-by: ChenZhaoYu <790348264@qq.com>
2023-04-04 08:27:16 +08:00
RyanzeX
b07f01b0cf chore: 引导用户触发提示词 (#1183) 2023-04-04 07:54:05 +08:00
LeafSummer
9b66fed26e fix: requestOptions (#1188)
修复页面上重复执行onRegenerate操作,会丢失上下文的问题
2023-04-04 07:53:37 +08:00
ChenZhaoYu
abc4c3ad22 chore: 回退 chatgpt 包版本,原因:token 长回复报错 2023-04-03 16:19:38 +08:00
48 changed files with 21020 additions and 905 deletions

View File

@@ -13,10 +13,10 @@ jobs:
- uses: actions/stale@v5
with:
days-before-issue-stale: 10
days-before-issue-close: 5
days-before-issue-close: 2
stale-issue-label: stale
stale-issue-message: This issue is stale because it has been open for 10 days with no activity.
close-issue-message: This issue was closed because it has been inactive for 5 days since being marked as stale.
close-issue-message: This issue was closed because it has been inactive for 2 days since being marked as stale.
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,8 +1,45 @@
## v2.11.1
`2023-10-11`
## Enhancement
- 优化打字机光标效果
- 清空聊天历史按钮
- 更新文档
## BugFix
- 修复移动端上的问题
- 修复不规范的引入导致的问题
## v2.11.0
`2023-04-26`
> [chatgpt-web-plus](https://github.com/Chanzhaoyu/chatgpt-web-plus) 新界面、完整用户管理
## Enhancement
- 更新默认 `accessToken` 反代地址为 [[pengzhile](https://github.com/pengzhile)] 的 `https://ai.fakeopen.com/api/conversation` [[24min](https://github.com/Chanzhaoyu/chatgpt-web/pull/1567/files)]
- 添加自定义 `temperature``top_p` [[quzard](https://github.com/Chanzhaoyu/chatgpt-web/pull/1260)]
- 优化代码 [[shunyue1320](https://github.com/Chanzhaoyu/chatgpt-web/pull/1328)]
- 优化复制代码反馈效果
## BugFix
- 修复余额查询和文案 [[luckywangxi](https://github.com/Chanzhaoyu/chatgpt-web/pull/1174)][[zuoning777](https://github.com/Chanzhaoyu/chatgpt-web/pull/1296)]
- 修复默认语言错误 [[idawnwon](https://github.com/Chanzhaoyu/chatgpt-web/pull/1352)]
- 修复 `onRegenerate` 下问题 [[leafsummer](https://github.com/Chanzhaoyu/chatgpt-web/pull/1188)]
## Other
- 引导用户触发提示词 [[RyanXinOne](https://github.com/Chanzhaoyu/chatgpt-web/pull/1183)]
- 添加韩语翻译 [[Kamilake](https://github.com/Chanzhaoyu/chatgpt-web/pull/1372)]
- 添加俄语翻译 [[aquaratixc](https://github.com/Chanzhaoyu/chatgpt-web/pull/1571)]
- 优化翻译和文本检查 [[PeterDaveHello](https://github.com/Chanzhaoyu/chatgpt-web/pull/1460)]
- 移除无用文件
## v2.10.9
`2023-04-03`
> 更新默认 `accessToken` 反代地址为 [[acheong08](https://github.com/acheong08)] 的 `https://bypass.churchless.tech/api/conversation`
> 更新默认 `accessToken` 反代地址为 [[pengzhile](https://github.com/pengzhile)] 的 `https://ai.fakeopen.com/api/conversation`
## Enhancement
- 添加 `socks5` 代理认证 [[yimiaoxiehou](https://github.com/Chanzhaoyu/chatgpt-web/pull/999)]
@@ -101,7 +138,7 @@
`2023-03-13`
更新依赖,`access_token` 默认代理为 [acheong08](https://github.com/acheong08) 的 `https://bypass.duti.tech/api/conversation`
更新依赖,`access_token` 默认代理为 [pengzhile](https://github.com/pengzhile) 的 `https://bypass.duti.tech/api/conversation`
## Feature
- `Prompt` 商店在线导入可以导入两种 `recommend.json`里提到的模板 [simonwu53](https://github.com/Chanzhaoyu/chatgpt-web/pull/521)

View File

@@ -1,6 +1,6 @@
# ChatGPT Web
> 声明:此项目只发布于 Github基于 MIT 协议,免费且作为开源学习使用。并且不会有任何形式的卖号、付费服务、讨论群、讨论组等行为。谨防受骗。
> 声明:此项目只发布于 GitHub基于 MIT 协议,免费且作为开源学习使用。并且不会有任何形式的卖号、付费服务、讨论群、讨论组等行为。谨防受骗。
![cover](./docs/c1.png)
![cover2](./docs/c2.png)
@@ -24,8 +24,10 @@
- [Docker 参数示例](#docker-参数示例)
- [Docker build \& Run](#docker-build--run)
- [Docker compose](#docker-compose)
- [防止爬虫抓取](#防止爬虫抓取)
- [使用 Railway 部署](#使用-railway-部署)
- [Railway 环境变量](#railway-环境变量)
- [使用 Sealos 部署](#使用-sealos-部署)
- [手动打包](#手动打包)
- [后端服务](#后端服务-1)
- [前端网页](#前端网页-1)
@@ -50,7 +52,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` 时,不管你是国内还是国外的机器,都会使用代理。默认代理为 [pengzhile](https://github.com/pengzhile) 大佬的 `https://ai.fakeopen.com/api/conversation`,这不是后门也不是监听,除非你有能力自己翻过 `CF` 验证,用前请知悉。[社区代理](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
5. 把项目发布到公共网络时,你应该设置 `AUTH_SECRET_KEY` 变量添加你的密码访问权限,你也应该修改 `index.html` 中的 `title`,防止被关键词搜索到。
切换方式:
@@ -160,7 +162,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://ai.fakeopen.com/api/conversation`[社区](https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy)(注意:只有这两个是推荐,其他第三方来源,请自行甄别)
通用:
@@ -215,7 +217,7 @@ services:
# 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
# gpt-4, gpt-4-0314, gpt-4-0613, gpt-4-32k, gpt-4-32k-0314, gpt-4-32k-0613, gpt-3.5-turbo-16k, gpt-3.5-turbo-16k-0613, gpt-3.5-turbo, gpt-3.5-turbo-0301, gpt-3.5-turbo-0613, text-davinci-003, text-davinci-002, code-davinci-002
OPENAI_API_MODEL: xxx
# 反向代理,可选
API_REVERSE_PROXY: xxx
@@ -234,6 +236,21 @@ services:
```
- `OPENAI_API_BASE_URL` 可选,设置 `OPENAI_API_KEY` 时可用
- `OPENAI_API_MODEL` 可选,设置 `OPENAI_API_KEY` 时可用
#### 防止爬虫抓取
**nginx**
将下面配置填入nginx配置文件中可以参考 `docker-compose/nginx/nginx.conf` 文件中添加反爬虫的方法
```
# 防止爬虫抓取
if ($http_user_agent ~* "360Spider|JikeSpider|Spider|spider|bot|Bot|2345Explorer|curl|wget|webZIP|qihoobot|Baiduspider|Googlebot|Googlebot-Mobile|Googlebot-Image|Mediapartners-Google|Adsbot-Google|Feedfetcher-Google|Yahoo! Slurp|Yahoo! Slurp China|YoudaoBot|Sosospider|Sogou spider|Sogou web spider|MSNBot|ia_archiver|Tomato Bot|NSPlayer|bingbot")
{
return 403;
}
```
### 使用 Railway 部署
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/yytmgc)
@@ -260,6 +277,12 @@ services:
> 注意: `Railway` 修改环境变量会重新 `Deploy`
### 使用 Sealos 部署
[![](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://cloud.sealos.io/?openapp=system-fastdeploy%3FtemplateName%3Dchatgpt-web)
> 环境变量与 Docker 环境变量一致
### 手动打包
#### 后端服务
> 如果你不需要本项目的 `node` 接口,可以省略如下操作

View File

@@ -1 +0,0 @@
export * from './proxy'

View File

@@ -1,16 +0,0 @@
import type { ProxyOptions } from 'vite'
export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) {
if (!isOpenProxy)
return
const proxy: Record<string, string | ProxyOptions> = {
'/api': {
target: viteEnv.VITE_APP_API_BASE_URL,
changeOrigin: true,
rewrite: path => path.replace('/api/', '/'),
},
}
return proxy
}

View File

@@ -3,6 +3,13 @@ server {
server_name localhost;
charset utf-8;
error_page 500 502 503 504 /50x.html;
# 防止爬虫抓取
if ($http_user_agent ~* "360Spider|JikeSpider|Spider|spider|bot|Bot|2345Explorer|curl|wget|webZIP|qihoobot|Baiduspider|Googlebot|Googlebot-Mobile|Googlebot-Image|Mediapartners-Google|Adsbot-Google|Feedfetcher-Google|Yahoo! Slurp|Yahoo! Slurp China|YoudaoBot|Sosospider|Sogou spider|Sogou web spider|MSNBot|ia_archiver|Tomato Bot|NSPlayer|bingbot")
{
return 403;
}
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;

View File

@@ -30,7 +30,7 @@ spec:
- name: OPENAI_API_MODEL
value: gpt-3.5-turbo
- name: API_REVERSE_PROXY
value: https://bypass.churchless.tech/api/conversation
value: https://ai.fakeopen.com/api/conversation
- name: AUTH_SECRET_KEY
value: '123456'
- name: TIMEOUT_MS

19631
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "chatgpt-web",
"version": "2.10.9",
"version": "2.11.1",
"private": false,
"description": "ChatGPT Web",
"author": "ChenZhaoYu <chenzhaoyu1994@gmail.com>",

View File

@@ -14,7 +14,7 @@ OPENAI_API_MODEL=
OPENAI_API_DISABLE_DEBUG=
# Reverse Proxy - Available on accessToken
# Default: https://bypass.churchless.tech/api/conversation
# Default: https://ai.fakeopen.com/api/conversation
# More: https://github.com/transitive-bullshit/chatgpt-api#reverse-proxy
API_REVERSE_PROXY=

View File

@@ -25,7 +25,7 @@
},
"dependencies": {
"axios": "^1.3.4",
"chatgpt": "^5.2.2",
"chatgpt": "^5.1.2",
"dotenv": "^16.0.3",
"esno": "^0.16.3",
"express": "^4.18.2",

1231
service/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,10 @@ import { ChatGPTAPI, ChatGPTUnofficialProxyAPI } from 'chatgpt'
import { SocksProxyAgent } from 'socks-proxy-agent'
import httpsProxyAgent from 'https-proxy-agent'
import fetch from 'node-fetch'
import axios from 'axios'
import { sendResponse } from '../utils'
import { isNotEmptyString } from '../utils/is'
import type { ApiModel, ChatContext, ChatGPTUnofficialProxyAPIOptions, ModelConfig } from '../types'
import type { RequestOptions } from './types'
import type { RequestOptions, SetProxyOptions, UsageResponse } from './types'
const { HttpsProxyAgent } = httpsProxyAgent
@@ -24,10 +23,11 @@ const ErrorCodeMessage: Record<string, string> = {
500: '[OpenAI] 服务器繁忙,请稍后再试 | Internal Server Error',
}
const timeoutMs: number = !isNaN(+process.env.TIMEOUT_MS) ? +process.env.TIMEOUT_MS : 30 * 1000
const timeoutMs: number = !isNaN(+process.env.TIMEOUT_MS) ? +process.env.TIMEOUT_MS : 100 * 1000
const disableDebug: boolean = process.env.OPENAI_API_DISABLE_DEBUG === 'true'
let apiModel: ApiModel
const model = isNotEmptyString(process.env.OPENAI_API_MODEL) ? process.env.OPENAI_API_MODEL : 'gpt-3.5-turbo'
if (!isNotEmptyString(process.env.OPENAI_API_KEY) && !isNotEmptyString(process.env.OPENAI_ACCESS_TOKEN))
throw new Error('Missing OPENAI_API_KEY or OPENAI_ACCESS_TOKEN environment variable')
@@ -39,8 +39,6 @@ let api: ChatGPTAPI | ChatGPTUnofficialProxyAPI
if (isNotEmptyString(process.env.OPENAI_API_KEY)) {
const OPENAI_API_BASE_URL = process.env.OPENAI_API_BASE_URL
const OPENAI_API_MODEL = process.env.OPENAI_API_MODEL
const model = isNotEmptyString(OPENAI_API_MODEL) ? OPENAI_API_MODEL : 'gpt-3.5-turbo'
const options: ChatGPTAPIOptions = {
apiKey: process.env.OPENAI_API_KEY,
@@ -60,6 +58,12 @@ let api: ChatGPTAPI | ChatGPTUnofficialProxyAPI
options.maxResponseTokens = 2048
}
}
else if (model.toLowerCase().includes('gpt-3.5')) {
if (model.toLowerCase().includes('16k')) {
options.maxModelTokens = 16384
options.maxResponseTokens = 4096
}
}
if (isNotEmptyString(OPENAI_API_BASE_URL))
options.apiBaseUrl = `${OPENAI_API_BASE_URL}/v1`
@@ -70,19 +74,13 @@ let api: ChatGPTAPI | ChatGPTUnofficialProxyAPI
apiModel = 'ChatGPTAPI'
}
else {
const OPENAI_API_MODEL = process.env.OPENAI_API_MODEL
const options: ChatGPTUnofficialProxyAPIOptions = {
accessToken: process.env.OPENAI_ACCESS_TOKEN,
apiReverseProxyUrl: isNotEmptyString(process.env.API_REVERSE_PROXY) ? process.env.API_REVERSE_PROXY : 'https://ai.fakeopen.com/api/conversation',
model,
debug: !disableDebug,
}
if (isNotEmptyString(OPENAI_API_MODEL))
options.model = OPENAI_API_MODEL
options.apiReverseProxyUrl = isNotEmptyString(process.env.API_REVERSE_PROXY)
? process.env.API_REVERSE_PROXY
: 'https://bypass.churchless.tech/api/conversation'
setupProxy(options)
api = new ChatGPTUnofficialProxyAPI({ ...options })
@@ -91,13 +89,14 @@ let api: ChatGPTAPI | ChatGPTUnofficialProxyAPI
})()
async function chatReplyProcess(options: RequestOptions) {
const { message, lastContext, process, systemMessage } = options
const { message, lastContext, process, systemMessage, temperature, top_p } = options
try {
let options: SendMessageOptions = { timeoutMs }
if (apiModel === 'ChatGPTAPI') {
if (isNotEmptyString(systemMessage))
options.systemMessage = systemMessage
options.completionParams = { model, temperature, top_p }
}
if (lastContext != null) {
@@ -125,7 +124,7 @@ async function chatReplyProcess(options: RequestOptions) {
}
}
async function fetchBalance() {
async function fetchUsage() {
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
const OPENAI_API_BASE_URL = process.env.OPENAI_API_BASE_URL
@@ -136,19 +135,47 @@ async function fetchBalance() {
? OPENAI_API_BASE_URL
: 'https://api.openai.com'
try {
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` }
const response = await axios.get(`${API_BASE_URL}/dashboard/billing/credit_grants`, { headers })
const balance = response.data.total_available ?? 0
return Promise.resolve(balance.toFixed(3))
const [startDate, endDate] = formatDate()
// 每月使用量
const urlUsage = `${API_BASE_URL}/v1/dashboard/billing/usage?start_date=${startDate}&end_date=${endDate}`
const headers = {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
}
catch {
const options = {} as SetProxyOptions
setupProxy(options)
try {
// 获取已使用量
const useResponse = await options.fetch(urlUsage, { headers })
if (!useResponse.ok)
throw new Error('获取使用量失败')
const usageData = await useResponse.json() as UsageResponse
const usage = Math.round(usageData.total_usage) / 100
return Promise.resolve(usage ? `$${usage}` : '-')
}
catch (error) {
global.console.log(error)
return Promise.resolve('-')
}
}
function formatDate(): string[] {
const today = new Date()
const year = today.getFullYear()
const month = today.getMonth() + 1
const lastDay = new Date(year, month, 0)
const formattedFirstDay = `${year}-${month.toString().padStart(2, '0')}-01`
const formattedLastDay = `${year}-${month.toString().padStart(2, '0')}-${lastDay.getDate().toString().padStart(2, '0')}`
return [formattedFirstDay, formattedLastDay]
}
async function chatConfig() {
const balance = await fetchBalance()
const usage = await fetchUsage()
const reverseProxy = process.env.API_REVERSE_PROXY ?? '-'
const httpsProxy = (process.env.HTTPS_PROXY || process.env.ALL_PROXY) ?? '-'
const socksProxy = (process.env.SOCKS_PROXY_HOST && process.env.SOCKS_PROXY_PORT)
@@ -156,11 +183,11 @@ async function chatConfig() {
: '-'
return sendResponse<ModelConfig>({
type: 'Success',
data: { apiModel, reverseProxy, timeoutMs, socksProxy, httpsProxy, balance },
data: { apiModel, reverseProxy, timeoutMs, socksProxy, httpsProxy, usage },
})
}
function setupProxy(options: ChatGPTAPIOptions | ChatGPTUnofficialProxyAPIOptions) {
function setupProxy(options: SetProxyOptions) {
if (isNotEmptyString(process.env.SOCKS_PROXY_HOST) && isNotEmptyString(process.env.SOCKS_PROXY_PORT)) {
const agent = new SocksProxyAgent({
hostname: process.env.SOCKS_PROXY_HOST,
@@ -172,17 +199,20 @@ function setupProxy(options: ChatGPTAPIOptions | ChatGPTUnofficialProxyAPIOption
return fetch(url, { agent, ...options })
}
}
else {
if (isNotEmptyString(process.env.HTTPS_PROXY) || isNotEmptyString(process.env.ALL_PROXY)) {
const httpsProxy = process.env.HTTPS_PROXY || process.env.ALL_PROXY
if (httpsProxy) {
const agent = new HttpsProxyAgent(httpsProxy)
options.fetch = (url, options) => {
return fetch(url, { agent, ...options })
}
else if (isNotEmptyString(process.env.HTTPS_PROXY) || isNotEmptyString(process.env.ALL_PROXY)) {
const httpsProxy = process.env.HTTPS_PROXY || process.env.ALL_PROXY
if (httpsProxy) {
const agent = new HttpsProxyAgent(httpsProxy)
options.fetch = (url, options) => {
return fetch(url, { agent, ...options })
}
}
}
else {
options.fetch = (url, options) => {
return fetch(url, { ...options })
}
}
}
function currentModel(): ApiModel {

View File

@@ -1,8 +1,19 @@
import type { ChatMessage } from 'chatgpt'
import type fetch from 'node-fetch'
export interface RequestOptions {
message: string
lastContext?: { conversationId?: string; parentMessageId?: string }
process?: (chat: ChatMessage) => void
systemMessage?: string
temperature?: number
top_p?: number
}
export interface SetProxyOptions {
fetch?: typeof fetch
}
export interface UsageResponse {
total_usage: number
}

View File

@@ -23,7 +23,7 @@ router.post('/chat-process', [auth, limiter], async (req, res) => {
res.setHeader('Content-type', 'application/octet-stream')
try {
const { prompt, options = {}, systemMessage } = req.body as RequestProps
const { prompt, options = {}, systemMessage, temperature, top_p } = req.body as RequestProps
let firstChunk = true
await chatReplyProcess({
message: prompt,
@@ -33,6 +33,8 @@ router.post('/chat-process', [auth, limiter], async (req, res) => {
firstChunk = false
},
systemMessage,
temperature,
top_p,
})
}
catch (error) {

View File

@@ -4,6 +4,8 @@ export interface RequestProps {
prompt: string
options?: ChatContext
systemMessage: string
temperature?: number
top_p?: number
}
export interface ChatContext {
@@ -26,7 +28,7 @@ export interface ModelConfig {
timeoutMs?: number
socksProxy?: string
httpsProxy?: string
balance?: string
usage?: string
}
export type ApiModel = 'ChatGPTAPI' | 'ChatGPTUnofficialProxyAPI' | undefined

View File

@@ -1,6 +1,6 @@
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
import { post } from '@/utils/request'
import { useSettingStore } from '@/store'
import { useAuthStore, useSettingStore } from '@/store'
export function fetchChatAPI<T = any>(
prompt: string,
@@ -28,10 +28,25 @@ export function fetchChatAPIProcess<T = any>(
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void },
) {
const settingStore = useSettingStore()
const authStore = useAuthStore()
let data: Record<string, any> = {
prompt: params.prompt,
options: params.options,
}
if (authStore.isChatGPTAPI) {
data = {
...data,
systemMessage: settingStore.systemMessage,
temperature: settingStore.temperature,
top_p: settingStore.top_p,
}
}
return post<T>({
url: '/chat-process',
data: { prompt: params.prompt, options: params.options, systemMessage: settingStore.systemMessage },
data,
signal: params.signal,
onDownloadProgress: params.onDownloadProgress,
})

View File

@@ -1,8 +1,8 @@
<script setup lang='ts'>
import { computed, onMounted, ref } from 'vue'
import { NSpin } from 'naive-ui'
import pkg from '../../../../package.json'
import { fetchChatConfig } from '@/api'
import pkg from '@/../package.json'
import { useAuthStore } from '@/store'
interface ConfigState {
@@ -11,7 +11,7 @@ interface ConfigState {
apiModel?: string
socksProxy?: string
httpsProxy?: string
balance?: string
usage?: string
}
const authStore = useAuthStore()
@@ -52,17 +52,17 @@ onMounted(() => {
href="https://github.com/Chanzhaoyu/chatgpt-web"
target="_blank"
>
Github
GitHub
</a>
免费且基于 MIT 协议没有任何形式的付费行为
</p>
<p>
如果你觉得此项目对你有帮助请在 Github 帮我点个 Star 或者给予一点赞助谢谢
如果你觉得此项目对你有帮助请在 GitHub 帮我点个 Star 或者给予一点赞助谢谢
</p>
</div>
<p>{{ $t("setting.api") }}{{ config?.apiModel ?? '-' }}</p>
<p v-if="isChatGPTAPI">
{{ $t("setting.balance") }}{{ config?.balance ?? '-' }}
{{ $t("setting.monthlyUsage") }}{{ config?.usage ?? '-' }}
</p>
<p v-if="!isChatGPTAPI">
{{ $t("setting.reverseProxy") }}{{ config?.reverseProxy ?? '-' }}

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { NButton, NInput, useMessage } from 'naive-ui'
import { NButton, NInput, NSlider, useMessage } from 'naive-ui'
import { useSettingStore } from '@/store'
import type { SettingsState } from '@/store/modules/settings/helper'
import { t } from '@/locales'
@@ -11,6 +11,10 @@ const ms = useMessage()
const systemMessage = ref(settingStore.systemMessage ?? '')
const temperature = ref(settingStore.temperature ?? 0.5)
const top_p = ref(settingStore.top_p ?? 1)
function updateSettings(options: Partial<SettingsState>) {
settingStore.updateSetting(options)
ms.success(t('common.success'))
@@ -27,7 +31,7 @@ function handleReset() {
<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]">{{ $t('setting.role') }}</span>
<span class="flex-shrink-0 w-[120px]">{{ $t('setting.role') }}</span>
<div class="flex-1">
<NInput v-model:value="systemMessage" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
</div>
@@ -36,7 +40,27 @@ function handleReset() {
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[100px]">&nbsp;</span>
<span class="flex-shrink-0 w-[120px]">{{ $t('setting.temperature') }} </span>
<div class="flex-1">
<NSlider v-model:value="temperature" :max="2" :min="0" :step="0.1" />
</div>
<span>{{ temperature }}</span>
<NButton size="tiny" text type="primary" @click="updateSettings({ temperature })">
{{ $t('common.save') }}
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[120px]">{{ $t('setting.top_p') }} </span>
<div class="flex-1">
<NSlider v-model:value="top_p" :max="1" :min="0" :step="0.1" />
</div>
<span>{{ top_p }}</span>
<NButton size="tiny" text type="primary" @click="updateSettings({ top_p })">
{{ $t('common.save') }}
</NButton>
</div>
<div class="flex items-center space-x-4">
<span class="flex-shrink-0 w-[120px]">&nbsp;</span>
<NButton size="small" @click="handleReset">
{{ $t('common.reset') }}
</NButton>

View File

@@ -57,6 +57,8 @@ const languageOptions: { label: string; key: Language; value: Language }[] = [
{ label: '简体中文', key: 'zh-CN', value: 'zh-CN' },
{ label: '繁體中文', key: 'zh-TW', value: 'zh-TW' },
{ label: 'English', key: 'en-US', value: 'en-US' },
{ label: '한국어', key: 'ko-KR', value: 'ko-KR' },
{ label: 'Русский язык', key: 'ru-RU', value: 'ru-RU' },
]
function updateUserInfo(options: Partial<UserInfo>) {

View File

@@ -1,5 +1,5 @@
import { computed } from 'vue'
import { enUS, zhCN, zhTW } from 'naive-ui'
import { enUS, koKR, zhCN, zhTW } from 'naive-ui'
import { useAppStore } from '@/store'
import { setLocale } from '@/locales'
@@ -11,6 +11,12 @@ export function useLanguage() {
case 'en-US':
setLocale('en-US')
return enUS
case 'ru-RU':
setLocale('ru-RU')
return enUS
case 'ko-KR':
setLocale('ko-KR')
return koKR
case 'zh-CN':
setLocale('zh-CN')
return zhCN
@@ -19,7 +25,7 @@ export function useLanguage() {
return zhTW
default:
setLocale('zh-CN')
return enUS
return zhCN
}
})

View File

@@ -26,10 +26,11 @@ export default {
failed: 'Failed',
verify: 'Verify',
unauthorizedTips: 'Unauthorized, please verify first.',
stopResponding: 'Stop Responding',
},
chat: {
newChatButton: 'New Chat',
placeholder: 'Ask me anything...(Shift + Enter = line break)',
placeholder: 'Ask me anything...(Shift + Enter = line break, "/" to trigger prompts)',
placeholderMobile: 'Ask me anything...',
copy: 'Copy',
copied: 'Copied',
@@ -59,6 +60,8 @@ export default {
name: 'Name',
description: 'Description',
role: 'Role',
temperature: 'Temperature',
top_p: 'Top_p',
resetUserInfo: 'Reset UserInfo',
chatHistory: 'ChatHistory',
theme: 'Theme',
@@ -69,6 +72,7 @@ export default {
socks: 'Socks',
httpsProxy: 'HTTPS Proxy',
balance: 'API Balance',
monthlyUsage: 'Monthly Usage',
},
store: {
siderButton: 'Prompt Store',

View File

@@ -1,8 +1,10 @@
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enUS from './en-US'
import koKR from './ko-KR'
import zhCN from './zh-CN'
import zhTW from './zh-TW'
import ruRU from './ru-RU'
import { useAppStoreWithOut } from '@/store/modules/app'
import type { Language } from '@/store/modules/app/helper'
@@ -16,8 +18,10 @@ const i18n = createI18n({
allowComposition: true,
messages: {
'en-US': enUS,
'ko-KR': koKR,
'zh-CN': zhCN,
'zh-TW': zhTW,
'ru-RU': ruRU,
},
})

94
src/locales/ko-KR.ts Normal file
View File

@@ -0,0 +1,94 @@
export default {
common: {
add: '추가',
addSuccess: '추가 성공',
edit: '편집',
editSuccess: '편집 성공',
delete: '삭제',
deleteSuccess: '삭제 성공',
save: '저장',
saveSuccess: '저장 성공',
reset: '초기화',
action: '액션',
export: '내보내기',
exportSuccess: '내보내기 성공',
import: '가져오기',
importSuccess: '가져오기 성공',
clear: '비우기',
clearSuccess: '비우기 성공',
yes: '예',
no: '아니오',
confirm: '확인',
download: '다운로드',
noData: '데이터 없음',
wrong: '문제가 발생했습니다. 나중에 다시 시도하십시오.',
success: '성공',
failed: '실패',
verify: '검증',
unauthorizedTips: '인증되지 않았습니다. 먼저 확인하십시오.',
stopResponding: '응답 중지',
},
chat: {
newChatButton: '새로운 채팅',
placeholder: '무엇이든 물어보세요...(Shift + Enter = 줄바꿈, "/"를 눌러서 힌트를 보세요)',
placeholderMobile: '무엇이든 물어보세요...',
copy: '복사',
copied: '복사됨',
copyCode: '코드 복사',
clearChat: '채팅 비우기',
clearChatConfirm: '이 채팅을 비우시겠습니까?',
exportImage: '이미지 내보내기',
exportImageConfirm: '이 채팅을 png로 내보내시겠습니까?',
exportSuccess: '내보내기 성공',
exportFailed: '내보내기 실패',
usingContext: '컨텍스트 모드',
turnOnContext: '현재 모드에서는 이전 대화 기록을 포함하여 메시지를 보낼 수 있습니다.',
turnOffContext: '현재 모드에서는 이전 대화 기록을 포함하지 않고 메시지를 보낼 수 있습니다.',
deleteMessage: '메시지 삭제',
deleteMessageConfirm: '이 메시지를 삭제하시겠습니까?',
deleteHistoryConfirm: '이 기록을 삭제하시겠습니까?',
clearHistoryConfirm: '채팅 기록을 삭제하시겠습니까?',
preview: '미리보기',
showRawText: '원본 텍스트로 보기',
},
setting: {
setting: '설정',
general: '일반',
advanced: '고급',
config: '설정',
avatarLink: '아바타 링크',
name: '이름',
description: '설명',
role: '역할',
temperature: '온도',
top_p: 'Top_p',
resetUserInfo: '사용자 정보 초기화',
chatHistory: '채팅 기록',
theme: '테마',
language: '언어',
api: 'API',
reverseProxy: '리버스 프록시',
timeout: '타임아웃',
socks: 'Socks',
httpsProxy: 'HTTPS 프록시',
balance: 'API 잔액',
monthlyUsage: '월 사용량',
},
store: {
siderButton: '프롬프트 저장소',
local: '로컬',
online: '온라인',
title: '제목',
description: '설명',
clearStoreConfirm: '데이터를 삭제하시겠습니까?',
importPlaceholder: '여기에 JSON 데이터를 붙여넣으십시오',
addRepeatTitleTips: '제목 중복됨, 다시 입력하십시오',
addRepeatContentTips: '내용 중복됨: {msg}, 다시 입력하십시오',
editRepeatTitleTips: '제목 충돌, 수정하십시오',
editRepeatContentTips: '내용 충돌 {msg} , 수정하십시오',
importError: '키 값 불일치',
importRepeatTitle: '제목이 반복되어 건너뜀: {msg}',
importRepeatContent: '내용이 반복되어 건너뜀: {msg}',
onlineImportWarning: '참고: JSON 파일 소스를 확인하십시오!',
},
}

95
src/locales/ru-RU.ts Normal file
View File

@@ -0,0 +1,95 @@
export default {
common: {
add: 'Добавить',
addSuccess: 'Добавлено успешно',
edit: 'Редактировать',
editSuccess: 'Изменено успешно',
delete: 'Удалить',
deleteSuccess: 'Удалено успешно',
save: 'Сохранить',
saveSuccess: 'Сохранено успешно',
reset: 'Сбросить',
action: 'Действие',
export: 'Экспортировать',
exportSuccess: 'Экспорт выполнен успешно',
import: 'Импортировать',
importSuccess: 'Импорт выполнен успешно',
clear: 'Очистить',
clearSuccess: 'Очищено успешно',
yes: 'Да',
no: 'Нет',
confirm: 'Подтвердить',
download: 'Загрузить',
noData: 'Нет данных',
wrong: 'Что-то пошло не так, пожалуйста, повторите попытку позже.',
success: 'Успех',
failed: 'Не удалось',
verify: 'Проверить',
unauthorizedTips: 'Не авторизован, сначала подтвердите свою личность.',
stopResponding: 'Прекращение отклика',
},
chat: {
newChatButton: 'Новый чат',
placeholder: 'Спросите меня о чем-нибудь ... (Shift + Enter = перенос строки, "/" для вызова подсказок)',
placeholderMobile: 'Спросите меня о чем-нибудь ...',
copy: 'Копировать',
copied: 'Скопировано',
copyCode: 'Копировать код',
clearChat: 'Очистить чат',
clearChatConfirm: 'Вы уверены, что хотите очистить этот чат?',
exportImage: 'Экспорт в изображение',
exportImageConfirm: 'Вы уверены, что хотите экспортировать этот чат в формате PNG?',
exportSuccess: 'Экспортировано успешно',
exportFailed: 'Не удалось выполнить экспорт',
usingContext: 'Режим контекста',
turnOnContext: 'В текущем режиме отправка сообщений будет включать предыдущие записи чата.',
turnOffContext: 'В текущем режиме отправка сообщений не будет включать предыдущие записи чата.',
deleteMessage: 'Удалить сообщение',
deleteMessageConfirm: 'Вы уверены, что хотите удалить это сообщение?',
deleteHistoryConfirm: 'Вы уверены, что хотите очистить эту историю?',
clearHistoryConfirm: 'Вы уверены, что хотите очистить историю чата?',
preview: 'Предварительный просмотр',
showRawText: 'Показать как обычный текст',
},
setting: {
setting: 'Настройки',
general: 'Общее',
advanced: 'Дополнительно',
config: 'Конфигурация',
avatarLink: 'Ссылка на аватар',
name: 'Имя',
description: 'Описание',
role: 'Роль',
temperature: 'Температура',
top_p: 'Top_p',
resetUserInfo: 'Сбросить информацию о пользователе',
chatHistory: 'История чата',
theme: 'Тема',
language: 'Язык',
api: 'API',
reverseProxy: 'Обратный прокси-сервер',
timeout: 'Время ожидания',
socks: 'Socks',
httpsProxy: 'HTTPS-прокси',
balance: 'Баланс API',
monthlyUsage: 'Ежемесячное использование',
},
store: {
siderButton: 'Хранилище подсказок',
local: 'Локальное',
online: 'Онлайн',
title: 'Название',
description: 'Описание',
clearStoreConfirm: 'Вы действительно хотите очистить данные?',
importPlaceholder: 'Пожалуйста, вставьте здесь JSON-данные',
addRepeatTitleTips: 'Дубликат названия, пожалуйста, введите другое название',
addRepeatContentTips: 'Дубликат содержимого: {msg}, пожалуйста, введите другой текст',
editRepeatTitleTips: 'Конфликт названий, пожалуйста, измените название',
editRepeatContentTips: 'Конфликт содержимого {msg}, пожалуйста, измените текст',
importError: 'Не совпадает ключ-значение',
importRepeatTitle: 'Название повторяющееся, пропускается: {msg}',
importRepeatContent: 'Содержание повторяющееся, пропускается: {msg}',
onlineImportWarning: 'Внимание! Проверьте источник JSON-файла!',
downloadError: 'Проверьте состояние сети и правильность JSON-файла',
},
}

94
src/locales/vi-VN.ts Normal file
View File

@@ -0,0 +1,94 @@
export default {
common: {
add: 'Thêm',
addSuccess: 'Thêm thành công',
edit: 'Sửa',
editSuccess: 'Sửa thành công',
delete: 'Xóa',
deleteSuccess: 'Xóa thành công',
save: 'Lưu',
saveSuccess: 'Lưu thành công',
reset: 'Đặt lại',
action: 'Hành động',
export: 'Xuất',
exportSuccess: 'Xuất thành công',
import: 'Nhập',
importSuccess: 'Nhập thành công',
clear: 'Dọn dẹp',
clearSuccess: 'Dọn dẹp thành công',
yes: 'Có',
no: 'Không',
confirm: 'Xác nhận',
download: 'Tải xuống',
noData: 'Không có dữ liệu',
wrong: 'Đã xảy ra lỗi, vui lòng thử lại sau.',
success: 'Thành công',
failed: 'Thất bại',
verify: 'Xác minh',
unauthorizedTips: 'Không được ủy quyền, vui lòng xác minh trước.',
},
chat: {
newChatButton: 'Tạo hội thoại',
placeholder: 'Hỏi tôi bất cứ điều gì...(Shift + Enter = ngắt dòng, "/" to trigger prompts)',
placeholderMobile: 'Hỏi tôi bất cứ điều gì...',
copy: 'Sao chép',
copied: 'Đã sao chép',
copyCode: 'Sao chép Code',
clearChat: 'Clear Chat',
clearChatConfirm: 'Bạn có chắc chắn xóa cuộc trò chuyện này?',
exportImage: 'Xuất hình ảnh',
exportImageConfirm: 'Bạn có chắc chắn xuất cuộc trò chuyện này sang png không?',
exportSuccess: 'Xuất thành công',
exportFailed: 'Xuất thất bại',
usingContext: 'Context Mode',
turnOnContext: 'Ở chế độ hiện tại, việc gửi tin nhắn sẽ mang theo các bản ghi trò chuyện trước đó.',
turnOffContext: 'Ở chế độ hiện tại, việc gửi tin nhắn sẽ không mang theo các bản ghi trò chuyện trước đó.',
deleteMessage: 'Xóa tin nhắn',
deleteMessageConfirm: 'Bạn có chắc chắn xóa tin nhắn này?',
deleteHistoryConfirm: 'Bạn có chắc chắn để xóa lịch sử này?',
clearHistoryConfirm: 'Bạn có chắc chắn để xóa lịch sử trò chuyện?',
preview: 'Xem trước',
showRawText: 'Hiển thị dưới dạng văn bản thô',
},
setting: {
setting: 'Cài đặt',
general: 'Chung',
advanced: 'Nâng cao',
config: 'Cấu hình',
avatarLink: 'Avatar Link',
name: 'Tên',
description: 'Miêu tả',
role: 'Vai trò',
temperature: 'Nhiệt độ',
top_p: 'Top_p',
resetUserInfo: 'Đặt lại thông tin người dùng',
chatHistory: 'Lịch sử trò chuyện',
theme: 'Giao diện',
language: 'Ngôn ngữ',
api: 'API',
reverseProxy: 'Reverse Proxy',
timeout: 'Timeout',
socks: 'Socks',
httpsProxy: 'HTTPS Proxy',
balance: 'API Balance',
monthlyUsage: 'Sử dụng hàng tháng',
},
store: {
siderButton: 'Prompt Store',
local: 'Local',
online: 'Online',
title: 'Tiêu đề',
description: 'Miêu tả',
clearStoreConfirm: 'Cho dù để xóa dữ liệu?',
importPlaceholder: 'Vui lòng dán dữ liệu JSON vào đây',
addRepeatTitleTips: 'Tiêu đề trùng lặp, vui lòng nhập lại',
addRepeatContentTips: 'Nội dung trùng lặp: {msg}, vui lòng nhập lại',
editRepeatTitleTips: 'Xung đột tiêu đề, vui lòng sửa lại',
editRepeatContentTips: 'Xung đột nội dung {msg} , vui lòng sửa đổi lại',
importError: 'Key value mismatch',
importRepeatTitle: 'Tiêu đề liên tục bị bỏ qua: {msg}',
importRepeatContent: 'Nội dung liên tục bị bỏ qua: {msg}',
onlineImportWarning: 'Lưu ý: Vui lòng kiểm tra nguồn tệp JSON!',
downloadError: 'Vui lòng kiểm tra trạng thái mạng và tính hợp lệ của tệp JSON',
},
}

View File

@@ -26,10 +26,11 @@ export default {
failed: '操作失败',
verify: '验证',
unauthorizedTips: '未经授权,请先进行验证。',
stopResponding: '停止响应',
},
chat: {
newChatButton: '新建聊天',
placeholder: '来说点什么吧...Shift + Enter = 换行)',
placeholder: '来说点什么吧...Shift + Enter = 换行"/" 触发提示词',
placeholderMobile: '来说点什么...',
copy: '复制',
copied: '复制成功',
@@ -46,7 +47,7 @@ export default {
deleteMessage: '删除消息',
deleteMessageConfirm: '是否删除此消息?',
deleteHistoryConfirm: '确定删除此记录?',
clearHistoryConfirm: '确定清空聊天记录?',
clearHistoryConfirm: '确定清空记录?',
preview: '预览',
showRawText: '显示原文',
},
@@ -59,6 +60,8 @@ export default {
name: '名称',
description: '描述',
role: '角色设定',
temperature: 'Temperature',
top_p: 'Top_p',
resetUserInfo: '重置用户信息',
chatHistory: '聊天记录',
theme: '主题',
@@ -69,6 +72,7 @@ export default {
socks: 'Socks',
httpsProxy: 'HTTPS Proxy',
balance: 'API余额',
monthlyUsage: '本月使用量',
},
store: {
siderButton: '提示词商店',

View File

@@ -26,10 +26,11 @@ export default {
failed: '操作失敗',
verify: '驗證',
unauthorizedTips: '未經授權,請先進行驗證。',
stopResponding: '停止回應',
},
chat: {
newChatButton: '新對話',
placeholder: '來說點什麼...Shift + Enter = 換行)',
newChatButton: '新對話',
placeholder: '來說點什麼...Shift + Enter = 換行"/" 觸發提示詞',
placeholderMobile: '來說點什麼...',
copy: '複製',
copied: '複製成功',
@@ -53,12 +54,14 @@ export default {
setting: {
setting: '設定',
general: '總覽',
advanced: '高級',
advanced: '進階',
config: '設定',
avatarLink: '頭貼連結',
name: '名稱',
description: '描述',
role: '角色設定',
temperature: 'Temperature',
top_p: 'Top_p',
resetUserInfo: '重設使用者資訊',
chatHistory: '紀錄',
theme: '主題',
@@ -68,7 +71,8 @@ export default {
timeout: '逾時',
socks: 'Socks',
httpsProxy: 'HTTPS Proxy',
balance: 'API額',
balance: 'API Credit 餘額',
monthlyUsage: '本月使用量',
},
store: {
siderButton: '提示詞商店',

3
src/store/helper.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const store = createPinia()

View File

@@ -1,7 +1,5 @@
import type { App } from 'vue'
import { createPinia } from 'pinia'
export const store = createPinia()
import { store } from './helper'
export function setupStore(app: App) {
app.use(store)

View File

@@ -4,7 +4,7 @@ const LOCAL_NAME = 'appSetting'
export type Theme = 'light' | 'dark' | 'auto'
export type Language = 'zh-CN' | 'zh-TW' | 'en-US'
export type Language = 'zh-CN' | 'zh-TW' | 'en-US' | 'ko-KR' | 'ru-RU'
export interface AppState {
siderCollapsed: boolean

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import type { AppState, Language, Theme } from './helper'
import { getLocalSetting, setLocalSetting } from './helper'
import { store } from '@/store'
import { store } from '@/store/helper'
export const useAppStore = defineStore('app-store', {
state: (): AppState => getLocalSetting(),

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { getToken, removeToken, setToken } from './helper'
import { store } from '@/store'
import { store } from '@/store/helper'
import { fetchSession } from '@/api'
interface SessionResponse {

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { getLocalState, setLocalState } from './helper'
import { defaultState, getLocalState, setLocalState } from './helper'
import { router } from '@/router'
export const useChatStore = defineStore('chat-store', {
@@ -182,6 +182,11 @@ export const useChatStore = defineStore('chat-store', {
}
},
clearHistory() {
this.$state = { ...defaultState() }
this.recordState()
},
async reloadRoute(uuid?: number) {
this.recordState()
await router.push({ name: 'Chat', params: { uuid } })

View File

@@ -4,11 +4,15 @@ const LOCAL_NAME = 'settingsStorage'
export interface SettingsState {
systemMessage: string
temperature: number
top_p: number
}
export function defaultSetting(): SettingsState {
return {
systemMessage: 'You are ChatGPT, a large language model trained by OpenAI. Follow the user\'s instructions carefully. Respond using markdown.',
temperature: 0.8,
top_p: 1,
}
}

View File

@@ -17,7 +17,7 @@ export function defaultSetting(): UserState {
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>',
description: 'Star on <a href="https://github.com/Chanzhaoyu/chatgpt-bot" class="text-blue-500" target="_blank" >GitHub</a>',
},
}
}

18
src/utils/copy.ts Normal file
View File

@@ -0,0 +1,18 @@
export function copyToClip(text: string) {
return new Promise((resolve, reject) => {
try {
const input: HTMLTextAreaElement = document.createElement('textarea')
input.setAttribute('readonly', 'readonly')
input.value = text
document.body.appendChild(input)
input.select()
if (document.execCommand('copy'))
document.execCommand('copy')
document.body.removeChild(input)
resolve(text)
}
catch (error) {
reject(error)
}
})
}

View File

@@ -1,18 +0,0 @@
import CryptoJS from 'crypto-js'
const CryptoSecret = '__CRYPTO_SECRET__'
export function enCrypto(data: any) {
const str = JSON.stringify(data)
return CryptoJS.AES.encrypt(str, CryptoSecret).toString()
}
export function deCrypto(data: string) {
const bytes = CryptoJS.AES.decrypt(data, CryptoSecret)
const str = bytes.toString(CryptoJS.enc.Utf8)
if (str)
return JSON.parse(str)
return null
}

View File

@@ -1,44 +0,0 @@
/**
* 转义 HTML 字符
* @param source
*/
export function encodeHTML(source: string) {
return source
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* 判断是否为代码块
* @param text
*/
export function includeCode(text: string | null | undefined) {
const regexp = /^(?:\s{4}|\t).+/gm
return !!(text?.includes(' = ') || text?.match(regexp))
}
/**
* 复制文本
* @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.value = props.text
document.body.appendChild(input)
input.select()
if (document.execCommand('copy'))
document.execCommand('copy')
document.body.removeChild(input)
}

View File

@@ -1 +1,57 @@
export * from './local'
interface StorageData<T = any> {
data: T
expire: number | null
}
export function createLocalStorage(options?: { expire?: number | null }) {
const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7
const { expire } = Object.assign({ expire: DEFAULT_CACHE_TIME }, options)
function set<T = any>(key: string, data: T) {
const storageData: StorageData<T> = {
data,
expire: expire !== null ? new Date().getTime() + expire * 1000 : null,
}
const json = JSON.stringify(storageData)
window.localStorage.setItem(key, json)
}
function get(key: string) {
const json = window.localStorage.getItem(key)
if (json) {
let storageData: StorageData | null = null
try {
storageData = JSON.parse(json)
}
catch {
// Prevent failure
}
if (storageData) {
const { data, expire } = storageData
if (expire === null || expire >= Date.now())
return data
}
remove(key)
return null
}
}
function remove(key: string) {
window.localStorage.removeItem(key)
}
function clear() {
window.localStorage.clear()
}
return { set, get, remove, clear }
}
export const ls = createLocalStorage()
export const ss = createLocalStorage({ expire: null })

View File

@@ -1,70 +0,0 @@
import { deCrypto, enCrypto } from '../crypto'
interface StorageData<T = any> {
data: T
expire: number | null
}
export function createLocalStorage(options?: { expire?: number | null; crypto?: boolean }) {
const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7
const { expire, crypto } = Object.assign(
{
expire: DEFAULT_CACHE_TIME,
crypto: true,
},
options,
)
function set<T = any>(key: string, data: T) {
const storageData: StorageData<T> = {
data,
expire: expire !== null ? new Date().getTime() + expire * 1000 : null,
}
const json = crypto ? enCrypto(storageData) : JSON.stringify(storageData)
window.localStorage.setItem(key, json)
}
function get(key: string) {
const json = window.localStorage.getItem(key)
if (json) {
let storageData: StorageData | null = null
try {
storageData = crypto ? deCrypto(json) : JSON.parse(json)
}
catch {
// Prevent failure
}
if (storageData) {
const { data, expire } = storageData
if (expire === null || expire >= Date.now())
return data
}
remove(key)
return null
}
}
function remove(key: string) {
window.localStorage.removeItem(key)
}
function clear() {
window.localStorage.clear()
}
return {
set,
get,
remove,
clear,
}
}
export const ls = createLocalStorage()
export const ss = createLocalStorage({ expire: null, crypto: false })

View File

@@ -9,7 +9,7 @@ interface Props {
interface Emit {
(ev: 'export'): void
(ev: 'toggleUsingContext'): void
(ev: 'handleClear'): void
}
defineProps<Props>()
@@ -36,8 +36,8 @@ function handleExport() {
emit('export')
}
function toggleUsingContext() {
emit('toggleUsingContext')
function handleClear() {
emit('handleClear')
}
</script>
@@ -62,16 +62,16 @@ function toggleUsingContext() {
{{ 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>
<HoverButton @click="handleClear">
<span class="text-xl text-[#4f555e] dark:text-white">
<SvgIcon icon="ri:delete-bin-line" />
</span>
</HoverButton>
</div>
</div>
</header>

View File

@@ -1,11 +1,12 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, onMounted, onUnmounted, onUpdated, 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'
import { copyToClip } from '@/utils/copy'
interface Props {
inversion?: boolean
@@ -22,6 +23,7 @@ const { isMobile } = useBasicLayout()
const textRef = ref<HTMLElement>()
const mdi = new MarkdownIt({
html: false,
linkify: true,
highlight(code, language) {
const validLang = !!(language && hljs.getLanguage(language))
@@ -60,16 +62,53 @@ 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 })
function addCopyEvents() {
if (textRef.value) {
const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
copyBtn.forEach((btn) => {
btn.addEventListener('click', () => {
const code = btn.parentElement?.nextElementSibling?.textContent
if (code) {
copyToClip(code).then(() => {
btn.textContent = '复制成功'
setTimeout(() => {
btn.textContent = '复制代码'
}, 1000)
})
}
})
})
}
}
function removeCopyEvents() {
if (textRef.value) {
const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
copyBtn.forEach((btn) => {
btn.removeEventListener('click', () => {})
})
}
}
onMounted(() => {
addCopyEvents()
})
onUpdated(() => {
addCopyEvents()
})
onUnmounted(() => {
removeCopyEvents()
})
</script>
<template>
<div class="text-black" :class="wrapClass">
<div ref="textRef" class="leading-relaxed break-words">
<div v-if="!inversion" class="flex items-end">
<div v-if="!asRawText" class="w-full markdown-body" v-html="text" />
<div v-else class="w-full whitespace-pre-wrap" v-text="text" />
<span v-if="loading" class="dark:text-white w-[4px] h-[20px] block animate-blink" />
<div v-if="!inversion">
<div v-if="!asRawText" class="markdown-body" :class="{ 'markdown-body-generate': loading }" v-html="text" />
<div v-else class="whitespace-pre-wrap" v-text="text" />
</div>
<div v-else class="whitespace-pre-wrap" v-text="text" />
</div>

View File

@@ -1,13 +1,13 @@
<script setup lang='ts'>
import { computed, ref } from 'vue'
import { NDropdown } from 'naive-ui'
import { NDropdown, useMessage } 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'
import { copyToClip } from '@/utils/copy'
interface Props {
dateTime?: string
@@ -30,6 +30,8 @@ const { isMobile } = useBasicLayout()
const { iconRender } = useIconRender()
const message = useMessage()
const textRef = ref<HTMLElement>()
const asRawText = ref(props.inversion)
@@ -64,7 +66,7 @@ const options = computed(() => {
function handleSelect(key: 'copyText' | 'delete' | 'toggleRenderType') {
switch (key) {
case 'copyText':
copyText({ text: props.text ?? '' })
handleCopy()
return
case 'toggleRenderType':
asRawText.value = !asRawText.value
@@ -78,6 +80,16 @@ function handleRegenerate() {
messageRef.value?.scrollIntoView()
emit('regenerate')
}
async function handleCopy() {
try {
await copyToClip(props.text || '')
message.success('复制成功')
}
catch {
message.error('复制失败')
}
}
</script>
<template>

View File

@@ -57,10 +57,60 @@
}
}
&.markdown-body-generate>dd:last-child:after,
&.markdown-body-generate>dl:last-child:after,
&.markdown-body-generate>dt:last-child:after,
&.markdown-body-generate>h1:last-child:after,
&.markdown-body-generate>h2:last-child:after,
&.markdown-body-generate>h3:last-child:after,
&.markdown-body-generate>h4:last-child:after,
&.markdown-body-generate>h5:last-child:after,
&.markdown-body-generate>h6:last-child:after,
&.markdown-body-generate>li:last-child:after,
&.markdown-body-generate>ol:last-child li:last-child:after,
&.markdown-body-generate>p:last-child:after,
&.markdown-body-generate>pre:last-child code:after,
&.markdown-body-generate>td:last-child:after,
&.markdown-body-generate>ul:last-child li:last-child:after {
animation: blink 1s steps(5, start) infinite;
color: #000;
content: '_';
font-weight: 700;
margin-left: 3px;
vertical-align: baseline;
}
@keyframes blink {
to {
visibility: hidden;
}
}
}
html.dark {
.markdown-body {
&.markdown-body-generate>dd:last-child:after,
&.markdown-body-generate>dl:last-child:after,
&.markdown-body-generate>dt:last-child:after,
&.markdown-body-generate>h1:last-child:after,
&.markdown-body-generate>h2:last-child:after,
&.markdown-body-generate>h3:last-child:after,
&.markdown-body-generate>h4:last-child:after,
&.markdown-body-generate>h5:last-child:after,
&.markdown-body-generate>h6:last-child:after,
&.markdown-body-generate>li:last-child:after,
&.markdown-body-generate>ol:last-child li:last-child:after,
&.markdown-body-generate>p:last-child:after,
&.markdown-body-generate>pre:last-child code:after,
&.markdown-body-generate>td:last-child:after,
&.markdown-body-generate>ul:last-child li:last-child:after {
color: #65a665;
}
}
.message-reply {
.whitespace-pre-wrap {
white-space: pre-wrap;
@@ -73,3 +123,13 @@ html.dark {
background-color: #282c34;
}
}
@media screen and (max-width: 533px) {
.markdown-body .code-block-wrapper {
padding: unset;
code {
padding: 24px 16px 16px 16px;
}
}
}

View File

@@ -1,24 +0,0 @@
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())
}

View File

@@ -8,7 +8,6 @@ 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'
@@ -27,8 +26,6 @@ const ms = useMessage()
const chatStore = useChatStore()
useCopyCode()
const { isMobile } = useBasicLayout()
const { addChat, updateChat, updateChatSome, getChatByUuidAndIndex } = useChat()
const { scrollRef, scrollToBottom, scrollToBottomIfAtBottom } = useScroll()
@@ -37,7 +34,7 @@ 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 conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !!item.conversationOptions)))
const prompt = ref<string>('')
const loading = ref<boolean>(false)
@@ -96,7 +93,7 @@ async function onConversation() {
+uuid,
{
dateTime: new Date().toLocaleString(),
text: '',
text: '思考中',
loading: true,
inversion: false,
error: false,
@@ -233,7 +230,7 @@ async function onRegenerate(index: number) {
error: false,
loading: true,
conversationOptions: null,
requestOptions: { prompt: message, ...options },
requestOptions: { prompt: message, options: { ...options } },
},
)
@@ -264,7 +261,7 @@ async function onRegenerate(index: number) {
error: false,
loading: true,
conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id },
requestOptions: { prompt: message, ...options },
requestOptions: { prompt: message, options: { ...options } },
},
)
@@ -308,7 +305,7 @@ async function onRegenerate(index: number) {
error: true,
loading: false,
conversationOptions: null,
requestOptions: { prompt: message, ...options },
requestOptions: { prompt: message, options: { ...options } },
},
)
}
@@ -472,7 +469,7 @@ onUnmounted(() => {
v-if="isMobile"
:using-context="usingContext"
@export="handleExport"
@toggle-using-context="toggleUsingContext"
@handle-clear="handleClear"
/>
<main class="flex-1 overflow-hidden">
<div id="scrollRef" ref="scrollRef" class="h-full overflow-hidden overflow-y-auto">
@@ -505,7 +502,7 @@ onUnmounted(() => {
<template #icon>
<SvgIcon icon="ri:stop-circle-line" />
</template>
Stop Responding
{{ t('common.stopResponding') }}
</NButton>
</div>
</div>
@@ -516,7 +513,7 @@ onUnmounted(() => {
<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">
<HoverButton v-if="!isMobile" @click="handleClear">
<span class="text-xl text-[#4f555e] dark:text-white">
<SvgIcon icon="ri:delete-bin-line" />
</span>
@@ -526,7 +523,7 @@ onUnmounted(() => {
<SvgIcon icon="ri:download-2-line" />
</span>
</HoverButton>
<HoverButton v-if="!isMobile" @click="toggleUsingContext">
<HoverButton @click="toggleUsingContext">
<span class="text-xl" :class="{ 'text-[#4b9e5f]': usingContext, 'text-[#a8071a]': !usingContext }">
<SvgIcon icon="ri:chat-history-line" />
</span>

View File

@@ -1,16 +1,19 @@
<script setup lang='ts'>
import type { CSSProperties } from 'vue'
import { computed, ref, watch } from 'vue'
import { NButton, NLayoutSider } from 'naive-ui'
import { NButton, NLayoutSider, useDialog } 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'
import { PromptStore, SvgIcon } from '@/components/common'
import { t } from '@/locales'
const appStore = useAppStore()
const chatStore = useChatStore()
const dialog = useDialog()
const { isMobile } = useBasicLayout()
const show = ref(false)
@@ -26,6 +29,20 @@ function handleUpdateCollapsed() {
appStore.setSiderCollapsed(!collapsed.value)
}
function handleClearAll() {
dialog.warning({
title: t('chat.deleteMessage'),
content: t('chat.clearHistoryConfirm'),
positiveText: t('common.yes'),
negativeText: t('common.no'),
onPositiveClick: () => {
chatStore.clearHistory()
if (isMobile.value)
appStore.setSiderCollapsed(true)
},
})
}
const getMobileClass = computed<CSSProperties>(() => {
if (isMobile.value) {
return {
@@ -79,9 +96,14 @@ watch(
<div class="flex-1 min-h-0 pb-4 overflow-hidden">
<List />
</div>
<div class="p-4">
<NButton block @click="show = true">
{{ $t('store.siderButton') }}
<div class="flex items-center p-4 space-x-4">
<div class="flex-1">
<NButton block @click="show = true">
{{ $t('store.siderButton') }}
</NButton>
</div>
<NButton @click="handleClearAll">
<SvgIcon icon="ri:close-circle-line" />
</NButton>
</div>
</main>
@@ -89,7 +111,7 @@ watch(
</div>
</NLayoutSider>
<template v-if="isMobile">
<div v-show="!collapsed" class="fixed inset-0 z-40 bg-black/40" @click="handleUpdateCollapsed" />
<div v-show="!collapsed" class="fixed inset-0 z-40 w-full h-full bg-black/40" @click="handleUpdateCollapsed" />
</template>
<PromptStore v-model:visible="show" />
</template>