Compare commits

..

61 Commits

Author SHA1 Message Date
fred-bf
52316785d1 Merge branch 'main' into feat/voice-input 2024-03-19 17:52:00 +08:00
fred-bf
3ba984d09e Merge pull request #4306 from H0llyW00dzZ/simplify-cherry-pick
[Cherry Pick] Improve [Utils] Check Vision Model
2024-03-19 17:45:57 +08:00
fred-bf
f274683d46 Merge pull request #4322 from imraax/dev
Fix "Enter" bug
2024-03-19 17:44:41 +08:00
fred-bf
e20ce8e335 Merge pull request #4339 from ChatGPTNextWeb/fred-bf-patch-2
feat: update vercel deploy env
2024-03-18 18:25:24 +08:00
fred-bf
9fd750511c feat: update vercel deploy env 2024-03-18 18:24:48 +08:00
Fred
e2b15f785a feat: update voice input button 2024-03-18 17:53:59 +08:00
Raax
028957fcdc Fix "Enter" bug
Fix Chinese input method "Enter" on Safari
2024-03-16 21:55:16 +08:00
H0llyW00dzZ
a4c54cae60 Improve [Utils] Check Vision Model
- [+] refactor(utils.ts): improve isVisionModel function to use array.some instead of model.includes
2024-03-15 09:38:42 +07:00
fred-bf
cc0eae7153 Merge pull request #4288 from fred-bf/fix/migrate-proxy-url
fix: auto migrate proxy config
2024-03-14 03:05:08 +08:00
Fred
066ca9e552 fix: auto migrate proxy config 2024-03-14 03:03:46 +08:00
fred-bf
7c04a90d77 Merge pull request #4287 from fred-bf/main
feat: bump version
2024-03-14 02:30:58 +08:00
fred-bf
a8a65ac769 Merge branch 'ChatGPTNextWeb:main' into main 2024-03-14 02:30:22 +08:00
Fred
aec3c5d6cc feat: bump version 2024-03-14 02:29:31 +08:00
fred-bf
a22141c2eb Merge pull request #4285 from fred-bf/fix/cors-ssrf
[Bugfix] Fix CORS SSRF security issue
2024-03-14 02:27:55 +08:00
Fred
99aa064319 fix: fix webdav sync issue 2024-03-14 01:58:25 +08:00
Fred
6aaf83f3c2 fix: fix upstash sync issue 2024-03-14 01:56:36 +08:00
Fred
133ce39a13 chore: update cors default path 2024-03-14 01:33:41 +08:00
Fred
8645214654 fix: change matching pattern 2024-03-14 01:26:13 +08:00
Fred
eebc334e02 fix: remove corsFetch 2024-03-14 00:57:54 +08:00
Fred
038fa3b301 fix: add webdav request filter 2024-03-14 00:33:26 +08:00
Fred
9a8497299d fix: adjust upstash api 2024-03-13 23:58:28 +08:00
Fred
42d04d473e fix: update ui 2024-03-13 23:06:35 +08:00
fred-bf
61ce3868b5 Merge pull request #4279 from SukkaW/package-json-corepack
chore: specify yarn 1 in package.json
2024-03-13 20:09:57 +08:00
SukkaW
844c2a26bc chore: specify yarn 1 in package.json 2024-03-13 13:30:16 +08:00
fred-bf
a15c4d9c20 Merge pull request #4234 from fengzai6/main
Fix EmojiPicker mobile width adaptation and update avatar clicking behavior
2024-03-11 13:59:09 +08:00
fred-bf
ff9f0e60ac Merge pull request #3972 from greenjerry/fix-export-garbled
fix: 修复导出时字符乱码问题
2024-03-07 17:07:16 +08:00
fred-bf
2bf6111bf5 Merge branch 'main' into fix-export-garbled 2024-03-07 17:07:08 +08:00
fengzai6
ad10a11903 Add z-index to avatar 2024-03-07 15:51:58 +08:00
fengzai6
c22153a4eb Revert "fix: No history message attached when for gemini-pro-vision"
This reverts commit c197962851.
2024-03-07 15:46:13 +08:00
fengzai6
5348d57057 Fix EmojiPicker mobile width adaptation and update avatar clicking behavior 2024-03-07 15:36:19 +08:00
fengzai6
052524dabd Merge remote-tracking branch 'upstream/main' 2024-03-07 15:32:09 +08:00
fred-bf
5529ece220 Merge pull request #4218 from ChatGPTNextWeb/fred-bf-patch-1
chore: update GTM_ID definition
2024-03-05 17:37:22 +08:00
fred-bf
e71094d4a8 chore: update GTM_ID definition, close #4217 2024-03-05 17:36:52 +08:00
Fred
2f53107581 feat: init voice support 2024-03-04 20:04:19 +08:00
fred-bf
98aa023d70 Merge pull request #4195 from aliceric27/main
slightly polishes the tw text.
2024-03-04 19:03:23 +08:00
aliceric27
e1066434d0 fix some text 2024-03-03 00:23:00 +08:00
aliceric27
86ae4b2a75 slightly polishes the tw text. 2024-03-02 23:58:23 +08:00
fred-bf
99fb9dcf11 Merge pull request #4164 from KSnow616/main
feat: Pasting images into the textbox
2024-02-29 22:14:02 +08:00
fred-bf
1294817103 Merge pull request #4089 from H0llyW00dzZ/cherry-pick
[Cherry Pick] Fix [Utils] Regex trimTopic
2024-02-29 16:31:30 +08:00
Snow Kawashiro
9775660da7 Update chat.tsx 2024-02-28 20:45:42 +08:00
Snow Kawashiro
e7051353eb vision_model_only 2024-02-28 20:38:00 +08:00
Snow Kawashiro
bd19e97cf8 add_image_pasting 2024-02-28 20:05:13 +08:00
fred-bf
8b821ac0c9 Merge pull request #4162 from fred-bf/fix/identify-vision-model
fix: fix the method to detect vision model
2024-02-28 11:35:22 +08:00
Fred
43e5dc2292 fix: fix the method to detect vision model 2024-02-28 11:33:43 +08:00
fred-bf
08fa22749a fix: add max_tokens when using vision model (#4157) 2024-02-27 17:28:01 +08:00
fengzai6
c197962851 fix: No history message attached when for gemini-pro-vision 2024-02-27 15:02:58 +08:00
fred-bf
44a51273be Merge pull request #4149 from fred-bf/feat/auto-detach-scrolling
feat: auto detach scrolling
2024-02-27 11:56:37 +08:00
Fred
e3b3ae97bc chore: clear scroll info 2024-02-27 11:49:44 +08:00
Fred
410a22dc63 feat: auto detach scrolling 2024-02-27 11:43:40 +08:00
Algorithm5838
069766d581 Correct cutoff dates (#4118) 2024-02-27 10:28:54 +08:00
DonaldBear
f22e36e52f feat(tw.ts): added new translations (#4142)
* feat(tw.ts): added new translations

I have translated previously untranslated text in response to the latest update.

* feat(tw.ts): added new translations

I have translated previously untranslated text in response to the latest update.
2024-02-27 00:16:56 +08:00
fred-bf
bc1794fb4a feat: bump version (#4133) 2024-02-26 18:15:00 +08:00
Fred
aacd26c7db feat: bump version 2024-02-26 18:14:10 +08:00
fred-bf
ff166f7b4c chore: adjust for ollama support (#4129) 2024-02-26 17:18:46 +08:00
H0llyW00dzZ
22baebaf8c [Cherry Pick] Fix [Utils] Regex trimTopic
- [+] fix(utils.ts): update regular expressions in trimTopic function to handle asterisks
2024-02-21 04:19:12 +07:00
H0llyW00dzZ
e756506c18 [Cherry Pick] Improve Github Issue Template (#4041)
* Improve Github Issue Template

- [+] feat(issue templates): update issue templates from markdown to form schema
- [+] feat(issue templates): translate issue templates to Chinese
- [+] remove(issue templates): delete old markdown issue templates

* chore: remove Chinese template issue temporarily

---------

Co-authored-by: Fred <fred@nextchat.dev>
2024-02-20 18:11:02 +08:00
Qiying Wang
fd67f980a5 Fix temperature range (#4083) 2024-02-20 18:05:17 +08:00
TheRam_
e2da3406d2 Add vision support (#4076) 2024-02-20 18:04:32 +08:00
Ikko Eltociear Ashimine
05b6d989b6 chore: fix typo in next.config.mjs (#4072)
verison -> version
2024-02-20 17:59:59 +08:00
H0llyW00dzZ
1d6ee64e1d [Cherry Pick] Fix [UI/UX] [Front End] Settings Page (#4032)
* Fix [UI/UX] [Locales] Correct Spelling

- [+] fix(locales): correct spelling and improve wording in cn.ts and en.ts locale files

* Fix [UI/UX] [Front End] Settings Page

- [+] fix(settings.tsx): correct typo in ApiVerion to ApiVersion
- [+] refactor(settings.tsx): switch Azure.ApiKey to Google.ApiKey in ListItem title and subTitle

* Fix [UI/UX] [Locales] [SK] Correct Typo

- [+] fix(sk.ts): correct typo in ApiVersion key in Slovak locale file
2024-02-12 20:36:52 +08:00
greenjerry
bf711f2ad7 修复导出json和markdown时中文及其他utf8字符乱码问题 2024-02-02 13:58:06 +08:00
51 changed files with 2852 additions and 1227 deletions

View File

@@ -1,43 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug] "
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Deployment**
- [ ] Docker
- [ ] Vercel
- [ ] Server
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional Logs**
Add any logs about the problem here.

146
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,146 @@
name: Bug report
description: Create a report to help us improve
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: "## Describe the bug"
- type: textarea
id: bug-description
attributes:
label: "Bug Description"
description: "A clear and concise description of what the bug is."
placeholder: "Explain the bug..."
validations:
required: true
- type: markdown
attributes:
value: "## To Reproduce"
- type: textarea
id: steps-to-reproduce
attributes:
label: "Steps to Reproduce"
description: "Steps to reproduce the behavior:"
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: markdown
attributes:
value: "## Expected behavior"
- type: textarea
id: expected-behavior
attributes:
label: "Expected Behavior"
description: "A clear and concise description of what you expected to happen."
placeholder: "Describe what you expected to happen..."
validations:
required: true
- type: markdown
attributes:
value: "## Screenshots"
- type: textarea
id: screenshots
attributes:
label: "Screenshots"
description: "If applicable, add screenshots to help explain your problem."
placeholder: "Paste your screenshots here or write 'N/A' if not applicable..."
validations:
required: false
- type: markdown
attributes:
value: "## Deployment"
- type: checkboxes
id: deployment
attributes:
label: "Deployment Method"
description: "Please select the deployment method you are using."
options:
- label: "Docker"
- label: "Vercel"
- label: "Server"
- type: markdown
attributes:
value: "## Desktop (please complete the following information):"
- type: input
id: desktop-os
attributes:
label: "Desktop OS"
description: "Your desktop operating system."
placeholder: "e.g., Windows 10"
validations:
required: false
- type: input
id: desktop-browser
attributes:
label: "Desktop Browser"
description: "Your desktop browser."
placeholder: "e.g., Chrome, Safari"
validations:
required: false
- type: input
id: desktop-version
attributes:
label: "Desktop Browser Version"
description: "Version of your desktop browser."
placeholder: "e.g., 89.0"
validations:
required: false
- type: markdown
attributes:
value: "## Smartphone (please complete the following information):"
- type: input
id: smartphone-device
attributes:
label: "Smartphone Device"
description: "Your smartphone device."
placeholder: "e.g., iPhone X"
validations:
required: false
- type: input
id: smartphone-os
attributes:
label: "Smartphone OS"
description: "Your smartphone operating system."
placeholder: "e.g., iOS 14.4"
validations:
required: false
- type: input
id: smartphone-browser
attributes:
label: "Smartphone Browser"
description: "Your smartphone browser."
placeholder: "e.g., Safari"
validations:
required: false
- type: input
id: smartphone-version
attributes:
label: "Smartphone Browser Version"
description: "Version of your smartphone browser."
placeholder: "e.g., 14"
validations:
required: false
- type: markdown
attributes:
value: "## Additional Logs"
- type: textarea
id: additional-logs
attributes:
label: "Additional Logs"
description: "Add any logs about the problem here."
placeholder: "Paste any relevant logs here..."
validations:
required: false

View File

@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature] "
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,53 @@
name: Feature request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: "## Is your feature request related to a problem? Please describe."
- type: textarea
id: problem-description
attributes:
label: Problem Description
description: "A clear and concise description of what the problem is. Example: I'm always frustrated when [...]"
placeholder: "Explain the problem you are facing..."
validations:
required: true
- type: markdown
attributes:
value: "## Describe the solution you'd like"
- type: textarea
id: desired-solution
attributes:
label: Solution Description
description: A clear and concise description of what you want to happen.
placeholder: "Describe the solution you'd like..."
validations:
required: true
- type: markdown
attributes:
value: "## Describe alternatives you've considered"
- type: textarea
id: alternatives-considered
attributes:
label: Alternatives Considered
description: A clear and concise description of any alternative solutions or features you've considered.
placeholder: "Describe any alternative solutions or features you've considered..."
validations:
required: false
- type: markdown
attributes:
value: "## Additional context"
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
placeholder: "Add any other context or screenshots about the feature request here..."
validations:
required: false

View File

@@ -1,24 +0,0 @@
---
name: 功能建议
about: 请告诉我们你的灵光一闪
title: "[Feature] "
labels: ''
assignees: ''
---
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
**这个功能与现有的问题有关吗?**
如果有关,请在此列出链接或者描述问题。
**你想要什么功能或者有什么建议?**
尽管告诉我们。
**有没有可以参考的同类竞品?**
可以给出参考产品的链接或者截图。
**其他信息**
可以说说你的其他考虑。

View File

@@ -1,36 +0,0 @@
---
name: 反馈问题
about: 请告诉我们你遇到的问题
title: "[Bug] "
labels: ''
assignees: ''
---
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
**反馈须知**
⚠️ 注意:不遵循此模板的任何帖子都会被立即关闭,如果没有提供下方的信息,我们无法定位你的问题。
请在下方中括号内输入 x 来表示你已经知晓相关内容。
- [ ] 我确认已经在 [常见问题](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/faq-cn.md) 中搜索了此次反馈的问题,没有找到解答;
- [ ] 我确认已经在 [Issues](https://github.com/Yidadaa/ChatGPT-Next-Web/issues) 列表(包括已经 Close 的)中搜索了此次反馈的问题,没有找到解答。
- [ ] 我确认已经在 [Vercel 使用教程](https://github.com/Yidadaa/ChatGPT-Next-Web/blob/main/docs/vercel-cn.md) 中搜索了此次反馈的问题,没有找到解答。
**描述问题**
请在此描述你遇到了什么问题。
**如何复现**
请告诉我们你是通过什么操作触发的该问题。
**截图**
请在此提供控制台截图、屏幕截图或者服务端的 log 截图。
**一些必要的信息**
- 系统:[比如 windows 10/ macos 12/ linux / android 11 / ios 16]
- 浏览器: [比如 chrome, safari]
- 版本: [填写设置页面的版本号]
- 部署方式:[比如 vercel、docker 或者服务器部署]

View File

@@ -25,7 +25,7 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FYidadaa%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&env=GOOGLE_API_KEY&project-name=chatgpt-next-web&repository-name=ChatGPT-Next-Web)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/ZBUEFA)

View File

@@ -1,43 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const [protocol, ...subpath] = params.path;
const targetUrl = `${protocol}://${subpath.join("/")}`;
const method = req.headers.get("method") ?? undefined;
const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "",
);
const fetchOptions: RequestInit = {
headers: {
authorization: req.headers.get("authorization") ?? "",
},
body: shouldNotHaveBody ? null : req.body,
method,
// @ts-ignore
duplex: "half",
};
const fetchResult = await fetch(targetUrl, fetchOptions);
console.log("[Any Proxy]", targetUrl, {
status: fetchResult.status,
statusText: fetchResult.statusText,
});
return fetchResult;
}
export const POST = handle;
export const GET = handle;
export const OPTIONS = handle;
export const runtime = "edge";

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
async function handle(
req: NextRequest,
{ params }: { params: { action: string; key: string[] } },
) {
const requestUrl = new URL(req.url);
const endpoint = requestUrl.searchParams.get("endpoint");
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const [...key] = params.key;
// only allow to request to *.upstash.io
if (!endpoint || !new URL(endpoint).hostname.endsWith(".upstash.io")) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.key.join("/"),
},
{
status: 403,
},
);
}
// only allow upstash get and set method
if (params.action !== "get" && params.action !== "set") {
console.log("[Upstash Route] forbidden action ", params.action);
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.action,
},
{
status: 403,
},
);
}
const targetUrl = `${endpoint}/${params.action}/${params.key.join("/")}`;
const method = req.method;
const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "",
);
const fetchOptions: RequestInit = {
headers: {
authorization: req.headers.get("authorization") ?? "",
},
body: shouldNotHaveBody ? null : req.body,
method,
// @ts-ignore
duplex: "half",
};
console.log("[Upstash Proxy]", targetUrl, fetchOptions);
const fetchResult = await fetch(targetUrl, fetchOptions);
console.log("[Any Proxy]", targetUrl, {
status: fetchResult.status,
statusText: fetchResult.statusText,
});
return fetchResult;
}
export const POST = handle;
export const GET = handle;
export const OPTIONS = handle;
export const runtime = "edge";

