mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-22 05:30:19 +09:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
8fbcc653a0
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
43
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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
146
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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.
|
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
24
.github/ISSUE_TEMPLATE/功能建议.md
vendored
@ -1,24 +0,0 @@
|
||||
---
|
||||
name: 功能建议
|
||||
about: 请告诉我们你的灵光一闪
|
||||
title: "[Feature] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
> 为了提高交流效率,我们设立了官方 QQ 群和 QQ 频道,如果你在使用或者搭建过程中遇到了任何问题,请先第一时间加群或者频道咨询解决,除非是可以稳定复现的 Bug 或者较为有创意的功能建议,否则请不要随意往 Issue 区发送低质无意义帖子。
|
||||
|
||||
> [点击加入官方群聊](https://github.com/Yidadaa/ChatGPT-Next-Web/discussions/1724)
|
||||
|
||||
**这个功能与现有的问题有关吗?**
|
||||
如果有关,请在此列出链接或者描述问题。
|
||||
|
||||
**你想要什么功能或者有什么建议?**
|
||||
尽管告诉我们。
|
||||
|
||||
**有没有可以参考的同类竞品?**
|
||||
可以给出参考产品的链接或者截图。
|
||||
|
||||
**其他信息**
|
||||
可以说说你的其他考虑。
|
36
.github/ISSUE_TEMPLATE/反馈问题.md
vendored
36
.github/ISSUE_TEMPLATE/反馈问题.md
vendored
@ -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 或者服务器部署]
|
21
README.md
21
README.md
@ -20,6 +20,9 @@
|
||||
|
||||
[](https://gitpod.io/#https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain)
|
||||
|
||||
> [!WARNING]
|
||||
> 本项目插件功能基于 [OpenAI API 函数调用](https://platform.openai.com/docs/guides/function-calling) 功能实现,转发 GitHub Copilot 接口或类似实现的模拟接口并不能正常调用插件功能!
|
||||
|
||||

|
||||
|
||||

|
||||
@ -37,7 +40,8 @@
|
||||
- 支持 OpenAI TTS(文本转语音)https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/208
|
||||
|
||||
- 支持 GPT-4V(视觉) 模型
|
||||
- 需要配置对象存储服务,请参考 [对象存储服务配置指南](./docs/s3-oss.md) 配置
|
||||
- ~~需要配置对象存储服务,请参考 [对象存储服务配置指南](./docs/s3-oss.md) 配置~~
|
||||
- 已同步上游仓库视觉模型调用方式(压缩图片),不过这里还是会有撑爆 localstorage 的风险(https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/77#issuecomment-1846410078),后续会兼容两种形式的图片本地存储,如果配置了对象存储会优先使用对象存储。
|
||||
|
||||
- 基于 [LangChain](https://github.com/hwchase17/langchainjs) 实现的插件功能,目前支持以下插件,未来会添加更多
|
||||
- 搜索(优先级:`GoogleCustomSearch > SerpAPI > BingSerpAPI > ChooseSearchEngine > DuckDuckGo`)
|
||||
@ -106,7 +110,7 @@
|
||||
- 配置密钥 `GOOGLE_API_KEY` ,key 可以在这里获取:https://ai.google.dev/tutorials/setup
|
||||
- 配置自定义接口地址(可选) `GOOGLE_BASE_URL`,可以使用我的这个项目搭建一个基于 vercel 的代理服务:[google-gemini-vercel-proxy](https://github.com/Hk-Gosuto/google-gemini-vercel-proxy)
|
||||
- 常见问题参考:[Gemini Prompting FAQs](https://js.langchain.com/docs/integrations/chat/google_generativeai#gemini-prompting-faqs)
|
||||
- gemini-pro-vision 模型需要配置对象存储服务,请参考 [对象存储服务配置指南](./docs/s3-oss.md) 配置
|
||||
- ~~gemini-pro-vision 模型需要配置对象存储服务,请参考 [对象存储服务配置指南](./docs/s3-oss.md) 配置~~
|
||||
- ⚠ gemini-pro-vision 注意事项 https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/203 :
|
||||
- 每次对话必须包含图像数据,不然会出现 `Add an image to use models/gemini-pro-vision, or switch your model to a text model.` 错误。
|
||||
- 只支持单轮对话,多轮对话会出现 `Multiturn chat is not enabled for models/gemini-pro-vision` 错误。
|
||||
@ -134,12 +138,17 @@
|
||||
|
||||
最新版本中已经移除上面两个模型。
|
||||
|
||||
- [ ] 支持添加自定义插件
|
||||
- [ ] ~~支持添加自定义插件~~
|
||||
|
||||
## 最新动态
|
||||
- [ ] 支持其他类型文件上传 https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/77
|
||||
|
||||
- 🚀 v2.9.5 正式版本发布
|
||||
- 🚀 v2.9.1-plugin-preview 预览版发布。
|
||||
- [ ] 支持 Azure Storage https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/217
|
||||
|
||||
- [ ] 支持语音输入 https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/208
|
||||
|
||||
- [ ] 支持 Fooocus-API 插件 https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/58
|
||||
|
||||
- [ ] 支持在 UI 配置插件需要的 Key https://github.com/Hk-Gosuto/ChatGPT-Next-Web-LangChain/issues/70
|
||||
|
||||
## 开始使用
|
||||
|
||||
|
@ -17,7 +17,7 @@ async function handle(
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
let baseUrl = serverConfig.googleBaseUrl || GOOGLE_BASE_URL;
|
||||
let baseUrl = serverConfig.googleUrl || GOOGLE_BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
|
@ -16,10 +16,17 @@ export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
|
||||
export const TTSModels = ["tts-1", "tts-1-hd"] 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;
|
||||
image_url?: string;
|
||||
content: string | MultimodalContent[];
|
||||
}
|
||||
|
||||
export interface LLMConfig {
|
||||
|
@ -1,9 +1,4 @@
|
||||
import {
|
||||
ApiPath,
|
||||
Google,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import {
|
||||
AgentChatOptions,
|
||||
ChatOptions,
|
||||
@ -14,13 +9,13 @@ import {
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import axios from "axios";
|
||||
|
||||
const getImageBase64Data = async (url: string) => {
|
||||
const response = await axios.get(url, { responseType: "arraybuffer" });
|
||||
const base64 = Buffer.from(response.data, "binary").toString("base64");
|
||||
return base64;
|
||||
};
|
||||
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 {
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
@ -39,32 +34,34 @@ export class GeminiProApi implements LLMApi {
|
||||
);
|
||||
}
|
||||
async chat(options: ChatOptions): Promise<void> {
|
||||
const messages: any[] = [];
|
||||
if (options.config.model.includes("vision")) {
|
||||
for (const v of options.messages) {
|
||||
let message: any = {
|
||||
role: v.role.replace("assistant", "model").replace("system", "user"),
|
||||
parts: [{ text: v.content }],
|
||||
};
|
||||
if (v.image_url) {
|
||||
var base64Data = await getImageBase64Data(v.image_url);
|
||||
message.parts.push({
|
||||
// const apiClient = this;
|
||||
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: "image/jpeg",
|
||||
data: base64Data,
|
||||
mime_type: imageType,
|
||||
data: imageData,
|
||||
},
|
||||
});
|
||||
}
|
||||
messages.push(message);
|
||||
}
|
||||
} else {
|
||||
options.messages.map((v) =>
|
||||
messages.push({
|
||||
role: v.role.replace("assistant", "model").replace("system", "user"),
|
||||
parts: [{ text: v.content }],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
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; ) {
|
||||
@ -79,7 +76,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,
|
||||
@ -118,15 +117,28 @@ export class GeminiProApi implements LLMApi {
|
||||
],
|
||||
};
|
||||
|
||||
console.log("[Request] google payload: ", requestPayload);
|
||||
const accessStore = useAccessStore.getState();
|
||||
let baseUrl = accessStore.googleUrl;
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
let shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
try {
|
||||
const chatPath = this.path(
|
||||
Google.ChatPath.replace("{{model}}", options.config.model),
|
||||
);
|
||||
let googleChatPath = visionModel
|
||||
? Google.VisionChatPath
|
||||
: Google.ChatPath;
|
||||
let chatPath = this.path(googleChatPath);
|
||||
|
||||
if (!baseUrl) {
|
||||
baseUrl = isApp
|
||||
? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath
|
||||
: chatPath;
|
||||
}
|
||||
|
||||
if (isApp) {
|
||||
baseUrl += `?key=${accessStore.googleApiKey}`;
|
||||
}
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
@ -142,10 +154,6 @@ export class GeminiProApi implements LLMApi {
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let streamChatPath = chatPath.replace(
|
||||
"generateContent",
|
||||
"streamGenerateContent",
|
||||
);
|
||||
let finished = false;
|
||||
|
||||
let existingTexts: string[] = [];
|
||||
@ -175,11 +183,12 @@ export class GeminiProApi implements LLMApi {
|
||||
|
||||
// start animaion
|
||||
animateResponseText();
|
||||
fetch(streamChatPath, chatPayload)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await response?.text());
|
||||
}
|
||||
|
||||
fetch(
|
||||
baseUrl.replace("generateContent", "streamGenerateContent"),
|
||||
chatPayload,
|
||||
)
|
||||
.then((response) => {
|
||||
const reader = response?.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let partialData = "";
|
||||
@ -189,6 +198,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;
|
||||
@ -230,7 +252,7 @@ export class GeminiProApi implements LLMApi {
|
||||
options.onError?.(error as Error);
|
||||
});
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
const res = await fetch(baseUrl, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
@ -259,30 +281,7 @@ export class GeminiProApi implements LLMApi {
|
||||
return [];
|
||||
}
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
const isGoogle =
|
||||
accessStore.useCustomConfig &&
|
||||
accessStore.provider === ServiceProvider.Google;
|
||||
|
||||
if (isGoogle && !accessStore.isValidGoogle()) {
|
||||
throw Error(
|
||||
"incomplete google config, please check it in your settings page",
|
||||
);
|
||||
}
|
||||
|
||||
let baseUrl = isGoogle ? accessStore.googleBaseUrl : ApiPath.GoogleAI;
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
baseUrl = ApiPath.GoogleAI;
|
||||
}
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.GoogleAI)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
return "/api/google/" + path;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
LLMUsage,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import Locale from "../../locales";
|
||||
@ -26,7 +27,11 @@ import {
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { makeAzurePath } from "@/app/azure";
|
||||
import axios from "axios";
|
||||
import {
|
||||
getMessageTextContent,
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
} from "@/app/utils";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
@ -120,49 +125,11 @@ export class ChatGPTApi implements LLMApi {
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages: any[] = [];
|
||||
|
||||
const getImageBase64Data = async (url: string) => {
|
||||
const response = await axios.get(url, { responseType: "arraybuffer" });
|
||||
const base64 = Buffer.from(response.data, "binary").toString("base64");
|
||||
return base64;
|
||||
};
|
||||
if (options.config.model === "gpt-4-vision-preview") {
|
||||
for (const v of options.messages) {
|
||||
let message: {
|
||||
role: string;
|
||||
content: {
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
}[];
|
||||
} = {
|
||||
const visionModel = isVisionModel(options.config.model);
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: [],
|
||||
};
|
||||
message.content.push({
|
||||
type: "text",
|
||||
text: v.content,
|
||||
});
|
||||
if (v.image_url) {
|
||||
var base64Data = await getImageBase64Data(v.image_url);
|
||||
message.content.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${base64Data}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
messages.push(message);
|
||||
}
|
||||
} else {
|
||||
options.messages.map((v) =>
|
||||
messages.push({
|
||||
role: v.role,
|
||||
content: v.content,
|
||||
}),
|
||||
);
|
||||
}
|
||||
content: visionModel ? v.content : getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
@ -186,6 +153,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;
|
||||
|
@ -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;
|
||||
@ -190,12 +232,10 @@
|
||||
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
$linear: linear-gradient(
|
||||
to right,
|
||||
$linear: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
rgba(0, 0, 0, 0));
|
||||
mask-image: $linear;
|
||||
|
||||
@mixin show {
|
||||
@ -328,7 +368,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user > .chat-message-container {
|
||||
.chat-message-user>.chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@ -350,6 +390,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) {
|
||||
@ -413,6 +454,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;
|
||||
@ -427,7 +526,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 {
|
||||
@ -492,6 +591,7 @@
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
.hint-content {
|
||||
font-size: 12px;
|
||||
|
||||
@ -506,15 +606,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);
|
||||
@ -526,9 +637,7 @@
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
.chat-input:focus {}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
@ -580,9 +689,4 @@
|
||||
.chat-input-send {
|
||||
bottom: 30px;
|
||||
}
|
||||
|
||||
.chat-input-image {
|
||||
bottom: 84px;
|
||||
right: 32px;
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
Fragment,
|
||||
RefObject,
|
||||
} from "react";
|
||||
|
||||
import SendWhiteIcon from "../icons/send-white.svg";
|
||||
@ -17,6 +18,7 @@ import CopyIcon from "../icons/copy.svg";
|
||||
import SpeakIcon from "../icons/speak.svg";
|
||||
import SpeakStopIcon from "../icons/speak-stop.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";
|
||||
@ -24,7 +26,7 @@ import MinIcon from "../icons/min.svg";
|
||||
import ResetIcon from "../icons/reload.svg";
|
||||
import BreakIcon from "../icons/break.svg";
|
||||
import SettingsIcon from "../icons/chat-settings.svg";
|
||||
import ClearIcon from "../icons/clear.svg";
|
||||
import DeleteIcon from "../icons/clear.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import PinIcon from "../icons/pin.svg";
|
||||
import EditIcon from "../icons/rename.svg";
|
||||
@ -33,6 +35,7 @@ import CancelIcon from "../icons/cancel.svg";
|
||||
import EnablePluginIcon from "../icons/plugin_enable.svg";
|
||||
import DisablePluginIcon from "../icons/plugin_disable.svg";
|
||||
import UploadIcon from "../icons/upload.svg";
|
||||
import ImageIcon from "../icons/image.svg";
|
||||
|
||||
import LightIcon from "../icons/light.svg";
|
||||
import DarkIcon from "../icons/dark.svg";
|
||||
@ -60,6 +63,10 @@ import {
|
||||
selectOrCopy,
|
||||
autoGrowTextArea,
|
||||
useMobileScreen,
|
||||
getMessageTextContent,
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
compressImage,
|
||||
} from "../utils";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
@ -101,6 +108,7 @@ import { useAllModels } from "../utils/hooks";
|
||||
import Image from "next/image";
|
||||
import { ClientApi } from "../client/api";
|
||||
import { createTTSPlayer } from "../utils/audio";
|
||||
import { MultimodalContent } from "../client/api";
|
||||
|
||||
const ttsPlayer = createTTSPlayer();
|
||||
|
||||
@ -408,11 +416,13 @@ function ChatAction(props: {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -425,7 +435,7 @@ function useScrollToBottom() {
|
||||
|
||||
// auto scroll
|
||||
useEffect(() => {
|
||||
if (autoScroll) {
|
||||
if (autoScroll && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
});
|
||||
@ -439,18 +449,20 @@ function useScrollToBottom() {
|
||||
}
|
||||
|
||||
export function ChatActions(props: {
|
||||
uploadImage: () => void;
|
||||
setAttachImages: (images: string[]) => void;
|
||||
setUploading: (uploading: boolean) => void;
|
||||
showPromptModal: () => void;
|
||||
scrollToBottom: () => void;
|
||||
showPromptHints: () => void;
|
||||
imageSelected: (img: any) => void;
|
||||
hitBottom: boolean;
|
||||
uploading: boolean;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
|
||||
// switch Plugins
|
||||
const usePlugins = chatStore.currentSession().mask.usePlugins;
|
||||
function switchUsePlugins() {
|
||||
@ -473,33 +485,6 @@ export function ChatActions(props: {
|
||||
const couldStop = ChatControllerPool.hasPending();
|
||||
const stopAll = () => ChatControllerPool.stopAll();
|
||||
|
||||
function selectImage() {
|
||||
document.getElementById("chat-image-file-select-upload")?.click();
|
||||
}
|
||||
|
||||
function closeImageButton() {
|
||||
document.getElementById("chat-input-image-close")?.click();
|
||||
}
|
||||
|
||||
const onImageSelected = async (e: any) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const api = new ClientApi();
|
||||
setUploadLoading(true);
|
||||
const uploadFile = await api.file
|
||||
.upload(file)
|
||||
.catch((e) => {
|
||||
console.error("[Upload]", e);
|
||||
showToast(prettyObject(e));
|
||||
})
|
||||
.finally(() => setUploadLoading(false));
|
||||
props.imageSelected({
|
||||
fileName: uploadFile.fileName,
|
||||
fileUrl: uploadFile.filePath,
|
||||
});
|
||||
e.target.value = null;
|
||||
};
|
||||
|
||||
// switch model
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
const allModels = useAllModels();
|
||||
@ -508,8 +493,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);
|
||||
@ -520,37 +513,7 @@ export function ChatActions(props: {
|
||||
);
|
||||
showToast(nextModel);
|
||||
}
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items || [];
|
||||
const api = new ClientApi();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image") === -1) continue;
|
||||
const file = items[i].getAsFile();
|
||||
if (file !== null) {
|
||||
setUploadLoading(true);
|
||||
api.file
|
||||
.upload(file)
|
||||
.then((uploadFile) => {
|
||||
props.imageSelected({
|
||||
fileName: uploadFile.fileName,
|
||||
fileUrl: uploadFile.filePath,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[Upload]", e);
|
||||
showToast(prettyObject(e));
|
||||
})
|
||||
.finally(() => setUploadLoading(false));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (currentModel.includes("vision")) {
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => {
|
||||
window.removeEventListener("paste", onPaste);
|
||||
};
|
||||
}
|
||||
}, [chatStore, currentModel, models, props]);
|
||||
}, [chatStore, currentModel, models]);
|
||||
|
||||
return (
|
||||
<div className={styles["chat-input-actions"]}>
|
||||
@ -577,6 +540,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]}
|
||||
@ -592,7 +562,6 @@ export function ChatActions(props: {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
onClick={props.showPromptHints}
|
||||
text={Locale.Chat.InputActions.Prompt}
|
||||
@ -626,23 +595,6 @@ export function ChatActions(props: {
|
||||
icon={usePlugins ? <EnablePluginIcon /> : <DisablePluginIcon />}
|
||||
/>
|
||||
)}
|
||||
{currentModel.includes("vision") && (
|
||||
<ChatAction
|
||||
onClick={selectImage}
|
||||
text="选择图片"
|
||||
loding={uploadLoading}
|
||||
icon={<UploadIcon />}
|
||||
innerNode={
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.webp,.jpeg"
|
||||
id="chat-image-file-select-upload"
|
||||
style={{ display: "none" }}
|
||||
onChange={onImageSelected}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showModelSelector && (
|
||||
<Selector
|
||||
@ -657,7 +609,6 @@ export function ChatActions(props: {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
session.mask.modelConfig.model = s[0] as ModelType;
|
||||
session.mask.syncGlobalConfig = false;
|
||||
closeImageButton();
|
||||
});
|
||||
showToast(s[0]);
|
||||
}}
|
||||
@ -746,6 +697,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 };
|
||||
|
||||
@ -761,10 +720,22 @@ function _Chat() {
|
||||
const [userImage, setUserImage] = useState<any>();
|
||||
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();
|
||||
@ -843,8 +814,9 @@ function _Chat() {
|
||||
}
|
||||
setIsLoading(true);
|
||||
chatStore
|
||||
.onUserInput(userInput, userImage?.fileUrl)
|
||||
.onUserInput(userInput, attachImages)
|
||||
.then(() => setIsLoading(false));
|
||||
setAttachImages([]);
|
||||
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||
localStorage.setItem(LAST_INPUT_IMAGE_KEY, userImage);
|
||||
setUserInput("");
|
||||
@ -925,9 +897,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();
|
||||
@ -995,9 +967,9 @@ function _Chat() {
|
||||
|
||||
// resend the message
|
||||
setIsLoading(true);
|
||||
chatStore
|
||||
.onUserInput(userMessage.content, userMessage.image_url)
|
||||
.then(() => setIsLoading(false));
|
||||
const textContent = getMessageTextContent(userMessage);
|
||||
const images = getMessageImages(userMessage);
|
||||
chatStore.onUserInput(textContent, images).then(() => setIsLoading(false));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
@ -1085,7 +1057,6 @@ function _Chat() {
|
||||
...createMessage({
|
||||
role: "user",
|
||||
content: userInput,
|
||||
image_url: userImage?.fileUrl,
|
||||
}),
|
||||
preview: true,
|
||||
},
|
||||
@ -1139,7 +1110,6 @@ function _Chat() {
|
||||
setHitBottom(isHitBottom);
|
||||
setAutoScroll(isHitBottom);
|
||||
};
|
||||
|
||||
function scrollToBottom() {
|
||||
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
||||
scrollDomToBottom();
|
||||
@ -1225,6 +1195,94 @@ 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);
|
||||
}
|
||||
|
||||
const textareaMinHeight = userImage ? 121 : 68;
|
||||
|
||||
return (
|
||||
@ -1333,15 +1391,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;
|
||||
}
|
||||
});
|
||||
}}
|
||||
@ -1384,7 +1456,7 @@ function _Chat() {
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Delete}
|
||||
icon={<ClearIcon />}
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => onDelete(message.id ?? i)}
|
||||
/>
|
||||
|
||||
@ -1396,7 +1468,11 @@ function _Chat() {
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Copy}
|
||||
icon={<CopyIcon />}
|
||||
onClick={() => copyToClipboard(message.content)}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
getMessageTextContent(message),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{config.ttsConfig.enable && (
|
||||
<ChatAction
|
||||
@ -1413,7 +1489,9 @@ function _Chat() {
|
||||
<SpeakIcon />
|
||||
)
|
||||
}
|
||||
onClick={() => openaiSpeech(message.content)}
|
||||
onClick={() =>
|
||||
openaiSpeech(getMessageTextContent(message))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -1450,8 +1528,7 @@ function _Chat() {
|
||||
)}
|
||||
<div className={styles["chat-message-item"]}>
|
||||
<Markdown
|
||||
imageBase64={message.image_url}
|
||||
content={message.content}
|
||||
content={getMessageTextContent(message)}
|
||||
loading={
|
||||
(message.preview || message.streaming) &&
|
||||
message.content.length === 0 &&
|
||||
@ -1460,12 +1537,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>
|
||||
{!isUser && message.model?.includes("vision") && (
|
||||
<div
|
||||
@ -1497,9 +1604,13 @@ function _Chat() {
|
||||
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
||||
|
||||
<ChatActions
|
||||
uploadImage={uploadImage}
|
||||
setAttachImages={setAttachImages}
|
||||
setUploading={setUploading}
|
||||
showPromptModal={() => setShowPromptModal(true)}
|
||||
scrollToBottom={scrollToBottom}
|
||||
hitBottom={hitBottom}
|
||||
uploading={uploading}
|
||||
showPromptHints={() => {
|
||||
// Click again to close
|
||||
if (promptHints.length > 0) {
|
||||
@ -1515,8 +1626,16 @@ function _Chat() {
|
||||
setUserImage(img);
|
||||
}}
|
||||
/>
|
||||
<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)}
|
||||
@ -1525,6 +1644,7 @@ function _Chat() {
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={scrollToBottom}
|
||||
onClick={scrollToBottom}
|
||||
onPaste={handlePaste}
|
||||
rows={inputRows}
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
@ -1532,30 +1652,27 @@ function _Chat() {
|
||||
minHeight: textareaMinHeight,
|
||||
}}
|
||||
/>
|
||||
{userImage && (
|
||||
<div className={styles["chat-input-image"]}>
|
||||
{attachImages.length != 0 && (
|
||||
<div className={styles["attach-images"]}>
|
||||
{attachImages.map((image, index) => {
|
||||
return (
|
||||
<div
|
||||
style={{ position: "relative", width: "48px", height: "48px" }}
|
||||
key={index}
|
||||
className={styles["attach-image"]}
|
||||
style={{ backgroundImage: `url("${image}")` }}
|
||||
>
|
||||
<Image
|
||||
loader={() => userImage.fileUrl}
|
||||
src={userImage.fileUrl}
|
||||
alt={userImage.filename}
|
||||
title={userImage.filename}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
objectPosition="center"
|
||||
<div className={styles["attach-image-mask"]}>
|
||||
<DeleteImageButton
|
||||
deleteImage={() => {
|
||||
setAttachImages(
|
||||
attachImages.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={styles["chat-input-image-close"]}
|
||||
id="chat-input-image-close"
|
||||
onClick={() => {
|
||||
setUserImage(null);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
@ -1565,7 +1682,7 @@ function _Chat() {
|
||||
type="primary"
|
||||
onClick={() => doSubmit(userInput, userImage)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showExport && (
|
||||
|
@ -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 {}
|
||||
}
|
@ -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>
|
||||
@ -594,11 +600,37 @@ export function ImagePreviewer(props: {
|
||||
|
||||
<div className={styles["body"]}>
|
||||
<Markdown
|
||||
content={markdownImageUrlCorsProcess(m.content)}
|
||||
imageBase64={m.image_url}
|
||||
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>
|
||||
);
|
||||
@ -617,8 +649,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");
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -37,7 +37,12 @@ import { useLocation, 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,
|
||||
getMessageTextContent,
|
||||
readFromFile,
|
||||
} from "../utils";
|
||||
import { Updater } from "../typing";
|
||||
import { ModelConfigList } from "./model-config";
|
||||
import { FileName, Path } from "../constant";
|
||||
@ -74,7 +79,7 @@ function ContextPromptItem(props: {
|
||||
</Select>
|
||||
)}
|
||||
<Input
|
||||
value={props.prompt.content}
|
||||
value={getMessageTextContent(props.prompt)}
|
||||
type="text"
|
||||
className={chatStyle["context-content"]}
|
||||
rows={focusingInput ? 5 : 1}
|
||||
|
@ -17,6 +17,7 @@ import { Markdown } from "./markdown";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path } from "@/app/constant";
|
||||
import Locale from "../locales";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
|
||||
interface SearchResult {
|
||||
sessionId: string;
|
||||
@ -65,8 +66,8 @@ function HighlightedMessage({
|
||||
search: string;
|
||||
}) {
|
||||
const highlightedMessage = useMemo(
|
||||
() => highlightAndShorten(message.content, search),
|
||||
[message.content, search],
|
||||
() => highlightAndShorten(getMessageTextContent(message), search),
|
||||
[getMessageTextContent(message), search],
|
||||
);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -185,7 +186,11 @@ function SearchBarComponent(
|
||||
const matchingMessages: ChatMessage[] = [];
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.content.toLowerCase().includes(input.toLowerCase())) {
|
||||
if (
|
||||
getMessageTextContent(message)
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
) {
|
||||
matchingMessages.push(message!);
|
||||
}
|
||||
}
|
||||
|
@ -1076,19 +1076,19 @@ export function Settings() {
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={accessStore.googleBaseUrl}
|
||||
value={accessStore.googleUrl}
|
||||
placeholder={Google.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) =>
|
||||
(access.googleBaseUrl = e.currentTarget.value),
|
||||
(access.googleUrl = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></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}
|
||||
@ -1105,9 +1105,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
|
||||
|
@ -29,7 +29,10 @@ declare global {
|
||||
|
||||
// google only
|
||||
GOOGLE_API_KEY?: string;
|
||||
GOOGLE_BASE_URL?: string;
|
||||
GOOGLE_URL?: string;
|
||||
|
||||
// google tag manager
|
||||
GTM_ID?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -87,7 +90,7 @@ export const getServerSideConfig = () => {
|
||||
|
||||
isGoogle,
|
||||
googleApiKey: process.env.GOOGLE_API_KEY,
|
||||
googleBaseUrl: process.env.GOOGLE_BASE_URL,
|
||||
googleUrl: process.env.GEMINI_BASE_URL ?? process.env.GOOGLE_URL,
|
||||
|
||||
gtmId: process.env.GTM_ID,
|
||||
|
||||
|
@ -92,7 +92,10 @@ export const Azure = {
|
||||
|
||||
export const Google = {
|
||||
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
||||
ChatPath: "v1beta/models/{{model}}:generateContent",
|
||||
ChatPath: "v1beta/models/gemini-pro:generateContent",
|
||||
VisionChatPath: "v1beta/models/gemini-pro-vision:generateContent",
|
||||
|
||||
// /api/openai/v1/chat/completions
|
||||
};
|
||||
|
||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||
@ -106,13 +109,13 @@ Latex block: $$e=mc^2$$
|
||||
`;
|
||||
|
||||
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
|
||||
export const GOOGLE_SUMMARIZE_MODEL = "gemini-pro";
|
||||
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.
|
||||
|
1
app/icons/image.svg
Normal file
1
app/icons/image.svg
Normal 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
1
app/icons/loading.svg
Normal 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 |
@ -67,6 +67,7 @@ const cn = {
|
||||
Settings: "对话设置",
|
||||
EnablePlugins: "开启插件",
|
||||
DisablePlugins: "关闭插件",
|
||||
UploadImage: "上传图片",
|
||||
},
|
||||
Rename: "重命名对话",
|
||||
Typing: "正在输入…",
|
||||
@ -319,19 +320,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: {
|
||||
|
@ -69,6 +69,7 @@ const en: LocaleType = {
|
||||
Settings: "Settings",
|
||||
EnablePlugins: "Enable Plugins",
|
||||
DisablePlugins: "Disable Plugins",
|
||||
UploadImage: "Upload Images",
|
||||
},
|
||||
Rename: "Rename Chat",
|
||||
Typing: "Typing…",
|
||||
@ -327,9 +328,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: {
|
||||
@ -337,9 +337,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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -334,7 +334,7 @@ const sk: PartialLocaleType = {
|
||||
SubTitle: "Príklad:",
|
||||
},
|
||||
|
||||
ApiVerion: {
|
||||
ApiVersion: {
|
||||
Title: "Verzia API (gemini-pro verzia API)",
|
||||
SubTitle: "Vyberte špecifickú verziu časti",
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -32,7 +32,7 @@ const DEFAULT_ACCESS_STATE = {
|
||||
azureApiVersion: "2024-02-15-preview",
|
||||
|
||||
// google ai studio
|
||||
googleBaseUrl: "",
|
||||
googleUrl: "",
|
||||
googleApiKey: "",
|
||||
googleApiVersion: "v1",
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { trimTopic } from "../utils";
|
||||
import { trimTopic, getMessageTextContent } from "../utils";
|
||||
|
||||
import Locale, { getLang } from "../locales";
|
||||
import { showToast } from "../components/ui-lib";
|
||||
@ -8,13 +8,13 @@ import {
|
||||
DEFAULT_INPUT_TEMPLATE,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_SYSTEM_TEMPLATE,
|
||||
GOOGLE_SUMMARIZE_MODEL,
|
||||
KnowledgeCutOffDate,
|
||||
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";
|
||||
@ -97,13 +97,22 @@ function getSummarizeModel(currentModel: string) {
|
||||
const model = DEFAULT_MODELS.find((m) => m.name === currentModel);
|
||||
console.log("model", model);
|
||||
if (!model) return currentModel;
|
||||
if (model.provider.providerType === "google") return GOOGLE_SUMMARIZE_MODEL;
|
||||
if (model.provider.providerType === "google") return GEMINI_SUMMARIZE_MODEL;
|
||||
// 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) {
|
||||
@ -295,17 +304,36 @@ export const useChatStore = createPersistStore(
|
||||
get().summarizeSession();
|
||||
},
|
||||
|
||||
async onUserInput(content: string, image_url?: 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,
|
||||
image_url: image_url,
|
||||
content: mContent,
|
||||
});
|
||||
const botMessage: ChatMessage = createMessage({
|
||||
role: "assistant",
|
||||
@ -335,7 +363,7 @@ export const useChatStore = createPersistStore(
|
||||
get().updateCurrentSession((session) => {
|
||||
const savedUserMessage = {
|
||||
...userMessage,
|
||||
content,
|
||||
content: mContent,
|
||||
};
|
||||
session.messages.push(savedUserMessage);
|
||||
session.messages.push(botMessage);
|
||||
@ -558,7 +586,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);
|
||||
}
|
||||
|
||||
|
@ -125,7 +125,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);
|
||||
|
97
app/utils.ts
97
app/utils.ts
@ -1,12 +1,19 @@
|
||||
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 +47,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",
|
||||
@ -54,7 +61,7 @@ export async function downloadAs(text: string, filename: string) {
|
||||
try {
|
||||
await window.__TAURI__.fs.writeBinaryFile(
|
||||
result,
|
||||
new Uint8Array([...text].map((c) => c.charCodeAt(0)))
|
||||
new Uint8Array([...text].map((c) => c.charCodeAt(0))),
|
||||
);
|
||||
showToast(Locale.Download.Success);
|
||||
} catch (error) {
|
||||
@ -77,8 +84,51 @@ export async function downloadAs(text: string, filename: string) {
|
||||
element.click();
|
||||
|
||||
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 +262,41 @@ 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) {
|
||||
return (
|
||||
// model.startsWith("gpt-4-vision") ||
|
||||
// model.startsWith("gemini-pro-vision") ||
|
||||
model.includes("vision")
|
||||
);
|
||||
}
|
||||
|
@ -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*",
|
||||
|
9
src-tauri/Cargo.lock
generated
9
src-tauri/Cargo.lock
generated
@ -1887,15 +1887,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
@ -9,7 +9,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "NextChat",
|
||||
"version": "2.10.3"
|
||||
"version": "2.11.2"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
Loading…
Reference in New Issue
Block a user