View File

@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import { STORAGE_KEY } from "../../../constant";
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const folder = STORAGE_KEY;
const fileName = `${folder}/backup.json`;
const requestUrl = new URL(req.url);
let endpoint = requestUrl.searchParams.get("endpoint");
if (!endpoint?.endsWith("/")) {
endpoint += "/";
}
const endpointPath = params.path.join("/");
// only allow MKCOL, GET, PUT
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
},
);
}
// for MKCOL request, only allow request ${folder}
if (
req.method == "MKCOL" &&
!new URL(endpointPath).pathname.endsWith(folder)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
},
);
}
// for GET request, only allow request ending with fileName
if (
req.method == "GET" &&
!new URL(endpointPath).pathname.endsWith(fileName)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
},
);
}
// for PUT request, only allow request ending with fileName
if (
req.method == "PUT" &&
!new URL(endpointPath).pathname.endsWith(fileName)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
},
);
}
const targetUrl = `${endpoint + endpointPath}`;
const method = req.method;
const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "",
);
const fetchOptions: RequestInit = {
headers: {
authorization: req.headers.get("authorization") ?? "",
},
body: shouldNotHaveBody ? null : req.body,
method,
// @ts-ignore
duplex: "half",
};
const fetchResult = await fetch(targetUrl, fetchOptions);
console.log("[Any Proxy]", targetUrl, {
status: fetchResult.status,
statusText: fetchResult.statusText,
});
return fetchResult;
}
export const POST = handle;
export const GET = handle;
export const OPTIONS = handle;
export const runtime = "edge";

View File

@@ -14,9 +14,17 @@ export type MessageRole = (typeof ROLES)[number];
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
export type ChatModel = ModelType;
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string;
content: string | MultimodalContent[];
}
export interface LLMConfig {
@@ -143,7 +151,6 @@ export function getHeaders() {
const accessStore = useAccessStore.getState();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-requested-with": "XMLHttpRequest",
Accept: "application/json",
};
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;

View File

@@ -3,6 +3,12 @@ import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { getClientConfig } from "@/app/config/client";
import { DEFAULT_API_HOST } from "@/app/constant";
import {
getMessageTextContent,
getMessageImages,
isVisionModel,
} from "@/app/utils";
export class GeminiProApi implements LLMApi {
extractMessage(res: any) {
console.log("[Response] gemini-pro response: ", res);
@@ -15,10 +21,33 @@ export class GeminiProApi implements LLMApi {
}
async chat(options: ChatOptions): Promise<void> {
// const apiClient = this;
const messages = options.messages.map((v) => ({
role: v.role.replace("assistant", "model").replace("system", "user"),
parts: [{ text: v.content }],
}));
const visionModel = isVisionModel(options.config.model);
let multimodal = false;
const messages = options.messages.map((v) => {
let parts: any[] = [{ text: getMessageTextContent(v) }];
if (visionModel) {
const images = getMessageImages(v);
if (images.length > 0) {
multimodal = true;
parts = parts.concat(
images.map((image) => {
const imageType = image.split(";")[0].split(":")[1];
const imageData = image.split(",")[1];
return {
inline_data: {
mime_type: imageType,
data: imageData,
},
};
}),
);
}
}
return {
role: v.role.replace("assistant", "model").replace("system", "user"),
parts: parts,
};
});
// google requires that role in neighboring messages must not be the same
for (let i = 0; i < messages.length - 1; ) {
@@ -33,7 +62,9 @@ export class GeminiProApi implements LLMApi {
i++;
}
}
// if (visionModel && messages.length > 1) {
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
// }
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
@@ -80,13 +111,16 @@ export class GeminiProApi implements LLMApi {
const controller = new AbortController();
options.onController?.(controller);
try {
let chatPath = this.path(Google.ChatPath);
let googleChatPath = visionModel
? Google.VisionChatPath
: Google.ChatPath;
let chatPath = this.path(googleChatPath);
// let baseUrl = accessStore.googleUrl;
if (!baseUrl) {
baseUrl = isApp
? DEFAULT_API_HOST + "/api/proxy/google/" + Google.ChatPath
? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath
: chatPath;
}
@@ -152,6 +186,19 @@ export class GeminiProApi implements LLMApi {
value,
}): Promise<any> {
if (done) {
if (response.status !== 200) {
try {
let data = JSON.parse(ensureProperEnding(partialData));
if (data && data[0].error) {
options.onError?.(new Error(data[0].error.message));
} else {
options.onError?.(new Error("Request failed"));
}
} catch (_) {
options.onError?.(new Error("Request failed"));
}
}
console.log("Stream complete");
// options.onFinish(responseText + remainText);
finished = true;

View File

@@ -9,7 +9,14 @@ import {
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
LLMUsage,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
@@ -18,6 +25,11 @@ import {
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { makeAzurePath } from "@/app/azure";
import {
getMessageTextContent,
getMessageImages,
isVisionModel,
} from "@/app/utils";
export interface OpenAIListModelResponse {
object: string;
@@ -72,9 +84,10 @@ export class ChatGPTApi implements LLMApi {
}
async chat(options: ChatOptions) {
const visionModel = isVisionModel(options.config.model);
const messages = options.messages.map((v) => ({
role: v.role,
content: v.content,
content: visionModel ? v.content : getMessageTextContent(v),
}));
const modelConfig = {
@@ -97,6 +110,16 @@ export class ChatGPTApi implements LLMApi {
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
// add max_tokens to vision model
if (visionModel) {
Object.defineProperty(requestPayload, "max_tokens", {
enumerable: true,
configurable: true,
writable: true,
value: modelConfig.max_tokens,
});
}
console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !!options.config.stream;

View File

@@ -1,5 +1,47 @@
@import "../styles/animation.scss";
.attach-images {
position: absolute;
left: 30px;
bottom: 32px;
display: flex;
}
.attach-image {
cursor: default;
width: 64px;
height: 64px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
border-radius: 5px;
margin-right: 10px;
background-size: cover;
background-position: center;
background-color: var(--white);
.attach-image-mask {
width: 100%;
height: 100%;
opacity: 0;
transition: all ease 0.2s;
}
.attach-image-mask:hover {
opacity: 1;
}
.delete-image {
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
float: right;
background-color: var(--white);
}
}
.chat-input-actions {
display: flex;
flex-wrap: wrap;
@@ -16,7 +58,7 @@
box-shadow: var(--card-shadow);
transition: width ease 0.3s;
align-items: center;
height: 16px;
height: 24px;
width: var(--icon-width);
overflow: hidden;
@@ -26,7 +68,6 @@
.text {
white-space: nowrap;
padding-left: 5px;
opacity: 0;
transform: translateX(-5px);
transition: all ease 0.3s;
@@ -189,12 +230,10 @@
animation: slide-in ease 0.3s;
$linear: linear-gradient(
to right,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 0)
);
$linear: linear-gradient(to right,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 0));
mask-image: $linear;
@mixin show {
@@ -327,7 +366,7 @@
}
}
.chat-message-user > .chat-message-container {
.chat-message-user>.chat-message-container {
align-items: flex-end;
}
@@ -349,6 +388,7 @@
padding: 7px;
}
}
/* Specific styles for iOS devices */
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
@supports (-webkit-touch-callout: none) {
@@ -381,6 +421,64 @@
transition: all ease 0.3s;
}
.chat-message-item-image {
width: 100%;
margin-top: 10px;
}
.chat-message-item-images {
width: 100%;
display: grid;
justify-content: left;
grid-gap: 10px;
grid-template-columns: repeat(var(--image-count), auto);
margin-top: 10px;
}
.chat-message-item-image-multi {
object-fit: cover;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.chat-message-item-image,
.chat-message-item-image-multi {
box-sizing: border-box;
border-radius: 10px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
}
@media only screen and (max-width: 600px) {
$calc-image-width: calc(100vw/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $calc-image-width;
height: $calc-image-width;
}
.chat-message-item-image {
max-width: calc(100vw/3*2);
}
}
@media screen and (min-width: 600px) {
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $image-width;
height: $image-width;
max-width: $max-image-width;
max-height: $max-image-width;
}
.chat-message-item-image {
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
}
}
.chat-message-action-date {
font-size: 12px;
opacity: 0.2;
@@ -395,7 +493,7 @@
z-index: 1;
}
.chat-message-user > .chat-message-container > .chat-message-item {
.chat-message-user>.chat-message-container>.chat-message-item {
background-color: var(--second);
&:hover {
@@ -460,6 +558,7 @@
@include single-line();
}
.hint-content {
font-size: 12px;
@@ -474,15 +573,26 @@
}
.chat-input-panel-inner {
cursor: text;
display: flex;
flex: 1;
border-radius: 10px;
border: var(--border-in-light);
}
.chat-input-panel-inner-attach {
padding-bottom: 80px;
}
.chat-input-panel-inner:has(.chat-input:focus) {
border: 1px solid var(--primary);
}
.chat-input {
height: 100%;
width: 100%;
border-radius: 10px;
border: var(--border-in-light);
border: none;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
background-color: var(--white);
color: var(--black);
@@ -494,17 +604,11 @@
min-height: 68px;
}
.chat-input:focus {
border: 1px solid var(--primary);
}
.chat-input:focus {}
.chat-input-send {
background-color: var(--primary);
color: white;
position: absolute;
right: 30px;
bottom: 32px;
}
@media only screen and (max-width: 600px) {
@@ -515,4 +619,4 @@
.chat-input-send {
bottom: 30px;
}
}
}

View File

@@ -6,6 +6,7 @@ import React, {
useMemo,
useCallback,
Fragment,
RefObject,
} from "react";
import SendWhiteIcon from "../icons/send-white.svg";
@@ -15,6 +16,7 @@ import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import LoadingIcon from "../icons/three-dots.svg";
import LoadingButtonIcon from "../icons/loading.svg";
import PromptIcon from "../icons/prompt.svg";
import MaskIcon from "../icons/mask.svg";
import MaxIcon from "../icons/max.svg";
@@ -27,6 +29,7 @@ import PinIcon from "../icons/pin.svg";
import EditIcon from "../icons/rename.svg";
import ConfirmIcon from "../icons/confirm.svg";
import CancelIcon from "../icons/cancel.svg";
import ImageIcon from "../icons/image.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
@@ -53,6 +56,10 @@ import {
selectOrCopy,
autoGrowTextArea,
useMobileScreen,
getMessageTextContent,
getMessageImages,
isVisionModel,
compressImage,
} from "../utils";
import dynamic from "next/dynamic";
@@ -89,7 +96,8 @@ import { prettyObject } from "../utils/format";
import { ExportMessageModal } from "./exporter";
import { getClientConfig } from "../config/client";
import { useAllModels } from "../utils/hooks";
import { MultimodalContent } from "../client/api";
import SpeechRecorder from "./chat/speechRecorder";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
@@ -211,6 +219,8 @@ function useSubmitHandler() {
}, []);
const shouldSubmit = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Fix Chinese input method "Enter" on Safari
if (e.keyCode == 229) return false;
if (e.key !== "Enter") return false;
if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current))
return false;
@@ -337,7 +347,7 @@ function ChatAction(props: {
full: 16,
icon: 16,
});
const [isActive, setIsActive] = useState(false);
function updateWidth() {
if (!iconRef.current || !textRef.current) return;
const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
@@ -351,35 +361,34 @@ function ChatAction(props: {
return (
<div
className={`${styles["chat-input-action"]} clickable`}
className={`${styles["chat-input-action"]} clickable group`}
onClick={() => {
props.onClick();
setTimeout(updateWidth, 1);
}}
onMouseEnter={updateWidth}
onTouchStart={updateWidth}
style={
{
"--icon-width": `${width.icon}px`,
"--full-width": `${width.full}px`,
} as React.CSSProperties
}
>
<div ref={iconRef} className={styles["icon"]}>
{props.icon}
</div>
<div className={styles["text"]} ref={textRef}>
{props.text}
<div className="flex">
<div ref={iconRef} className={styles["icon"]}>
{props.icon}
</div>
<div
className={`${styles["text"]} transition-all duration-1000 w-0 group-hover:w-[60px]`}
ref={textRef}
>
{props.text}
</div>
</div>
</div>
);
}
function useScrollToBottom() {
function useScrollToBottom(
scrollRef: RefObject<HTMLDivElement>,
detach: boolean = false,
) {
// for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [autoScroll, setAutoScroll] = useState(true);
function scrollDomToBottom() {
const dom = scrollRef.current;
if (dom) {
@@ -392,7 +401,7 @@ function useScrollToBottom() {
// auto scroll
useEffect(() => {
if (autoScroll) {
if (autoScroll && !detach) {
scrollDomToBottom();
}
});
@@ -406,10 +415,15 @@ function useScrollToBottom() {
}
export function ChatActions(props: {
uploadImage: () => void;
setAttachImages: (images: string[]) => void;
setUploading: (uploading: boolean) => void;
showPromptModal: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
setUserInput: (text: string) => void;
hitBottom: boolean;
uploading: boolean;
}) {
const config = useAppConfig();
const navigate = useNavigate();
@@ -437,8 +451,16 @@ export function ChatActions(props: {
[allModels],
);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);
useEffect(() => {
const show = isVisionModel(currentModel);
setShowUploadImage(show);
if (!show) {
props.setAttachImages([]);
props.setUploading(false);
}
// if current model is not available
// switch to first available model
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
@@ -475,6 +497,13 @@ export function ChatActions(props: {
/>
)}
{showUploadImage && (
<ChatAction
onClick={props.uploadImage}
text={Locale.Chat.InputActions.UploadImage}
icon={props.uploading ? <LoadingButtonIcon /> : <ImageIcon />}
/>
)}
<ChatAction
onClick={nextTheme}
text={Locale.Chat.InputActions.Theme[theme]}
@@ -610,6 +639,14 @@ export function EditMessageModal(props: { onClose: () => void }) {
);
}
export function DeleteImageButton(props: { deleteImage: () => void }) {
return (
<div className={styles["delete-image"]} onClick={props.deleteImage}>
<DeleteIcon />
</div>
);
}
function _Chat() {
type RenderMessage = ChatMessage & { preview?: boolean };
@@ -624,10 +661,22 @@ function _Chat() {
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll, scrollDomToBottom } = useScrollToBottom();
const scrollRef = useRef<HTMLDivElement>(null);
const isScrolledToBottom = scrollRef?.current
? Math.abs(
scrollRef.current.scrollHeight -
(scrollRef.current.scrollTop + scrollRef.current.clientHeight),
) <= 1
: false;
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
scrollRef,
isScrolledToBottom,
);
const [hitBottom, setHitBottom] = useState(true);
const isMobileScreen = useMobileScreen();
const navigate = useNavigate();
const [attachImages, setAttachImages] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
// prompt hints
const promptStore = usePromptStore();
@@ -705,7 +754,10 @@ function _Chat() {
return;
}
setIsLoading(true);
chatStore.onUserInput(userInput).then(() => setIsLoading(false));
chatStore
.onUserInput(userInput, attachImages)
.then(() => setIsLoading(false));
setAttachImages([]);
localStorage.setItem(LAST_INPUT_KEY, userInput);
setUserInput("");
setPromptHints([]);
@@ -783,9 +835,9 @@ function _Chat() {
};
const onRightClick = (e: any, message: ChatMessage) => {
// copy to clipboard
if (selectOrCopy(e.currentTarget, message.content)) {
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
if (userInput.length === 0) {
setUserInput(message.content);
setUserInput(getMessageTextContent(message));
}
e.preventDefault();
@@ -853,7 +905,9 @@ function _Chat() {
// resend the message
setIsLoading(true);
chatStore.onUserInput(userMessage.content).then(() => setIsLoading(false));
const textContent = getMessageTextContent(userMessage);
const images = getMessageImages(userMessage);
chatStore.onUserInput(textContent, images).then(() => setIsLoading(false));
inputRef.current?.focus();
};
@@ -962,7 +1016,6 @@ function _Chat() {
setHitBottom(isHitBottom);
setAutoScroll(isHitBottom);
};
function scrollToBottom() {
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
scrollDomToBottom();
@@ -1047,6 +1100,92 @@ function _Chat() {
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handlePaste = useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const currentModel = chatStore.currentSession().mask.modelConfig.model;
if(!isVisionModel(currentModel)){return;}
const items = (event.clipboardData || window.clipboardData).items;
for (const item of items) {
if (item.kind === "file" && item.type.startsWith("image/")) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
const images: string[] = [];
images.push(...attachImages);
images.push(
...(await new Promise<string[]>((res, rej) => {
setUploading(true);
const imagesData: string[] = [];
compressImage(file, 256 * 1024)
.then((dataUrl) => {
imagesData.push(dataUrl);
setUploading(false);
res(imagesData);
})
.catch((e) => {
setUploading(false);
rej(e);
});
})),
);
const imagesLength = images.length;
if (imagesLength > 3) {
images.splice(3, imagesLength - 3);
}
setAttachImages(images);
}
}
}
},
[attachImages, chatStore],
);
async function uploadImage() {
const images: string[] = [];
images.push(...attachImages);
images.push(
...(await new Promise<string[]>((res, rej) => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept =
"image/png, image/jpeg, image/webp, image/heic, image/heif";
fileInput.multiple = true;
fileInput.onchange = (event: any) => {
setUploading(true);
const files = event.target.files;
const imagesData: string[] = [];
for (let i = 0; i < files.length; i++) {
const file = event.target.files[i];
compressImage(file, 256 * 1024)
.then((dataUrl) => {
imagesData.push(dataUrl);
if (
imagesData.length === 3 ||
imagesData.length === files.length
) {
setUploading(false);
res(imagesData);
}
})
.catch((e) => {
setUploading(false);
rej(e);
});
}
};
fileInput.click();
})),
);
const imagesLength = images.length;
if (imagesLength > 3) {
images.splice(3, imagesLength - 3);
}
setAttachImages(images);
}
return (
<div className={styles.chat} key={session.id}>
@@ -1154,15 +1293,29 @@ function _Chat() {
onClick={async () => {
const newMessage = await showPrompt(
Locale.Chat.Actions.Edit,
message.content,
getMessageTextContent(message),
10,
);
let newContent: string | MultimodalContent[] =
newMessage;
const images = getMessageImages(message);
if (images.length > 0) {
newContent = [{ type: "text", text: newMessage }];
for (let i = 0; i < images.length; i++) {
newContent.push({
type: "image_url",
image_url: {
url: images[i],
},
});
}
}
chatStore.updateCurrentSession((session) => {
const m = session.mask.context
.concat(session.messages)
.find((m) => m.id === message.id);
if (m) {
m.content = newMessage;
m.content = newContent;
}
});
}}
@@ -1217,7 +1370,11 @@ function _Chat() {
<ChatAction
text={Locale.Chat.Actions.Copy}
icon={<CopyIcon />}
onClick={() => copyToClipboard(message.content)}
onClick={() =>
copyToClipboard(
getMessageTextContent(message),
)
}
/>
</>
)}
@@ -1232,7 +1389,7 @@ function _Chat() {
)}
<div className={styles["chat-message-item"]}>
<Markdown
content={message.content}
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
message.content.length === 0 &&
@@ -1241,12 +1398,42 @@ function _Chat() {
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen) return;
setUserInput(message.content);
setUserInput(getMessageTextContent(message));
}}
fontSize={fontSize}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
/>
{getMessageImages(message).length == 1 && (
<img
className={styles["chat-message-item-image"]}
src={getMessageImages(message)[0]}
alt=""
/>
)}
{getMessageImages(message).length > 1 && (
<div
className={styles["chat-message-item-images"]}
style={
{
"--image-count": getMessageImages(message).length,
} as React.CSSProperties
}
>
{getMessageImages(message).map((image, index) => {
return (
<img
className={
styles["chat-message-item-image-multi"]
}
key={index}
src={image}
alt=""
/>
);
})}
</div>
)}
</div>
<div className={styles["chat-message-action-date"]}>
@@ -1266,9 +1453,14 @@ function _Chat() {
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<ChatActions
uploadImage={uploadImage}
setAttachImages={setAttachImages}
setUploading={setUploading}
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
setUserInput={setUserInput}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
@@ -1281,8 +1473,16 @@ function _Chat() {
onSearch("");
}}
/>
<div className={styles["chat-input-panel-inner"]}>
<label
className={`${styles["chat-input-panel-inner"]} ${
attachImages.length != 0
? styles["chat-input-panel-inner-attach"]
: ""
}`}
htmlFor="chat-input"
>
<textarea
id="chat-input"
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
@@ -1291,20 +1491,50 @@ function _Chat() {
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows}
autoFocus={autoFocus}
style={{
fontSize: config.fontSize,
}}
/>
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</div>
{attachImages.length != 0 && (
<div className={styles["attach-images"]}>
{attachImages.map((image, index) => {
return (
<div
key={index}
className={styles["attach-image"]}
style={{ backgroundImage: `url("${image}")` }}
>
<div className={styles["attach-image-mask"]}>
<DeleteImageButton
deleteImage={() => {
setAttachImages(
attachImages.filter((_, i) => i !== index),
);
}}
/>
</div>
</div>
);
})}
</div>
)}
<div className="flex gap-2 absolute left-[30px] bottom-[32px]">
<SpeechRecorder textUpdater={setUserInput}></SpeechRecorder>
</div>
<div className="flex gap-2 absolute right-[30px] bottom-[32px]">
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</div>
</label>
</div>
{showExport && (

View File

@@ -0,0 +1,64 @@
import React, { useState, useEffect } from "react";
import VoiceIcon from "@/app/icons/voice.svg";
import { getLang, formatLang } from "@/app/locales";
type SpeechRecognitionType =
| typeof window.SpeechRecognition
| typeof window.webkitSpeechRecognition;
export default function SpeechRecorder({
textUpdater,
onStop,
}: {
textUpdater: (text: string) => void;
onStop?: () => void;
}) {
const [speechRecognition, setSpeechRecognition] =
useState<SpeechRecognitionType | null>(null);
const [isRecording, setIsRecording] = useState(false);
useEffect(() => {
if ("SpeechRecognition" in window) {
setSpeechRecognition(new (window as any).SpeechRecognition());
} else if ("webkitSpeechRecognition" in window) {
setSpeechRecognition(new (window as any).webkitSpeechRecognition());
}
}, []);
return (
<>
{speechRecognition && (
<div>
<button
onClick={() => {
if (!isRecording && speechRecognition) {
speechRecognition.continuous = true;
speechRecognition.lang = formatLang(getLang());
console.log(speechRecognition.lang);
speechRecognition.interimResults = true;
speechRecognition.start();
speechRecognition.onresult = function (event: any) {
console.log(event);
var transcript = event.results[0][0].transcript;
console.log(transcript);
textUpdater(transcript);
};
setIsRecording(true);
} else {
speechRecognition.stop();
setIsRecording(false);
}
}}
>
{isRecording ? (
<button className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 ring-4 ring-blue-200 transition animate-pulse">
<VoiceIcon fill={"white"} />
</button>
) : (
<button className="p-2 rounded-full bg-zinc-100 hover:bg-zinc-200 transition">
<VoiceIcon fill={"#8282A5"} />
</button>
)}
</button>
</div>
)}
</>
);
}

View File

@@ -21,6 +21,7 @@ export function AvatarPicker(props: {
}) {
return (
<EmojiPicker
width={"100%"}
lazyLoadEmojis
theme={EmojiTheme.AUTO}
getEmojiUrl={getEmojiUrl}

View File

@@ -94,6 +94,7 @@
button {
flex-grow: 1;
&:not(:last-child) {
margin-right: 10px;
}
@@ -190,6 +191,59 @@
pre {
overflow: hidden;
}
.message-image {
width: 100%;
margin-top: 10px;
}
.message-images {
display: grid;
justify-content: left;
grid-gap: 10px;
grid-template-columns: repeat(var(--image-count), auto);
margin-top: 10px;
}
@media screen and (max-width: 600px) {
$image-width: calc(calc(100vw/2)/var(--image-count));
.message-image-multi {
width: $image-width;
height: $image-width;
}
.message-image {
max-width: calc(100vw/3*2);
}
}
@media screen and (min-width: 600px) {
$max-image-width: calc(900px/3*2/var(--image-count));
$image-width: calc(80vw/3*2/var(--image-count));
.message-image-multi {
width: $image-width;
height: $image-width;
max-width: $max-image-width;
max-height: $max-image-width;
}
.message-image {
max-width: calc(100vw/3*2);
}
}
.message-image-multi {
object-fit: cover;
}
.message-image,
.message-image-multi {
box-sizing: border-box;
border-radius: 10px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
}
}
&-assistant {
@@ -213,6 +267,5 @@
}
}
.default-theme {
}
}
.default-theme {}
}

View File

@@ -12,7 +12,12 @@ import {
showToast,
} from "./ui-lib";
import { IconButton } from "./button";
import { copyToClipboard, downloadAs, useMobileScreen } from "../utils";
import {
copyToClipboard,
downloadAs,
getMessageImages,
useMobileScreen,
} from "../utils";
import CopyIcon from "../icons/copy.svg";
import LoadingIcon from "../icons/three-dots.svg";
@@ -34,6 +39,7 @@ import { prettyObject } from "../utils/format";
import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { getMessageTextContent } from "../utils";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
@@ -287,7 +293,7 @@ export function RenderExport(props: {
id={`${m.role}:${i}`}
className={EXPORT_MESSAGE_CLASS_NAME}
>
<Markdown content={m.content} defaultShow />
<Markdown content={getMessageTextContent(m)} defaultShow />
</div>
))}
</div>
@@ -580,10 +586,37 @@ export function ImagePreviewer(props: {
<div className={styles["body"]}>
<Markdown
content={m.content}
content={getMessageTextContent(m)}
fontSize={config.fontSize}
defaultShow
/>
{getMessageImages(m).length == 1 && (
<img
key={i}
src={getMessageImages(m)[0]}
alt="message"
className={styles["message-image"]}
/>
)}
{getMessageImages(m).length > 1 && (
<div
className={styles["message-images"]}
style={
{
"--image-count": getMessageImages(m).length,
} as React.CSSProperties
}
>
{getMessageImages(m).map((src, i) => (
<img
key={i}
src={src}
alt="message"
className={styles["message-image-multi"]}
/>
))}
</div>
)}
</div>
</div>
);
@@ -602,8 +635,10 @@ export function MarkdownPreviewer(props: {
props.messages
.map((m) => {
return m.role === "user"
? `## ${Locale.Export.MessageFromYou}:\n${m.content}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${m.content.trim()}`;
? `## ${Locale.Export.MessageFromYou}:\n${getMessageTextContent(m)}`
: `## ${Locale.Export.MessageFromChatGPT}:\n${getMessageTextContent(
m,
).trim()}`;
})
.join("\n\n");

View File

@@ -22,7 +22,7 @@ import {
useAppConfig,
useChatStore,
} from "../store";
import { ROLES } from "../client/api";
import { MultimodalContent, ROLES } from "../client/api";
import {
Input,
List,
@@ -38,7 +38,12 @@ import { useNavigate } from "react-router-dom";
import chatStyle from "./chat.module.scss";
import { useEffect, useState } from "react";
import { copyToClipboard, downloadAs, readFromFile } from "../utils";
import {
copyToClipboard,
downloadAs,
getMessageImages,
readFromFile,
} from "../utils";
import { Updater } from "../typing";
import { ModelConfigList } from "./model-config";
import { FileName, Path } from "../constant";
@@ -50,6 +55,7 @@ import {
Draggable,
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { getMessageTextContent } from "../utils";
// drag and drop helper function
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
@@ -244,7 +250,7 @@ function ContextPromptItem(props: {
</>
)}
<Input
value={props.prompt.content}
value={getMessageTextContent(props.prompt)}
type="text"
className={chatStyle["context-content"]}
rows={focusingInput ? 5 : 1}
@@ -289,7 +295,18 @@ export function ContextPrompts(props: {
};
const updateContextPrompt = (i: number, prompt: ChatMessage) => {
props.updateContext((context) => (context[i] = prompt));
props.updateContext((context) => {
const images = getMessageImages(context[i]);
context[i] = prompt;
if (images.length > 0) {
const text = getMessageTextContent(context[i]);
const newContext: MultimodalContent[] = [{ type: "text", text }];
for (const img of images) {
newContext.push({ type: "image_url", image_url: { url: img } });
}
context[i].content = newContext;
}
});
};
const onDragEnd: OnDragEndResponder = (result) => {

View File

@@ -7,6 +7,7 @@ import { MaskAvatar } from "./mask";
import Locale from "../locales";
import styles from "./message-selector.module.scss";
import { getMessageTextContent } from "../utils";
function useShiftRange() {
const [startIndex, setStartIndex] = useState<number>();
@@ -103,7 +104,9 @@ export function MessageSelector(props: {
const searchResults = new Set<string>();
if (text.length > 0) {
messages.forEach((m) =>
m.content.includes(text) ? searchResults.add(m.id!) : null,
getMessageTextContent(m).includes(text)
? searchResults.add(m.id!)
: null,
);
}
setSearchIds(searchResults);
@@ -219,7 +222,7 @@ export function MessageSelector(props: {
{new Date(m.date).toLocaleString()}
</div>
<div className={`${styles["content"]} one-line`}>
{m.content}
{getMessageTextContent(m)}
</div>
</div>

View File

@@ -5,6 +5,8 @@
.avatar {
cursor: pointer;
position: relative;
z-index: 1;
}
.edit-prompt-modal {

View File

@@ -693,7 +693,9 @@ export function Settings() {
>
<div
className={styles.avatar}
onClick={() => setShowEmojiPicker(true)}
onClick={() => {
setShowEmojiPicker(!showEmojiPicker);
}}
>
<Avatar avatar={config.avatar} />
</div>
@@ -1081,8 +1083,8 @@ export function Settings() {
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Azure.ApiKey.Title}
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
title={Locale.Settings.Access.Google.ApiKey.Title}
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
>
<PasswordInput
value={accessStore.googleApiKey}
@@ -1099,9 +1101,9 @@ export function Settings() {
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Google.ApiVerion.Title}
title={Locale.Settings.Access.Google.ApiVersion.Title}
subTitle={
Locale.Settings.Access.Google.ApiVerion.SubTitle
Locale.Settings.Access.Google.ApiVersion.SubTitle
}
>
<input

View File

@@ -14,17 +14,24 @@
.popover-content {
position: absolute;
width: 350px;
animation: slide-in 0.3s ease;
right: 0;
top: calc(100% + 10px);
}
@media screen and (max-width: 600px) {
.popover-content {
width: auto;
}
}
.popover-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
}
.list-item {

View File

@@ -26,10 +26,10 @@ export function Popover(props: {
<div className={styles.popover}>
{props.children}
{props.open && (
<div className={styles["popover-content"]}>
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
{props.content}
</div>
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
)}
{props.open && (
<div className={styles["popover-content"]}>{props.content}</div>
)}
</div>
);

View File

@@ -30,6 +30,9 @@ declare global {
// google only
GOOGLE_API_KEY?: string;
GOOGLE_URL?: string;
// google tag manager
GTM_ID?: string;
}
}
}

View File

@@ -23,7 +23,7 @@ export enum Path {
}
export enum ApiPath {
Cors = "/api/cors",
Cors = "",
OpenAI = "/api/openai",
}
@@ -88,6 +88,7 @@ export const Azure = {
export const Google = {
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
ChatPath: "v1beta/models/gemini-pro:generateContent",
VisionChatPath: "v1beta/models/gemini-pro-vision:generateContent",
// /api/openai/v1/chat/completions
};
@@ -103,12 +104,13 @@ Latex block: $$e=mc^2$$
`;
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
export const KnowledgeCutOffDate: Record<string, string> = {
default: "2021-09",
"gpt-4-turbo-preview": "2023-04",
"gpt-4-turbo-preview": "2023-12",
"gpt-4-1106-preview": "2023-04",
"gpt-4-0125-preview": "2023-04",
"gpt-4-0125-preview": "2023-12",
"gpt-4-vision-preview": "2023-04",
// After improvements,
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
@@ -278,6 +280,15 @@ export const DEFAULT_MODELS = [
providerType: "google",
},
},
{
name: "gemini-pro-vision",
available: true,
provider: {
id: "google",
providerName: "Google",
providerType: "google",
},
},
] as const;
export const CHAT_PAGE_SIZE = 15;

1
app/global.d.ts vendored
View File

@@ -19,6 +19,7 @@ declare interface Window {
};
fs: {
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
writeTextFile(path: string, data: string): Promise<void>;
};
notification:{
requestPermission(): Promise<Permission>;

1
app/icons/image.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" height="16" width="16" version="1.1" xml:space="preserve" style=""><rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill="none" stroke="none"/><g class="currentLayer" style=""><title>Layer 1</title><g id="svg_1" class="" fill="#333" fill-opacity="1"><polygon points="2.4690866470336914,2.4690725803375244 4.447190761566162,2.4690725803375244 4.447190761566162,1.6882386207580566 1.6882381439208984,1.6882386207580566 1.6882381439208984,4.44719123840332 2.4690866470336914,4.44719123840332 " id="svg_2" fill="#333" fill-opacity="1"/><polygon points="11.552804470062256,1.6882386207580566 11.552804470062256,2.4690725803375244 13.530910968780518,2.4690725803375244 13.530910968780518,4.44719123840332 14.311760425567627,4.44719123840332 14.311760425567627,1.6882386207580566 " id="svg_3" fill="#333" fill-opacity="1"/><polygon points="13.530910968780518,13.530919075012207 11.552804470062256,13.530919075012207 11.552804470062256,14.311760902404785 14.311760425567627,14.311760902404785 14.311760425567627,11.552801132202148 13.530910968780518,11.552801132202148 " id="svg_4" fill="#333" fill-opacity="1"/><polygon points="2.4690866470336914,11.552801132202148 1.6882381439208984,11.552801132202148 1.6882381439208984,14.311760902404785 4.447190761566162,14.311760902404785 4.447190761566162,13.530919075012207 2.4690866470336914,13.530919075012207 " id="svg_5" fill="#333" fill-opacity="1"/><path d="M8.830417847409231,6.243117030680995 c0.68169614081525,0 1.2363241834494423,-0.5546280426341942 1.2363241834494423,-1.2363241834494423 S9.51214001610201,3.770468663782117 8.830417847409231,3.770468663782117 s-1.2363241834494423,0.5546280426341942 -1.2363241834494423,1.2363241834494423 S8.14872170659398,6.243117030680995 8.830417847409231,6.243117030680995 z" id="svg_6" fill="#333" fill-opacity="1"/><polygon points="3.7704806327819824,12.229532241821289 12.229516506195068,12.229532241821289 12.229516506195068,9.709510803222656 10.70320463180542,8.099010467529297 8.852166652679443,9.175727844238281 6.275332450866699,7.334256172180176 3.7704806327819824,9.977211952209473 " id="svg_7" fill="#333" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

1
app/icons/loading.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fff" style=""><rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill="none" stroke="none" style="" class="" /><g class="currentLayer" style=""><title>Layer 1</title><circle cx="4" cy="8" r="1.926" fill="#333" id="svg_1" class=""><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="2" repeatCount="indefinite" to="2" values="2;1.2;2" /><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1" /></circle><circle cx="8" cy="8" r="1.2736" fill="#333" fill-opacity=".3" id="svg_2" class=""><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="1.2" repeatCount="indefinite" to="1.2" values="1.2;2;1.2" /><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from=".5" repeatCount="indefinite" to=".5" values=".5;1;.5" /></circle><circle cx="12" cy="8" r="1.926" fill="#333" id="svg_3" class=""><animate attributeName="r" begin="0s" calcMode="linear" dur="0.8s" from="2" repeatCount="indefinite" to="2" values="2;1.2;2" /><animate attributeName="fill-opacity" begin="0s" calcMode="linear" dur="0.8s" from="1" repeatCount="indefinite" to="1" values="1;.5;1" /></circle></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

11
app/icons/voice.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.9375 5.3125C5.9375 3.06884 7.75634 1.25 10 1.25C12.2437 1.25 14.0625 3.06884 14.0625 5.3125V8.75C14.0625 10.9937 12.2437 12.8125 10 12.8125C7.75634 12.8125 5.9375 10.9937 5.9375 8.75V5.3125Z"
fill="auto" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3.35938 7.8125C3.79085 7.8125 4.14062 8.16228 4.14062 8.59375V8.75C4.14062 11.986 6.76396 14.6094 10 14.6094C13.236 14.6094 15.8594 11.986 15.8594 8.75V8.59375C15.8594 8.16228 16.2092 7.8125 16.6406 7.8125C17.0721 7.8125 17.4219 8.16228 17.4219 8.59375V8.75C17.4219 12.849 14.099 16.1719 10 16.1719C5.90101 16.1719 2.57812 12.849 2.57812 8.75V8.59375C2.57812 8.16228 2.9279 7.8125 3.35938 7.8125Z"
fill="auto" />
<path
d="M9.21875 15.4688C9.21875 15.0373 9.56853 14.6875 10 14.6875C10.4315 14.6875 10.7812 15.0373 10.7812 15.4688V17.9688C10.7812 18.4002 10.4315 18.75 10 18.75C9.56853 18.75 9.21875 18.4002 9.21875 17.9688V15.4688Z"
fill="auto" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -63,6 +63,7 @@ const cn = {
Masks: "所有面具",
Clear: "清除聊天",
Settings: "对话设置",
UploadImage: "上传图片",
},
Rename: "重命名对话",
Typing: "正在输入…",
@@ -314,19 +315,19 @@ const cn = {
},
Google: {
ApiKey: {
Title: "接口密钥",
SubTitle: "使用自定义 Google AI Studio API Key 绕过密码访问限制",
Placeholder: "Google AI Studio API Key",
Title: "API 密钥",
SubTitle: " Google AI 获取您的 API 密钥",
Placeholder: "输入您的 Google AI Studio API 密钥",
},
Endpoint: {
Title: "接口地址",
SubTitle: "不包含请求路径,样例:",
Title: "终端地址",
SubTitle: "例:",
},
ApiVerion: {
Title: "接口版本 (gemini-pro api version)",
SubTitle: "选择指定的部分版本",
ApiVersion: {
Title: "API 版本(仅适用于 gemini-pro",
SubTitle: "选择一个特定的 API 版本",
},
},
CustomModel: {

View File

@@ -65,6 +65,7 @@ const en: LocaleType = {
Masks: "Masks",
Clear: "Clear Context",
Settings: "Settings",
UploadImage: "Upload Images",
},
Rename: "Rename Chat",
Typing: "Typing…",
@@ -322,9 +323,8 @@ const en: LocaleType = {
Google: {
ApiKey: {
Title: "API Key",
SubTitle:
"Bypass password access restrictions using a custom Google AI Studio API Key",
Placeholder: "Google AI Studio API Key",
SubTitle: "Obtain your API Key from Google AI",
Placeholder: "Enter your Google AI Studio API Key",
},
Endpoint: {
@@ -332,9 +332,9 @@ const en: LocaleType = {
SubTitle: "Example:",
},
ApiVerion: {
Title: "API Version (gemini-pro api version)",
SubTitle: "Select a specific part version",
ApiVersion: {
Title: "API Version (specific to gemini-pro)",
SubTitle: "Select a specific API version",
},
},
},

View File

@@ -70,6 +70,28 @@ export const ALL_LANG_OPTIONS: Record<Lang, string> = {
sk: "Slovensky",
};
const LANG_CODE_MAPPING = {
cn: "zh-CN",
en: "en-US",
tw: "zh-TW",
pt: "pt-BR",
jp: "ja-JP",
ko: "ko-KR",
id: "id-ID",
fr: "fr-FR",
es: "es-ES",
it: "it-IT",
tr: "tr-TR",
de: "de-DE",
vi: "vi-VN",
ru: "ru-RU",
cs: "cs-CZ",
no: "nb-NO",
ar: "ar-SA",
bn: "bn-BD",
sk: "sk-SK",
};
const LANG_KEY = "lang";
const DEFAULT_LANG = "en";
@@ -81,6 +103,13 @@ merge(fallbackLang, targetLang);
export default fallbackLang as LocaleType;
export const formatLang = (languageCode: string) => {
return (
LANG_CODE_MAPPING[languageCode as keyof typeof LANG_CODE_MAPPING] ||
languageCode
);
};
function getItem(key: string) {
try {
return localStorage.getItem(key);

View File

@@ -334,7 +334,7 @@ const sk: PartialLocaleType = {
SubTitle: "Príklad:",
},
ApiVerion: {
ApiVersion: {
Title: "Verzia API (gemini-pro verzia API)",
SubTitle: "Vyberte špecifickú verziu časti",
},

View File

@@ -1,16 +1,36 @@
import { getClientConfig } from "../config/client";
import { SubmitKey } from "../store/config";
import type { PartialLocaleType } from "./index";
const tw: PartialLocaleType = {
const isApp = !!getClientConfig()?.isApp;
const tw = {
WIP: "該功能仍在開發中……",
Error: {
Unauthorized: "目前您的狀態是未授權,請前往[設定頁面](/#/auth)輸入授權碼。",
Unauthorized: isApp
? "檢測到無效 API Key請前往[設定](/#/settings)頁檢查 API Key 是否設定正確。"
: "訪問密碼不正確或為空,請前往[登入](/#/auth)頁輸入正確的訪問密碼,或者在[設定](/#/settings)頁填入你自己的 OpenAI API Key。",
},
Auth: {
Title: "需要密碼",
Tips: "管理員開啟了密碼驗證,請在下方填入訪問碼",
SubTips: "或者輸入你的 OpenAI 或 Google API 密鑰",
Input: "在此處填寫訪問碼",
Confirm: "確認",
Later: "稍候再說",
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 則對話`,
},
Chat: {
SubTitle: (count: number) => `您已經與 ChatGPT 進行了 ${count} 則對話`,
EditMessage: {
Title: "編輯消息記錄",
Topic: {
Title: "聊天主題",
SubTitle: "更改當前聊天主題",
},
},
Actions: {
ChatList: "檢視訊息列表",
CompressedHistory: "檢視壓縮後的歷史 Prompt",
@@ -18,7 +38,33 @@ const tw: PartialLocaleType = {
Copy: "複製",
Stop: "停止",
Retry: "重試",
Pin: "固定",
PinToastContent: "已將 1 條對話固定至預設提示詞",
PinToastAction: "查看",
Delete: "刪除",
Edit: "編輯",
},
Commands: {
new: "新建聊天",
newm: "從面具新建聊天",
next: "下一個聊天",
prev: "上一個聊天",
clear: "清除上下文",
del: "刪除聊天",
},
InputActions: {
Stop: "停止回應",
ToBottom: "移至最新",
Theme: {
auto: "自動主題",
light: "亮色模式",
dark: "深色模式",
},
Prompt: "快捷指令",
Masks: "所有面具",
Clear: "清除聊天",
Settings: "對話設定",
UploadImage: "上傳圖片",
},
Rename: "重新命名對話",
Typing: "正在輸入…",
@@ -34,13 +80,37 @@ const tw: PartialLocaleType = {
Reset: "重設",
SaveAs: "另存新檔",
},
IsContext: "預設提示詞",
},
Export: {
Title: "將聊天記錄匯出為 Markdown",
Copy: "複製全部",
Download: "下載檔案",
Share: "分享到 ShareGPT",
MessageFromYou: "來自您的訊息",
MessageFromChatGPT: "來自 ChatGPT 的訊息",
Format: {
Title: "導出格式",
SubTitle: "可以導出 Markdown 文本或者 PNG 圖片",
},
IncludeContext: {
Title: "包含面具上下文",
SubTitle: "是否在消息中展示面具上下文",
},
Steps: {
Select: "選取",
Preview: "預覽",
},
Image: {
Toast: "正在生成截圖",
Modal: "長按或右鍵保存圖片",
},
},
Select: {
Search: "查詢消息",
All: "選取全部",
Latest: "最近幾條",
Clear: "清除選中",
},
Memory: {
Title: "上下文記憶 Prompt",
@@ -60,6 +130,20 @@ const tw: PartialLocaleType = {
Title: "設定",
SubTitle: "設定選項",
Danger: {
Reset: {
Title: "重置所有設定",
SubTitle: "重置所有設定項回預設值",
Action: "立即重置",
Confirm: "確認重置所有設定?",
},
Clear: {
Title: "清除所有資料",
SubTitle: "清除所有聊天、設定資料",
Action: "立即清除",
Confirm: "確認清除所有聊天、設定資料?",
},
},
Lang: {
Name: "Language", // ATTENTION: if you wanna add a new translation, please do not translate this value, leave it as `Language`
All: "所有語言",
@@ -73,6 +157,11 @@ const tw: PartialLocaleType = {
Title: "匯入系統提示",
SubTitle: "強制在每個請求的訊息列表開頭新增一個模擬 ChatGPT 的系統提示",
},
InputTemplate: {
Title: "用戶輸入預處理",
SubTitle: "用戶最新的一條消息會填充到此模板",
},
Update: {
Version: (x: string) => `目前版本:${x}`,
IsLatest: "已是最新版本",
@@ -88,11 +177,62 @@ const tw: PartialLocaleType = {
Title: "預覽氣泡",
SubTitle: "在預覽氣泡中預覽 Markdown 內容",
},
AutoGenerateTitle: {
Title: "自動生成標題",
SubTitle: "根據對話內容生成合適的標題",
},
Sync: {
CloudState: "雲端資料",
NotSyncYet: "還沒有進行過同步",
Success: "同步成功",
Fail: "同步失敗",
Config: {
Modal: {
Title: "設定雲端同步",
Check: "檢查可用性",
},
SyncType: {
Title: "同步類型",
SubTitle: "選擇喜愛的同步服務器",
},
Proxy: {
Title: "啟用代理",
SubTitle: "在瀏覽器中同步時,必須啟用代理以避免跨域限制",
},
ProxyUrl: {
Title: "代理地址",
SubTitle: "僅適用於本項目自帶的跨域代理",
},
WebDav: {
Endpoint: "WebDAV 地址",
UserName: "用戶名",
Password: "密碼",
},
UpStash: {
Endpoint: "UpStash Redis REST Url",
UserName: "備份名稱",
Password: "UpStash Redis REST Token",
},
},
LocalState: "本地資料",
Overview: (overview: any) => {
return `${overview.chat} 次對話,${overview.message} 條消息,${overview.prompt} 條提示詞,${overview.mask} 個面具`;
},
ImportFailed: "導入失敗",
},
Mask: {
Splash: {
Title: "面具啟動頁面",
SubTitle: "新增聊天時,呈現面具啟動頁面",
},
Builtin: {
Title: "隱藏內置面具",
SubTitle: "在所有面具列表中隱藏內置面具",
},
},
Prompt: {
Disable: {
@@ -131,11 +271,81 @@ const tw: PartialLocaleType = {
NoAccess: "輸入 API Key 檢視餘額",
},
Access: {
AccessCode: {
Title: "訪問密碼",
SubTitle: "管理員已開啟加密訪問",
Placeholder: "請輸入訪問密碼",
},
CustomEndpoint: {
Title: "自定義接口 (Endpoint)",
SubTitle: "是否使用自定義 Azure 或 OpenAI 服務",
},
Provider: {
Title: "模型服務商",
SubTitle: "切換不同的服務商",
},
OpenAI: {
ApiKey: {
Title: "API Key",
SubTitle: "使用自定義 OpenAI Key 繞過密碼訪問限制",
Placeholder: "OpenAI API Key",
},
Endpoint: {
Title: "接口(Endpoint) 地址",
SubTitle: "除默認地址外,必須包含 http(s)://",
},
},
Azure: {
ApiKey: {
Title: "接口密鑰",
SubTitle: "使用自定義 Azure Key 繞過密碼訪問限制",
Placeholder: "Azure API Key",
},
Endpoint: {
Title: "接口(Endpoint) 地址",
SubTitle: "樣例:",
},
ApiVerion: {
Title: "接口版本 (azure api version)",
SubTitle: "選擇指定的部分版本",
},
},
Google: {
ApiKey: {
Title: "API 密鑰",
SubTitle: "從 Google AI 獲取您的 API 密鑰",
Placeholder: "輸入您的 Google AI Studio API 密鑰",
},
Endpoint: {
Title: "終端地址",
SubTitle: "示例:",
},
ApiVersion: {
Title: "API 版本(僅適用於 gemini-pro",
SubTitle: "選擇一個特定的 API 版本",
},
},
CustomModel: {
Title: "自定義模型名",
SubTitle: "增加自定義模型可選項,使用英文逗號隔開",
},
},
Model: "模型 (model)",
Temperature: {
Title: "隨機性 (temperature)",
SubTitle: "值越大,回應越隨機",
},
TopP: {
Title: "核采樣 (top_p)",
SubTitle: "與隨機性類似,但不要和隨機性一起更改",
},
MaxTokens: {
Title: "單次回應限制 (max_tokens)",
SubTitle: "單次互動所用的最大 Token 數",
@@ -166,10 +376,16 @@ const tw: PartialLocaleType = {
Success: "已複製到剪貼簿中",
Failed: "複製失敗,請賦予剪貼簿權限",
},
Download: {
Success: "內容已下載到您的目錄。",
Failed: "下載失敗。",
},
Context: {
Toast: (x: any) => `已設定 ${x} 條前置上下文`,
Edit: "前置上下文和歷史記憶",
Add: "新增一條",
Clear: "上下文已清除",
Revert: "恢復上下文",
},
Plugin: { Name: "外掛" },
FineTuned: { Sysmessage: "你是一個助手" },
@@ -198,16 +414,34 @@ const tw: PartialLocaleType = {
Config: {
Avatar: "角色頭像",
Name: "角色名稱",
Sync: {
Title: "使用全局設定",
SubTitle: "當前對話是否使用全局模型設定",
Confirm: "當前對話的自定義設定將會被自動覆蓋,確認啟用全局設定?",
},
HideContext: {
Title: "隱藏預設對話",
SubTitle: "隱藏後預設對話不會出現在聊天界面",
},
Share: {
Title: "分享此面具",
SubTitle: "生成此面具的直達鏈接",
Action: "覆制鏈接",
},
},
},
NewChat: {
Return: "返回",
Skip: "跳過",
NotShow: "不再呈現",
ConfirmNoShow: "確認停用?停用後可以隨時在設定中重新啟用。",
Title: "挑選一個面具",
SubTitle: "現在開始,與面具背後的靈魂思維碰撞",
More: "搜尋更多",
NotShow: "不再呈現",
ConfirmNoShow: "確認停用?停用後可以隨時在設定中重新啟用。",
},
URLCommand: {
Code: "檢測到連結中已經包含訪問碼,是否自動填入?",
Settings: "檢測到連結中包含了預設設定,是否自動填入?",
},
UI: {
Confirm: "確認",
@@ -215,8 +449,15 @@ const tw: PartialLocaleType = {
Close: "關閉",
Create: "新增",
Edit: "編輯",
Export: "導出",
Import: "導入",
Sync: "同步",
Config: "設定",
},
Exporter: {
Description: {
Title: "只有清除上下文之後的消息會被展示",
},
Model: "模型",
Messages: "訊息",
Topic: "主題",
@@ -224,4 +465,14 @@ const tw: PartialLocaleType = {
},
};
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export type LocaleType = typeof tw;
export type PartialLocaleType = DeepPartial<typeof tw>;
export default tw;
// Translated by @chunkiuuu, feel free the submit new pr if there are typo/incorrect translations :D

View File

@@ -1,4 +1,4 @@
import { trimTopic } from "../utils";
import { trimTopic, getMessageTextContent } from "../utils";
import Locale, { getLang } from "../locales";
import { showToast } from "../components/ui-lib";
@@ -12,8 +12,9 @@ import {
ModelProvider,
StoreKey,
SUMMARIZE_MODEL,
GEMINI_SUMMARIZE_MODEL,
} from "../constant";
import { ClientApi, RequestMessage } from "../client/api";
import { ClientApi, RequestMessage, MultimodalContent } from "../client/api";
import { ChatControllerPool } from "../client/controller";
import { prettyObject } from "../utils/format";
import { estimateTokenLength } from "../utils/token";
@@ -84,11 +85,20 @@ function createEmptySession(): ChatSession {
function getSummarizeModel(currentModel: string) {
// if it is using gpt-* models, force to use 3.5 to summarize
return currentModel.startsWith("gpt") ? SUMMARIZE_MODEL : currentModel;
if (currentModel.startsWith("gpt")) {
return SUMMARIZE_MODEL;
}
if (currentModel.startsWith("gemini-pro")) {
return GEMINI_SUMMARIZE_MODEL;
}
return currentModel;
}
function countMessages(msgs: ChatMessage[]) {
return msgs.reduce((pre, cur) => pre + estimateTokenLength(cur.content), 0);
return msgs.reduce(
(pre, cur) => pre + estimateTokenLength(getMessageTextContent(cur)),
0,
);
}
function fillTemplateWith(input: string, modelConfig: ModelConfig) {
@@ -280,16 +290,36 @@ export const useChatStore = createPersistStore(
get().summarizeSession();
},
async onUserInput(content: string) {
async onUserInput(content: string, attachImages?: string[]) {
const session = get().currentSession();
const modelConfig = session.mask.modelConfig;
const userContent = fillTemplateWith(content, modelConfig);
console.log("[User Input] after template: ", userContent);
const userMessage: ChatMessage = createMessage({
let mContent: string | MultimodalContent[] = userContent;
if (attachImages && attachImages.length > 0) {
mContent = [
{
type: "text",
text: userContent,
},
];
mContent = mContent.concat(
attachImages.map((url) => {
return {
type: "image_url",
image_url: {
url: url,
},
};
}),
);
}
let userMessage: ChatMessage = createMessage({
role: "user",
content: userContent,
content: mContent,
});
const botMessage: ChatMessage = createMessage({
@@ -307,7 +337,7 @@ export const useChatStore = createPersistStore(
get().updateCurrentSession((session) => {
const savedUserMessage = {
...userMessage,
content,
content: mContent,
};
session.messages = session.messages.concat([
savedUserMessage,
@@ -461,7 +491,7 @@ export const useChatStore = createPersistStore(
) {
const msg = messages[i];
if (!msg || msg.isError) continue;
tokenCount += estimateTokenLength(msg.content);
tokenCount += estimateTokenLength(getMessageTextContent(msg));
reversedRecentMessages.push(msg);
}

View File

@@ -91,7 +91,7 @@ export const ModalConfigValidator = {
return limitNumber(x, -2, 2, 0);
},
temperature(x: number) {
return limitNumber(x, 0, 1, 1);
return limitNumber(x, 0, 2, 1);
},
top_p(x: number) {
return limitNumber(x, 0, 1, 1);

View File

@@ -118,7 +118,7 @@ export const useSyncStore = createPersistStore(
}),
{
name: StoreKey.Sync,
version: 1.1,
version: 1.2,
migrate(persistedState, version) {
const newState = persistedState as typeof DEFAULT_SYNC_STATE;
@@ -127,6 +127,15 @@ export const useSyncStore = createPersistStore(
newState.upstash.username = STORAGE_KEY;
}
if (version < 1.2) {
if (
(persistedState as typeof DEFAULT_SYNC_STATE).proxyUrl ===
"/api/cors/"
) {
newState.proxyUrl = "";
}
}
return newState as any;
},
},

View File

@@ -1,3 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "./animation.scss";
@import "./window.scss";

View File

@@ -1,12 +1,17 @@
import { useEffect, useState } from "react";
import { showToast } from "./components/ui-lib";
import Locale from "./locales";
import { RequestMessage } from "./client/api";
import { DEFAULT_MODELS } from "./constant";
export function trimTopic(topic: string) {
// Fix an issue where double quotes still show in the Indonesian language
// This will remove the specified punctuation from the end of the string
// and also trim quotes from both the start and end if they exist.
return topic.replace(/^["“”]+|["“”]+$/g, "").replace(/[,。!?”“"、,.!?]*$/, "");
return topic
// fix for gemini
.replace(/^["“”*]+|["“”*]+$/g, "")
.replace(/[,。!?”“"、,.!?*]*$/, "");
}
export async function copyToClipboard(text: string) {
@@ -40,8 +45,8 @@ export async function downloadAs(text: string, filename: string) {
defaultPath: `${filename}`,
filters: [
{
name: `${filename.split('.').pop()} files`,
extensions: [`${filename.split('.').pop()}`],
name: `${filename.split(".").pop()} files`,
extensions: [`${filename.split(".").pop()}`],
},
{
name: "All Files",
@@ -52,9 +57,9 @@ export async function downloadAs(text: string, filename: string) {
if (result !== null) {
try {
await window.__TAURI__.fs.writeBinaryFile(
await window.__TAURI__.fs.writeTextFile(
result,
new Uint8Array([...text].map((c) => c.charCodeAt(0)))
text
);
showToast(Locale.Download.Success);
} catch (error) {
@@ -69,16 +74,59 @@ export async function downloadAs(text: string, filename: string) {
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text),
);
element.setAttribute("download", filename);
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.style.display = "none";
document.body.appendChild(element);
element.click();
element.click();
document.body.removeChild(element);
document.body.removeChild(element);
}
}
export function compressImage(file: File, maxSize: number): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (readerEvent: any) => {
const image = new Image();
image.onload = () => {
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
let width = image.width;
let height = image.height;
let quality = 0.9;
let dataUrl;
do {
canvas.width = width;
canvas.height = height;
ctx?.clearRect(0, 0, canvas.width, canvas.height);
ctx?.drawImage(image, 0, 0, width, height);
dataUrl = canvas.toDataURL("image/jpeg", quality);
if (dataUrl.length < maxSize) break;
if (quality > 0.5) {
// Prioritize quality reduction
quality -= 0.1;
} else {
// Then reduce the size
width *= 0.9;
height *= 0.9;
}
} while (dataUrl.length > maxSize);
resolve(dataUrl);
};
image.onerror = reject;
image.src = readerEvent.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function readFromFile() {
return new Promise<string>((res, rej) => {
const fileInput = document.createElement("input");
@@ -212,8 +260,43 @@ export function getCSSVar(varName: string) {
export function isMacOS(): boolean {
if (typeof window !== "undefined") {
let userAgent = window.navigator.userAgent.toLocaleLowerCase();
const macintosh = /iphone|ipad|ipod|macintosh/.test(userAgent)
return !!macintosh
const macintosh = /iphone|ipad|ipod|macintosh/.test(userAgent);
return !!macintosh;
}
return false
return false;
}
export function getMessageTextContent(message: RequestMessage) {
if (typeof message.content === "string") {
return message.content;
}
for (const c of message.content) {
if (c.type === "text") {
return c.text ?? "";
}
}
return "";
}
export function getMessageImages(message: RequestMessage): string[] {
if (typeof message.content === "string") {
return [];
}
const urls: string[] = [];
for (const c of message.content) {
if (c.type === "image_url") {
urls.push(c.image_url?.url ?? "");
}
}
return urls;
}
export function isVisionModel(model: string) {
// Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using)
const visionKeywords = [
"vision",
"claude-3",
];
return visionKeywords.some(keyword => model.includes(keyword));
}

View File

@@ -1,6 +1,5 @@
import { STORAGE_KEY } from "@/app/constant";
import { SyncStore } from "@/app/store/sync";
import { corsFetch } from "../cors";
import { chunks } from "../format";
export type UpstashConfig = SyncStore["upstash"];
@@ -18,10 +17,9 @@ export function createUpstashClient(store: SyncStore) {
return {
async check() {
try {
const res = await corsFetch(this.path(`get/${storeKey}`), {
const res = await fetch(this.path(`get/${storeKey}`, proxyUrl), {
method: "GET",
headers: this.headers(),
proxyUrl,
});
console.log("[Upstash] check", res.status, res.statusText);
return [200].includes(res.status);
@@ -32,10 +30,9 @@ export function createUpstashClient(store: SyncStore) {
},
async redisGet(key: string) {
const res = await corsFetch(this.path(`get/${key}`), {
const res = await fetch(this.path(`get/${key}`, proxyUrl), {
method: "GET",
headers: this.headers(),
proxyUrl,
});
console.log("[Upstash] get key = ", key, res.status, res.statusText);
@@ -45,11 +42,10 @@ export function createUpstashClient(store: SyncStore) {
},
async redisSet(key: string, value: string) {
const res = await corsFetch(this.path(`set/${key}`), {
const res = await fetch(this.path(`set/${key}`, proxyUrl), {
method: "POST",
headers: this.headers(),
body: value,
proxyUrl,
});
console.log("[Upstash] set key = ", key, res.status, res.statusText);
@@ -84,18 +80,28 @@ export function createUpstashClient(store: SyncStore) {
Authorization: `Bearer ${config.apiKey}`,
};
},
path(path: string) {
let url = config.endpoint;
if (!url.endsWith("/")) {
url += "/";
path(path: string, proxyUrl: string = "") {
if (!path.endsWith("/")) {
path += "/";
}
if (path.startsWith("/")) {
path = path.slice(1);
}
return url + path;
if (proxyUrl.length > 0 && !proxyUrl.endsWith("/")) {
proxyUrl += "/";
}
let url;
if (proxyUrl.length > 0 || proxyUrl === "/") {
let u = new URL(proxyUrl + "/api/upstash/" + path);
// add query params
u.searchParams.append("endpoint", config.endpoint);
url = u.toString();
} else {
url = "/api/upstash/" + path + "?endpoint=" + config.endpoint;
}
return url;
},
};
}

View File

@@ -1,6 +1,5 @@
import { STORAGE_KEY } from "@/app/constant";
import { SyncStore } from "@/app/store/sync";
import { corsFetch } from "../cors";
export type WebDAVConfig = SyncStore["webdav"];
export type WebDavClient = ReturnType<typeof createWebDavClient>;
@@ -15,10 +14,9 @@ export function createWebDavClient(store: SyncStore) {
return {
async check() {
try {
const res = await corsFetch(this.path(folder), {
const res = await fetch(this.path(folder, proxyUrl), {
method: "MKCOL",
headers: this.headers(),
proxyUrl,
});
console.log("[WebDav] check", res.status, res.statusText);
return [201, 200, 404, 301, 302, 307, 308].includes(res.status);
@@ -30,10 +28,9 @@ export function createWebDavClient(store: SyncStore) {
},
async get(key: string) {
const res = await corsFetch(this.path(fileName), {
const res = await fetch(this.path(fileName, proxyUrl), {
method: "GET",
headers: this.headers(),
proxyUrl,
});
console.log("[WebDav] get key = ", key, res.status, res.statusText);
@@ -42,11 +39,10 @@ export function createWebDavClient(store: SyncStore) {
},
async set(key: string, value: string) {
const res = await corsFetch(this.path(fileName), {
const res = await fetch(this.path(fileName, proxyUrl), {
method: "PUT",
headers: this.headers(),
body: value,
proxyUrl,
});
console.log("[WebDav] set key = ", key, res.status, res.statusText);
@@ -59,18 +55,28 @@ export function createWebDavClient(store: SyncStore) {
authorization: `Basic ${auth}`,
};
},
path(path: string) {
let url = config.endpoint;
if (!url.endsWith("/")) {
url += "/";
path(path: string, proxyUrl: string = "") {
if (!path.endsWith("/")) {
path += "/";
}
if (path.startsWith("/")) {
path = path.slice(1);
}
return url + path;
if (proxyUrl.length > 0 && !proxyUrl.endsWith("/")) {
proxyUrl += "/";
}
let url;
if (proxyUrl.length > 0 || proxyUrl === "/") {
let u = new URL(proxyUrl + "/api/webdav/" + path);
// add query params
u.searchParams.append("endpoint", config.endpoint);
url = u.toString();
} else {
url = "/api/upstash/" + path + "?endpoint=" + config.endpoint;
}
return url;
},
};
}

View File

@@ -4,6 +4,9 @@ import { ApiPath, DEFAULT_API_HOST } from "../constant";
export function corsPath(path: string) {
const baseUrl = getClientConfig()?.isApp ? `${DEFAULT_API_HOST}` : "";
if (baseUrl === "" && path === "") {
return "";
}
if (!path.startsWith("/")) {
path = "/" + path;
}
@@ -14,37 +17,3 @@ export function corsPath(path: string) {
return `${baseUrl}${path}`;
}
export function corsFetch(
url: string,
options: RequestInit & {
proxyUrl?: string;
},
) {
if (!url.startsWith("http")) {
throw Error("[CORS Fetch] url must starts with http/https");
}
let proxyUrl = options.proxyUrl ?? corsPath(ApiPath.Cors);
if (!proxyUrl.endsWith("/")) {
proxyUrl += "/";
}
url = url.replace("://", "/");
const corsOptions = {
...options,
method: "POST",
headers: options.method
? {
...options.headers,
method: options.method,
}
: options.headers,
};
const corsUrl = proxyUrl + url;
console.info("[CORS] target = ", corsUrl);
return fetch(corsUrl, corsOptions);
}

View File

@@ -64,7 +64,7 @@ if (mode !== "export") {
nextConfig.rewrites = async () => {
const ret = [
// adjust for previous verison directly using "/api/proxy/" as proxy base route
// adjust for previous version directly using "/api/proxy/" as proxy base route
{
source: "/api/proxy/v1/:path*",
destination: "https://api.openai.com/v1/:path*",

View File

@@ -50,6 +50,7 @@
"@types/react-dom": "^18.2.7",
"@types/react-katex": "^3.0.0",
"@types/spark-md5": "^3.0.4",
"autoprefixer": "^10.4.18",
"cross-env": "^7.0.3",
"eslint": "^8.49.0",
"eslint-config-next": "13.4.19",
@@ -57,11 +58,14 @@
"eslint-plugin-prettier": "^4.2.1",
"husky": "^8.0.0",
"lint-staged": "^13.2.2",
"postcss": "^8.4.35",
"prettier": "^3.0.2",
"tailwindcss": "^3.4.1",
"typescript": "5.2.2",
"webpack": "^5.88.1"
},
"resolutions": {
"lint-staged/yaml": "^2.2.2"
}
}
"packageManager": "yarn@1.22.19"
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -9,7 +9,7 @@
},
"package": {
"productName": "NextChat",
"version": "2.10.3"
"version": "2.11.3"
},
"tauri": {
"allowlist": {

15
tailwind.config.js Normal file
View File

@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -23,6 +23,12 @@
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/calcTextareaHeight.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/calcTextareaHeight.ts"
],
"exclude": ["node_modules"]
}

2072
yarn.lock

File diff suppressed because it is too large Load Diff