Compare commits

..

12 Commits

Author SHA1 Message Date
glay
1998cf5ced Merge feature/update-bedrock-api into main 2024-11-05 20:20:20 +08:00
glay
1164e1bdf6 Merge feature/update-bedrock-api into main 2024-11-05 20:04:36 +08:00
glay
d55c752e1e 修改: app/api/bedrock.ts
修改:     app/utils.ts
2024-11-05 19:13:32 +08:00
glay
e3c18bb123 修改: app/api/bedrock.ts 2024-11-05 18:53:12 +08:00
glay
f532731e2a 修改: app/client/platforms/bedrock.ts 2024-11-05 17:32:52 +08:00
glay
58837f6dec 修改: app/api/bedrock.ts
修改:     app/client/platforms/bedrock.ts
2024-11-05 17:28:19 +08:00
glay
afbf5eb541 修改: .env.template
修改:     app/api/auth.ts
	修改:     app/api/bedrock.ts
	修改:     app/client/api.ts
	修改:     app/client/platforms/bedrock.ts
	修改:     app/components/settings.tsx
	修改:     app/config/server.ts
	修改:     app/constant.t
2024-11-05 14:27:52 +08:00
glay
0f276f59bb 修改: app/client/platforms/bedrock.ts 2024-11-05 10:34:33 +08:00
glay
fc391168e9 修改: app/api/bedrock.ts
删除:     app/api/bedrock/models.ts
	删除:     app/api/bedrock/utils.ts
	修改:     app/client/platforms/bedrock.ts
	删除:     app/components/chat-actions.tsx
	修改:     app/components/chat.tsx
	修改:     app/constant.ts
	删除:     app/icons/document.svg
2024-11-04 16:24:10 +08:00
glay
dca4a0e48f 修改: app/api/bedrock.ts
修改:     app/api/bedrock/models.ts
	修改:     app/api/bedrock/utils.ts
	修改:     app/client/api.ts
	修改:     app/client/platforms/bedrock.ts
	新文件:   app/components/chat-actions.tsx
	修改:     app/components/chat.module.scss
	修改:     app/components/chat.tsx
	修改:     app/constant.ts
	新文件:   app/icons/document.svg
	修改:     app/locales/cn.ts
	修改:     app/locales/en.ts
2024-10-31 14:23:38 +08:00
glay
722c28839f Merge branch 'ChatGPTNextWeb:main' into main 2024-10-31 11:51:41 +08:00
glay
ff356f0c8c 修改: app/api/[provider]/[...path]/route.ts
修改:     app/api/auth.ts
	新文件:   app/api/bedrock.ts
	新文件:   app/api/bedrock/models.ts
	新文件:   app/api/bedrock/utils.ts
	修改:     app/client/api.ts
	新文件:   app/client/platforms/bedrock.ts
	修改:     app/components/settings.tsx
	修改:     app/config/server.ts
	修改:     app/constant.ts
	修改:     app/locales/cn.ts
	修改:     app/locales/en.ts
	修改:     app/store/access.ts
	修改:     app/utils.ts
	修改:     package.json
2024-10-29 22:20:26 +08:00
66 changed files with 1674 additions and 2569 deletions

View File

@@ -66,4 +66,9 @@ ANTHROPIC_API_VERSION=
ANTHROPIC_URL= ANTHROPIC_URL=
### (optional) ### (optional)
WHITE_WEBDAV_ENDPOINTS= WHITE_WEBDAV_ENDPOINTS=
### bedrock (optional)
AWS_REGION=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=

View File

@@ -1,17 +1,16 @@
<div align="center"> <div align="center">
<a href='https://nextchat.dev/chat'> <a href='#企业版'>
<img src="https://github.com/user-attachments/assets/287c510f-f508-478e-ade3-54d30453dc18" width="1000" alt="icon"/> <img src="./docs/images/ent.svg" alt="icon"/>
</a> </a>
<h1 align="center">NextChat (ChatGPT Next Web)</h1> <h1 align="center">NextChat (ChatGPT Next Web)</h1>
English / [简体中文](./README_CN.md) English / [简体中文](./README_CN.md)
One-Click to get a well-designed cross-platform ChatGPT web UI, with Claude, GPT4 & Gemini Pro support. One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 & Gemini Pro support.
一键免费部署你的跨平台私人 ChatGPT 应用, 支持 Claude, GPT4 & Gemini Pro 模型。 一键免费部署你的跨平台私人 ChatGPT 应用, 支持 GPT3, GPT4 & Gemini Pro 模型。
[![Saas][Saas-image]][saas-url] [![Saas][Saas-image]][saas-url]
[![Web][Web-image]][web-url] [![Web][Web-image]][web-url]
@@ -32,7 +31,7 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with Claude, GPT
[MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple [MacOS-image]: https://img.shields.io/badge/-MacOS-black?logo=apple
[Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu [Linux-image]: https://img.shields.io/badge/-Linux-333?logo=ubuntu
[<img src="https://vercel.com/button" alt="Deploy on Vercel" height="30">](https://vercel.com/new/clone?repository-url=https://github.com/Dogtiti/ChatGPT-Next-Web-EarlyBird&env=OPENAI_API_KEY&env=CODE&project-name=nextchat-earlyBird&repository-name=NextChat-EarlyBird) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) [<img src="https://img.shields.io/badge/BT_Deploy-Install-20a53a" alt="BT Deply Install" height="30">](https://www.bt.cn/new/download.html) [<img src="https://vercel.com/button" alt="Deploy on Vercel" height="30">](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) [<img src="https://zeabur.com/button.svg" alt="Deploy on Zeabur" height="30">](https://zeabur.com/templates/ZBUEFA) [<img src="https://gitpod.io/button/open-in-gitpod.svg" alt="Open in Gitpod" height="30">](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) [<img src="https://img.shields.io/badge/BT_Deploy-Install-20a53a" alt="BT Deply Install" height="30">](https://www.bt.cn/new/download.html) [<img src="https://svgshare.com/i/1AVg.svg" alt="Deploy to Alibaba Cloud" height="30">](https://computenest.aliyun.com/market/service-f1c9b75e59814dc49d52)
[<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp) [<img src="https://github.com/user-attachments/assets/903482d4-3e87-4134-9af1-f2588fa90659" height="60" width="288" >](https://monica.im/?utm=nxcrp)
@@ -97,11 +96,10 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) - [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
- [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - [x] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
- [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - [x] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
- [x] Supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
- [ ] local knowledge base - [ ] local knowledge base
## What's New ## What's New
- 🚀 v2.15.8 Now supports Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
- 🚀 v2.15.4 The Application supports using Tauri fetch LLM API, MORE SECURITY! [#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379) - 🚀 v2.15.4 The Application supports using Tauri fetch LLM API, MORE SECURITY! [#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379)
- 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) - 🚀 v2.15.0 Now supports Plugins! Read this: [NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
- 🚀 v2.14.0 Now supports Artifacts & SD - 🚀 v2.14.0 Now supports Artifacts & SD
@@ -136,11 +134,10 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092) - [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
- [x] 插件机制,支持`联网搜索``计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - [x] 插件机制,支持`联网搜索``计算器`、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
- [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353) - [x] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) [#5353](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5353)
- [x] 支持 Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
- [ ] 本地知识库 - [ ] 本地知识库
## 最新动态 ## 最新动态
- 🚀 v2.15.8 现在支持Realtime Chat [#5672](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5672)
- 🚀 v2.15.4 客户端支持Tauri本地直接调用大模型API更安全[#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379) - 🚀 v2.15.4 客户端支持Tauri本地直接调用大模型API更安全[#5379](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/issues/5379)
- 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins) - 🚀 v2.15.0 现在支持插件功能了!了解更多:[NextChat-Awesome-Plugins](https://github.com/ChatGPTNextWeb/NextChat-Awesome-Plugins)
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。 - 🚀 v2.14.0 现在支持 Artifacts & SD 了。
@@ -356,13 +353,6 @@ For ByteDance: use `modelName@bytedance=deploymentName` to customize model name
Change default model Change default model
### `VISION_MODELS` (optional)
> Default: Empty
> Example: `gpt-4-vision,claude-3-opus,my-custom-model` means add vision capabilities to these models in addition to the default pattern matches (which detect models containing keywords like "vision", "claude-3", "gemini-1.5", etc).
Add additional models to have vision capabilities, beyond the default pattern matching. Multiple models should be separated by commas.
### `WHITE_WEBDAV_ENDPOINTS` (optional) ### `WHITE_WEBDAV_ENDPOINTS` (optional)
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format
@@ -477,7 +467,7 @@ If you want to add a new translation, read this [document](./docs/translation.md
## Donation ## Donation
[Buy Me a Coffee](https://1kafei.com/dogtiti) [Buy Me a Coffee](https://www.buymeacoffee.com/yidadaa)
## Special Thanks ## Special Thanks

View File

@@ -33,7 +33,7 @@
1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys); 1. 准备好你的 [OpenAI API Key](https://platform.openai.com/account/api-keys);
2. 点击右侧按钮开始部署: 2. 点击右侧按钮开始部署:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Dogtiti/ChatGPT-Next-Web-EarlyBird&env=OPENAI_API_KEY&env=CODE&project-name=nextchat-earlyBird&repository-name=NextChat-EarlyBird),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE [![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),直接使用 Github 账号登录即可,记得在环境变量页填入 API Key 和[页面访问密码](#配置页面访问密码) CODE
3. 部署完毕后,即可开始使用; 3. 部署完毕后,即可开始使用;
4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。 4. (可选)[绑定自定义域名](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercel 分配的域名 DNS 在某些区域被污染了,绑定自定义域名即可直连。
@@ -235,13 +235,6 @@ ChatGLM Api Url.
更改默认模型 更改默认模型
### `VISION_MODELS` (可选)
> 默认值:空
> 示例:`gpt-4-vision,claude-3-opus,my-custom-model` 表示为这些模型添加视觉能力,作为对默认模式匹配的补充(默认会检测包含"vision"、"claude-3"、"gemini-1.5"等关键词的模型)。
在默认模式匹配之外,添加更多具有视觉能力的模型。多个模型用逗号分隔。
### `DEFAULT_INPUT_TEMPLATE` (可选) ### `DEFAULT_INPUT_TEMPLATE` (可选)
自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项 自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项

View File

@@ -30,7 +30,7 @@
1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する; 1. [OpenAI API Key](https://platform.openai.com/account/api-keys)を準備する;
2. 右側のボタンをクリックしてデプロイを開始: 2. 右側のボタンをクリックしてデプロイを開始:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Dogtiti/ChatGPT-Next-Web-EarlyBird&env=OPENAI_API_KEY&env=CODE&project-name=nextchat-earlyBird&repository-name=NextChat-EarlyBird) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください; [![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) 、GitHubアカウントで直接ログインし、環境変数ページにAPI Keyと[ページアクセスパスワード](#設定ページアクセスパスワード) CODEを入力してください;
3. デプロイが完了したら、すぐに使用を開始できます; 3. デプロイが完了したら、すぐに使用を開始できます;
4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。 4. (オプション)[カスタムドメインをバインド](https://vercel.com/docs/concepts/projects/domains/add-a-domain)Vercelが割り当てたドメインDNSは一部の地域で汚染されているため、カスタムドメインをバインドすると直接接続できます。
@@ -217,13 +217,6 @@ ByteDance モードでは、`modelName@bytedance=deploymentName` 形式でモデ
デフォルトのモデルを変更します。 デフォルトのモデルを変更します。
### `VISION_MODELS` (オプション)
> デフォルト:空
> 例:`gpt-4-vision,claude-3-opus,my-custom-model` は、これらのモデルにビジョン機能を追加します。これはデフォルトのパターンマッチング("vision"、"claude-3"、"gemini-1.5"などのキーワードを含むモデルを検出)に加えて適用されます。
デフォルトのパターンマッチングに加えて、追加のモデルにビジョン機能を付与します。複数のモデルはカンマで区切ります。
### `DEFAULT_INPUT_TEMPLATE` (オプション) ### `DEFAULT_INPUT_TEMPLATE` (オプション)
『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。 『設定』の『ユーザー入力前処理』の初期設定に使用するテンプレートをカスタマイズします。

View File

@@ -1,6 +1,7 @@
import { ApiPath } from "@/app/constant"; import { ApiPath } from "@/app/constant";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { handle as openaiHandler } from "../../openai"; import { handle as openaiHandler } from "../../openai";
import { handle as bedrockHandler } from "../../bedrock";
import { handle as azureHandler } from "../../azure"; import { handle as azureHandler } from "../../azure";
import { handle as googleHandler } from "../../google"; import { handle as googleHandler } from "../../google";
import { handle as anthropicHandler } from "../../anthropic"; import { handle as anthropicHandler } from "../../anthropic";
@@ -21,12 +22,15 @@ async function handle(
const apiPath = `/api/${params.provider}`; const apiPath = `/api/${params.provider}`;
console.log(`[${params.provider} Route] params `, params); console.log(`[${params.provider} Route] params `, params);
switch (apiPath) { switch (apiPath) {
case ApiPath.Bedrock:
return bedrockHandler(req, { params });
case ApiPath.Azure: case ApiPath.Azure:
return azureHandler(req, { params }); return azureHandler(req, { params });
case ApiPath.Google: case ApiPath.Google:
return googleHandler(req, { params }); return googleHandler(req, { params });
case ApiPath.Anthropic: case ApiPath.Anthropic:
return anthropicHandler(req, { params }); return anthropicHandler(req, { params });
case ApiPath.Baidu: case ApiPath.Baidu:
return baiduHandler(req, { params }); return baiduHandler(req, { params });
case ApiPath.ByteDance: case ApiPath.ByteDance:

View File

@@ -52,6 +52,28 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
msg: "you are not allowed to access with your own api key", msg: "you are not allowed to access with your own api key",
}; };
} }
// Special handling for Bedrock
if (modelProvider === ModelProvider.Bedrock) {
const region = serverConfig.awsRegion;
const accessKeyId = serverConfig.awsAccessKey;
const secretAccessKey = serverConfig.awsSecretKey;
console.log("[Auth] Bedrock credentials:", {
region,
accessKeyId: accessKeyId ? "***" : undefined,
secretKey: secretAccessKey ? "***" : undefined,
});
// Check if AWS credentials are provided
if (!region || !accessKeyId || !secretAccessKey) {
return {
error: true,
msg: "Missing AWS credentials. Please configure Region, Access Key ID, and Secret Access Key in settings.",
};
}
return { error: false };
}
// if user does not provide an api key, inject system api key // if user does not provide an api key, inject system api key
if (!apiKey) { if (!apiKey) {

265
app/api/bedrock.ts Normal file
View File

@@ -0,0 +1,265 @@
import { getServerSideConfig } from "../config/server";
import { prettyObject } from "../utils/format";
import { NextRequest, NextResponse } from "next/server";
import {
BedrockRuntimeClient,
ConverseStreamCommand,
ConverseStreamCommandInput,
Message,
ContentBlock,
ConverseStreamOutput,
} from "@aws-sdk/client-bedrock-runtime";
const ALLOWED_PATH = new Set(["converse"]);
function decrypt(str: string): string {
try {
return Buffer.from(str, "base64").toString().split("").reverse().join("");
} catch {
return "";
}
}
export interface ConverseRequest {
modelId: string;
messages: {
role: "user" | "assistant" | "system";
content: string | any[];
}[];
inferenceConfig?: {
maxTokens?: number;
temperature?: number;
topP?: number;
stopSequences?: string[];
};
tools?: {
name: string;
description?: string;
input_schema: any;
}[];
}
function supportsToolUse(modelId: string): boolean {
// llama和mistral模型不支持工具调用
return modelId.toLowerCase().includes("claude-3");
}
function formatRequestBody(
request: ConverseRequest,
): ConverseStreamCommandInput {
const messages: Message[] = request.messages.map((msg) => ({
role: msg.role === "system" ? "user" : msg.role,
content: Array.isArray(msg.content)
? msg.content.map((item) => {
if (item.type === "tool_use") {
return {
toolUse: {
toolUseId: item.id,
name: item.name,
input: item.input || "{}",
},
} as ContentBlock;
}
if (item.type === "tool_result") {
return {
toolResult: {
toolUseId: item.tool_use_id,
content: [{ text: item.content || ";" }],
status: "success",
},
} as ContentBlock;
}
if (item.type === "text") {
return { text: item.text || ";" } as ContentBlock;
}
if (item.type === "image") {
return {
image: {
format: item.source.media_type.split("/")[1] as
| "png"
| "jpeg"
| "gif"
| "webp",
source: {
bytes: Uint8Array.from(
Buffer.from(item.source.data, "base64"),
),
},
},
} as ContentBlock;
}
return { text: ";" } as ContentBlock;
})
: [{ text: msg.content || ";" } as ContentBlock],
}));
const input: ConverseStreamCommandInput = {
modelId: request.modelId,
messages,
...(request.inferenceConfig && {
inferenceConfig: request.inferenceConfig,
}),
};
// 只有在支持工具调用的模型上才添加toolConfig
if (request.tools?.length && supportsToolUse(request.modelId)) {
input.toolConfig = {
tools: request.tools.map((tool) => ({
toolSpec: {
name: tool.name,
description: tool.description,
inputSchema: {
json: tool.input_schema,
},
},
})),
toolChoice: { auto: {} },
};
}
return input;
}
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const subpath = params.path.join("/");
if (!ALLOWED_PATH.has(subpath)) {
return NextResponse.json(
{ error: true, msg: "Path not allowed: " + subpath },
{ status: 403 },
);
}
const serverConfig = getServerSideConfig();
let region = serverConfig.awsRegion;
let accessKeyId = serverConfig.awsAccessKey;
let secretAccessKey = serverConfig.awsSecretKey;
let sessionToken = undefined;
if (!region || !accessKeyId || !secretAccessKey) {
region = decrypt(req.headers.get("X-Region") ?? "");
accessKeyId = decrypt(req.headers.get("X-Access-Key") ?? "");
secretAccessKey = decrypt(req.headers.get("X-Secret-Key") ?? "");
sessionToken = req.headers.get("X-Session-Token")
? decrypt(req.headers.get("X-Session-Token") ?? "")
: undefined;
}
if (!region || !accessKeyId || !secretAccessKey) {
return NextResponse.json(
{ error: true, msg: "Missing AWS credentials" },
{ status: 401 },
);
}
try {
const client = new BedrockRuntimeClient({
region,
credentials: { accessKeyId, secretAccessKey, sessionToken },
});
const body = (await req.json()) as ConverseRequest;
const command = new ConverseStreamCommand(formatRequestBody(body));
const response = await client.send(command);
if (!response.stream) {
throw new Error("No stream in response");
}
const stream = new ReadableStream({
async start(controller) {
try {
const responseStream =
response.stream as AsyncIterable<ConverseStreamOutput>;
for await (const event of responseStream) {
if (
"contentBlockStart" in event &&
event.contentBlockStart?.start?.toolUse &&
event.contentBlockStart.contentBlockIndex !== undefined
) {
controller.enqueue(
`data: ${JSON.stringify({
type: "content_block",
content_block: {
type: "tool_use",
id: event.contentBlockStart.start.toolUse.toolUseId,
name: event.contentBlockStart.start.toolUse.name,
},
index: event.contentBlockStart.contentBlockIndex,
})}\n\n`,
);
} else if (
"contentBlockDelta" in event &&
event.contentBlockDelta?.delta &&
event.contentBlockDelta.contentBlockIndex !== undefined
) {
const delta = event.contentBlockDelta.delta;
if ("text" in delta && delta.text) {
controller.enqueue(
`data: ${JSON.stringify({
type: "content_block_delta",
delta: {
type: "text_delta",
text: delta.text,
},
index: event.contentBlockDelta.contentBlockIndex,
})}\n\n`,
);
} else if ("toolUse" in delta && delta.toolUse?.input) {
controller.enqueue(
`data: ${JSON.stringify({
type: "content_block_delta",
delta: {
type: "input_json_delta",
partial_json: delta.toolUse.input,
},
index: event.contentBlockDelta.contentBlockIndex,
})}\n\n`,
);
}
} else if (
"contentBlockStop" in event &&
event.contentBlockStop?.contentBlockIndex !== undefined
) {
controller.enqueue(
`data: ${JSON.stringify({
type: "content_block_stop",
index: event.contentBlockStop.contentBlockIndex,
})}\n\n`,
);
}
}
controller.close();
} catch (error) {
console.error("[Bedrock] Stream error:", error);
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (e) {
console.error("[Bedrock] Error:", e);
return NextResponse.json(
{
error: true,
message: e instanceof Error ? e.message : "Unknown error",
details: prettyObject(e),
},
{ status: 500 },
);
}
}

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "../config/server"; import { getServerSideConfig } from "../config/server";
import { OPENAI_BASE_URL, ServiceProvider } from "../constant"; import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
import { isModelAvailableInServer } from "../utils/model";
import { cloudflareAIGatewayUrl } from "../utils/cloudflare"; import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
import { getModelProvider, isModelAvailableInServer } from "../utils/model";
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
@@ -71,7 +71,7 @@ export async function requestOpenai(req: NextRequest) {
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName)) .filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
.forEach((m) => { .forEach((m) => {
const [fullName, displayName] = m.split("="); const [fullName, displayName] = m.split("=");
const [_, providerName] = getModelProvider(fullName); const [_, providerName] = fullName.split("@");
if (providerName === "azure" && !displayName) { if (providerName === "azure" && !displayName) {
const [_, deployId] = (serverConfig?.azureUrl ?? "").split( const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
"deployments/", "deployments/",

View File

@@ -14,7 +14,7 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
if (config.disableGPT4) { if (config.disableGPT4) {
remoteModelRes.data = remoteModelRes.data.filter( remoteModelRes.data = remoteModelRes.data.filter(
(m) => (m) =>
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o") || m.id.startsWith("o1")) || !(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
m.id.startsWith("gpt-4o-mini"), m.id.startsWith("gpt-4o-mini"),
); );
} }

View File

@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "@/app/config/server";
export async function handle( export async function handle(
req: NextRequest, req: NextRequest,
@@ -10,7 +9,6 @@ export async function handle(
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 }); return NextResponse.json({ body: "OK" }, { status: 200 });
} }
const serverConfig = getServerSideConfig();
// remove path params from searchParams // remove path params from searchParams
req.nextUrl.searchParams.delete("path"); req.nextUrl.searchParams.delete("path");
@@ -33,18 +31,6 @@ export async function handle(
return true; return true;
}), }),
); );
// if dalle3 use openai api key
const baseUrl = req.headers.get("x-base-url");
if (baseUrl?.includes("api.openai.com")) {
if (!serverConfig.apiKey) {
return NextResponse.json(
{ error: "OpenAI API key not configured" },
{ status: 500 },
);
}
headers.set("Authorization", `Bearer ${serverConfig.apiKey}`);
}
const controller = new AbortController(); const controller = new AbortController();
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
headers, headers,

View File

@@ -12,6 +12,7 @@ import {
useChatStore, useChatStore,
} from "../store"; } from "../store";
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai"; import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
import { BedrockApi } from "./platforms/bedrock";
import { GeminiProApi } from "./platforms/google"; import { GeminiProApi } from "./platforms/google";
import { ClaudeApi } from "./platforms/anthropic"; import { ClaudeApi } from "./platforms/anthropic";
import { ErnieApi } from "./platforms/baidu"; import { ErnieApi } from "./platforms/baidu";
@@ -31,11 +32,19 @@ export const TTSModels = ["tts-1", "tts-1-hd"] as const;
export type ChatModel = ModelType; export type ChatModel = ModelType;
export interface MultimodalContent { export interface MultimodalContent {
type: "text" | "image_url"; type: "text" | "image_url" | "document";
text?: string; text?: string;
image_url?: { image_url?: {
url: string; url: string;
}; };
document?: {
format: string;
name: string;
source: {
bytes: string;
media_type?: string;
};
};
} }
export interface RequestMessage { export interface RequestMessage {
@@ -130,6 +139,9 @@ export class ClientApi {
constructor(provider: ModelProvider = ModelProvider.GPT) { constructor(provider: ModelProvider = ModelProvider.GPT) {
switch (provider) { switch (provider) {
case ModelProvider.Bedrock:
this.llm = new BedrockApi();
break;
case ModelProvider.GeminiPro: case ModelProvider.GeminiPro:
this.llm = new GeminiProApi(); this.llm = new GeminiProApi();
break; break;
@@ -239,6 +251,7 @@ export function getHeaders(ignoreHeaders: boolean = false) {
function getConfig() { function getConfig() {
const modelConfig = chatStore.currentSession().mask.modelConfig; const modelConfig = chatStore.currentSession().mask.modelConfig;
const isBedrock = modelConfig.providerName === ServiceProvider.Bedrock;
const isGoogle = modelConfig.providerName === ServiceProvider.Google; const isGoogle = modelConfig.providerName === ServiceProvider.Google;
const isAzure = modelConfig.providerName === ServiceProvider.Azure; const isAzure = modelConfig.providerName === ServiceProvider.Azure;
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic; const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
@@ -252,6 +265,8 @@ export function getHeaders(ignoreHeaders: boolean = false) {
const isEnabledAccessControl = accessStore.enabledAccessControl(); const isEnabledAccessControl = accessStore.enabledAccessControl();
const apiKey = isGoogle const apiKey = isGoogle
? accessStore.googleApiKey ? accessStore.googleApiKey
: isBedrock
? accessStore.awsAccessKey // Use AWS access key for Bedrock
: isAzure : isAzure
? accessStore.azureApiKey ? accessStore.azureApiKey
: isAnthropic : isAnthropic
@@ -272,6 +287,7 @@ export function getHeaders(ignoreHeaders: boolean = false) {
: "" : ""
: accessStore.openaiApiKey; : accessStore.openaiApiKey;
return { return {
isBedrock,
isGoogle, isGoogle,
isAzure, isAzure,
isAnthropic, isAnthropic,
@@ -294,10 +310,13 @@ export function getHeaders(ignoreHeaders: boolean = false) {
? "x-api-key" ? "x-api-key"
: isGoogle : isGoogle
? "x-goog-api-key" ? "x-goog-api-key"
: isBedrock
? "x-api-key"
: "Authorization"; : "Authorization";
} }
const { const {
isBedrock,
isGoogle, isGoogle,
isAzure, isAzure,
isAnthropic, isAnthropic,
@@ -310,17 +329,30 @@ export function getHeaders(ignoreHeaders: boolean = false) {
const authHeader = getAuthHeader(); const authHeader = getAuthHeader();
const bearerToken = getBearerToken( if (isBedrock) {
apiKey, // 简单加密 AWS credentials
isAzure || isAnthropic || isGoogle, const encrypt = (str: string) =>
); Buffer.from(str.split("").reverse().join("")).toString("base64");
if (bearerToken) { headers["X-Region"] = encrypt(accessStore.awsRegion);
headers[authHeader] = bearerToken; headers["X-Access-Key"] = encrypt(accessStore.awsAccessKey);
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) { headers["X-Secret-Key"] = encrypt(accessStore.awsSecretKey);
headers["Authorization"] = getBearerToken( if (accessStore.awsSessionToken) {
ACCESS_CODE_PREFIX + accessStore.accessCode, headers["X-Session-Token"] = encrypt(accessStore.awsSessionToken);
}
} else {
const bearerToken = getBearerToken(
apiKey,
isAzure || isAnthropic || isGoogle,
); );
if (bearerToken) {
headers[authHeader] = bearerToken;
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
headers["Authorization"] = getBearerToken(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
}
} }
return headers; return headers;
@@ -328,6 +360,8 @@ export function getHeaders(ignoreHeaders: boolean = false) {
export function getClientApi(provider: ServiceProvider): ClientApi { export function getClientApi(provider: ServiceProvider): ClientApi {
switch (provider) { switch (provider) {
case ServiceProvider.Bedrock:
return new ClientApi(ModelProvider.Bedrock);
case ServiceProvider.Google: case ServiceProvider.Google:
return new ClientApi(ModelProvider.GeminiPro); return new ClientApi(ModelProvider.GeminiPro);
case ServiceProvider.Anthropic: case ServiceProvider.Anthropic:

View File

@@ -0,0 +1,292 @@
import { ApiPath } from "../../constant";
import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api";
import {
useAppConfig,
usePluginStore,
useChatStore,
ChatMessageTool,
} from "../../store";
import { getMessageTextContent, isVisionModel } from "../../utils";
import { fetch } from "../../utils/stream";
import { preProcessImageContent, stream } from "../../utils/chat";
import { RequestPayload } from "./openai";
export type MultiBlockContent = {
type: "image" | "text";
source?: {
type: string;
media_type: string;
data: string;
};
text?: string;
};
export type AnthropicMessage = {
role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
content: string | MultiBlockContent[];
};
const ClaudeMapper = {
assistant: "assistant",
user: "user",
system: "user",
} as const;
export class BedrockApi implements LLMApi {
speech(options: SpeechOptions): Promise<ArrayBuffer> {
throw new Error("Speech not implemented for Bedrock.");
}
extractMessage(res: any) {
console.log("[Response] claude response: ", res);
return res?.content?.[0]?.text;
}
async chat(options: ChatOptions): Promise<void> {
const visionModel = isVisionModel(options.config.model);
const shouldStream = !!options.config.stream;
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
// try get base64image from local cache image_url
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = await preProcessImageContent(v.content);
messages.push({ role: v.role, content });
}
const keys = ["system", "user"];
// roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages
for (let i = 0; i < messages.length - 1; i++) {
const message = messages[i];
const nextMessage = messages[i + 1];
if (keys.includes(message.role) && keys.includes(nextMessage.role)) {
messages[i] = [
message,
{
role: "assistant",
content: ";",
},
] as any;
}
}
const prompt = messages
.flat()
.filter((v) => {
if (!v.content) return false;
if (typeof v.content === "string" && !v.content.trim()) return false;
return true;
})
.map((v) => {
const { role, content } = v;
const insideRole = ClaudeMapper[role] ?? "user";
if (!visionModel || typeof content === "string") {
return {
role: insideRole,
content: getMessageTextContent(v),
};
}
return {
role: insideRole,
content: content
.filter((v) => v.image_url || v.text)
.map(({ type, text, image_url }) => {
if (type === "text") {
return {
type,
text: text!,
};
}
const { url = "" } = image_url || {};
const colonIndex = url.indexOf(":");
const semicolonIndex = url.indexOf(";");
const comma = url.indexOf(",");
const mimeType = url.slice(colonIndex + 1, semicolonIndex);
const encodeType = url.slice(semicolonIndex + 1, comma);
const data = url.slice(comma + 1);
return {
type: "image" as const,
source: {
type: encodeType,
media_type: mimeType,
data,
},
};
}),
};
});
if (prompt[0]?.role === "assistant") {
prompt.unshift({
role: "user",
content: ";",
});
}
const requestBody = {
modelId: options.config.model,
messages: prompt,
inferenceConfig: {
maxTokens: modelConfig.max_tokens,
temperature: modelConfig.temperature,
topP: modelConfig.top_p,
stopSequences: [],
},
};
const conversePath = `${ApiPath.Bedrock}/converse`;
const controller = new AbortController();
options.onController?.(controller);
if (shouldStream) {
let currentToolUse: ChatMessageTool | null = null;
let index = -1;
const [tools, funcs] = usePluginStore
.getState()
.getAsTools(
useChatStore.getState().currentSession().mask?.plugin || [],
);
return stream(
conversePath,
requestBody,
getHeaders(),
// @ts-ignore
tools.map((tool) => ({
name: tool?.function?.name,
description: tool?.function?.description,
input_schema: tool?.function?.parameters,
})),
funcs,
controller,
// parseSSE
(text: string, runTools: ChatMessageTool[]) => {
// console.log("parseSSE", text, runTools);
let chunkJson:
| undefined
| {
type: "content_block_delta" | "content_block_stop";
content_block?: {
type: "tool_use";
id: string;
name: string;
};
delta?: {
type: "text_delta" | "input_json_delta";
text?: string;
partial_json?: string;
};
index: number;
};
chunkJson = JSON.parse(text);
if (chunkJson?.content_block?.type == "tool_use") {
index += 1;
const id = chunkJson?.content_block.id;
const name = chunkJson?.content_block.name;
runTools.push({
id,
type: "function",
function: {
name,
arguments: "",
},
});
}
if (
chunkJson?.delta?.type == "input_json_delta" &&
chunkJson?.delta?.partial_json
) {
// @ts-ignore
runTools[index]["function"]["arguments"] +=
chunkJson?.delta?.partial_json;
}
return chunkJson?.delta?.text;
},
// processToolMessage, include tool_calls message and tool call results
(
requestPayload: RequestPayload,
toolCallMessage: any,
toolCallResult: any[],
) => {
// reset index value
index = -1;
// @ts-ignore
requestPayload?.messages?.splice(
// @ts-ignore
requestPayload?.messages?.length,
0,
{
role: "assistant",
content: toolCallMessage.tool_calls.map(
(tool: ChatMessageTool) => ({
type: "tool_use",
id: tool.id,
name: tool?.function?.name,
input: tool?.function?.arguments
? JSON.parse(tool?.function?.arguments)
: {},
}),
),
},
// @ts-ignore
...toolCallResult.map((result) => ({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: result.tool_call_id,
content: result.content,
},
],
})),
);
},
options,
);
} else {
const payload = {
method: "POST",
body: JSON.stringify(requestBody),
signal: controller.signal,
headers: {
...getHeaders(), // get common headers
},
};
try {
controller.signal.onabort = () =>
options.onFinish("", new Response(null, { status: 400 }));
const res = await fetch(conversePath, payload);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message, res);
} catch (e) {
console.error("failed to chat", e);
options.onError?.(e as Error);
}
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models() {
return [];
}
}

View File

@@ -25,103 +25,12 @@ import { getMessageTextContent } from "@/app/utils";
import { RequestPayload } from "./openai"; import { RequestPayload } from "./openai";
import { fetch } from "@/app/utils/stream"; import { fetch } from "@/app/utils/stream";
interface BasePayload {
model: string;
}
interface ChatPayload extends BasePayload {
messages: ChatOptions["messages"];
stream?: boolean;
temperature?: number;
presence_penalty?: number;
frequency_penalty?: number;
top_p?: number;
}
interface ImageGenerationPayload extends BasePayload {
prompt: string;
size?: string;
user_id?: string;
}
interface VideoGenerationPayload extends BasePayload {
prompt: string;
duration?: number;
resolution?: string;
user_id?: string;
}
type ModelType = "chat" | "image" | "video";
export class ChatGLMApi implements LLMApi { export class ChatGLMApi implements LLMApi {
private disableListModels = true; private disableListModels = true;
private getModelType(model: string): ModelType {
if (model.startsWith("cogview-")) return "image";
if (model.startsWith("cogvideo-")) return "video";
return "chat";
}
private getModelPath(type: ModelType): string {
switch (type) {
case "image":
return ChatGLM.ImagePath;
case "video":
return ChatGLM.VideoPath;
default:
return ChatGLM.ChatPath;
}
}
private createPayload(
messages: ChatOptions["messages"],
modelConfig: any,
options: ChatOptions,
): BasePayload {
const modelType = this.getModelType(modelConfig.model);
const lastMessage = messages[messages.length - 1];
const prompt =
typeof lastMessage.content === "string"
? lastMessage.content
: lastMessage.content.map((c) => c.text).join("\n");
switch (modelType) {
case "image":
return {
model: modelConfig.model,
prompt,
size: options.config.size,
} as ImageGenerationPayload;
default:
return {
messages,
stream: options.config.stream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
} as ChatPayload;
}
}
private parseResponse(modelType: ModelType, json: any): string {
switch (modelType) {
case "image": {
const imageUrl = json.data?.[0]?.url;
return imageUrl ? `![Generated Image](${imageUrl})` : "";
}
case "video": {
const videoUrl = json.data?.[0]?.url;
return videoUrl ? `<video controls src="${videoUrl}"></video>` : "";
}
default:
return this.extractMessage(json);
}
}
path(path: string): string { path(path: string): string {
const accessStore = useAccessStore.getState(); const accessStore = useAccessStore.getState();
let baseUrl = ""; let baseUrl = "";
if (accessStore.useCustomConfig) { if (accessStore.useCustomConfig) {
@@ -142,6 +51,7 @@ export class ChatGLMApi implements LLMApi {
} }
console.log("[Proxy Endpoint] ", baseUrl, path); console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/"); return [baseUrl, path].join("/");
} }
@@ -169,16 +79,24 @@ export class ChatGLMApi implements LLMApi {
}, },
}; };
const modelType = this.getModelType(modelConfig.model); const requestPayload: RequestPayload = {
const requestPayload = this.createPayload(messages, modelConfig, options); messages,
const path = this.path(this.getModelPath(modelType)); stream: options.config.stream,
model: modelConfig.model,
temperature: modelConfig.temperature,
presence_penalty: modelConfig.presence_penalty,
frequency_penalty: modelConfig.frequency_penalty,
top_p: modelConfig.top_p,
};
console.log(`[Request] glm ${modelType} payload: `, requestPayload); console.log("[Request] glm payload: ", requestPayload);
const shouldStream = !!options.config.stream;
const controller = new AbortController(); const controller = new AbortController();
options.onController?.(controller); options.onController?.(controller);
try { try {
const chatPath = this.path(ChatGLM.ChatPath);
const chatPayload = { const chatPayload = {
method: "POST", method: "POST",
body: JSON.stringify(requestPayload), body: JSON.stringify(requestPayload),
@@ -186,23 +104,12 @@ export class ChatGLMApi implements LLMApi {
headers: getHeaders(), headers: getHeaders(),
}; };
// make a fetch request
const requestTimeoutId = setTimeout( const requestTimeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS,
); );
if (modelType === "image" || modelType === "video") {
const res = await fetch(path, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
console.log(`[Response] glm ${modelType}:`, resJson);
const message = this.parseResponse(modelType, resJson);
options.onFinish(message, res);
return;
}
const shouldStream = !!options.config.stream;
if (shouldStream) { if (shouldStream) {
const [tools, funcs] = usePluginStore const [tools, funcs] = usePluginStore
.getState() .getState()
@@ -210,7 +117,7 @@ export class ChatGLMApi implements LLMApi {
useChatStore.getState().currentSession().mask?.plugin || [], useChatStore.getState().currentSession().mask?.plugin || [],
); );
return stream( return stream(
path, chatPath,
requestPayload, requestPayload,
getHeaders(), getHeaders(),
tools as any, tools as any,
@@ -218,6 +125,7 @@ export class ChatGLMApi implements LLMApi {
controller, controller,
// parseSSE // parseSSE
(text: string, runTools: ChatMessageTool[]) => { (text: string, runTools: ChatMessageTool[]) => {
// console.log("parseSSE", text, runTools);
const json = JSON.parse(text); const json = JSON.parse(text);
const choices = json.choices as Array<{ const choices = json.choices as Array<{
delta: { delta: {
@@ -246,7 +154,7 @@ export class ChatGLMApi implements LLMApi {
} }
return choices[0]?.delta?.content; return choices[0]?.delta?.content;
}, },
// processToolMessage // processToolMessage, include tool_calls message and tool call results
( (
requestPayload: RequestPayload, requestPayload: RequestPayload,
toolCallMessage: any, toolCallMessage: any,
@@ -264,7 +172,7 @@ export class ChatGLMApi implements LLMApi {
options, options,
); );
} else { } else {
const res = await fetch(path, chatPayload); const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId); clearTimeout(requestTimeoutId);
const resJson = await res.json(); const resJson = await res.json();
@@ -276,7 +184,6 @@ export class ChatGLMApi implements LLMApi {
options.onError?.(e as Error); options.onError?.(e as Error);
} }
} }
async usage() { async usage() {
return { return {
used: 0, used: 0,

View File

@@ -29,7 +29,7 @@ import { RequestPayload } from "./openai";
import { fetch } from "@/app/utils/stream"; import { fetch } from "@/app/utils/stream";
export class GeminiProApi implements LLMApi { export class GeminiProApi implements LLMApi {
path(path: string, shouldStream = false): string { path(path: string): string {
const accessStore = useAccessStore.getState(); const accessStore = useAccessStore.getState();
let baseUrl = ""; let baseUrl = "";
@@ -51,10 +51,8 @@ export class GeminiProApi implements LLMApi {
console.log("[Proxy Endpoint] ", baseUrl, path); console.log("[Proxy Endpoint] ", baseUrl, path);
let chatPath = [baseUrl, path].join("/"); let chatPath = [baseUrl, path].join("/");
if (shouldStream) {
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
}
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
return chatPath; return chatPath;
} }
extractMessage(res: any) { extractMessage(res: any) {
@@ -62,7 +60,6 @@ export class GeminiProApi implements LLMApi {
return ( return (
res?.candidates?.at(0)?.content?.parts.at(0)?.text || res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
res?.at(0)?.candidates?.at(0)?.content?.parts.at(0)?.text ||
res?.error?.message || res?.error?.message ||
"" ""
); );
@@ -169,10 +166,7 @@ export class GeminiProApi implements LLMApi {
options.onController?.(controller); options.onController?.(controller);
try { try {
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb // https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
const chatPath = this.path( const chatPath = this.path(Google.ChatPath(modelConfig.model));
Google.ChatPath(modelConfig.model),
shouldStream,
);
const chatPayload = { const chatPayload = {
method: "POST", method: "POST",

View File

@@ -24,7 +24,7 @@ import {
stream, stream,
} from "@/app/utils/chat"; } from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
import { ModelSize, DalleQuality, DalleStyle } from "@/app/typing"; import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
import { import {
ChatOptions, ChatOptions,
@@ -65,7 +65,6 @@ export interface RequestPayload {
frequency_penalty: number; frequency_penalty: number;
top_p: number; top_p: number;
max_tokens?: number; max_tokens?: number;
max_completion_tokens?: number;
} }
export interface DalleRequestPayload { export interface DalleRequestPayload {
@@ -73,7 +72,7 @@ export interface DalleRequestPayload {
prompt: string; prompt: string;
response_format: "url" | "b64_json"; response_format: "url" | "b64_json";
n: number; n: number;
size: ModelSize; size: DalleSize;
quality: DalleQuality; quality: DalleQuality;
style: DalleStyle; style: DalleStyle;
} }
@@ -224,7 +223,7 @@ export class ChatGPTApi implements LLMApi {
// O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet. // O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
requestPayload = { requestPayload = {
messages, messages,
stream: options.config.stream, stream: !isO1 ? options.config.stream : false,
model: modelConfig.model, model: modelConfig.model,
temperature: !isO1 ? modelConfig.temperature : 1, temperature: !isO1 ? modelConfig.temperature : 1,
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0, presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
@@ -234,11 +233,6 @@ 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. // Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
}; };
// O1 使用 max_completion_tokens 控制token数 (https://platform.openai.com/docs/guides/reasoning#controlling-costs)
if (isO1) {
requestPayload["max_completion_tokens"] = modelConfig.max_tokens;
}
// add max_tokens to vision model // add max_tokens to vision model
if (visionModel) { if (visionModel) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
@@ -247,7 +241,7 @@ export class ChatGPTApi implements LLMApi {
console.log("[Request] openai payload: ", requestPayload); console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !isDalle3 && !!options.config.stream; const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
const controller = new AbortController(); const controller = new AbortController();
options.onController?.(controller); options.onController?.(controller);

View File

@@ -18,8 +18,6 @@ import {
trackSettingsPageGuideToCPaymentClick, trackSettingsPageGuideToCPaymentClick,
trackAuthorizationPageButtonToCPaymentClick, trackAuthorizationPageButtonToCPaymentClick,
} from "../utils/auth-settings-events"; } from "../utils/auth-settings-events";
import clsx from "clsx";
const storage = safeLocalStorage(); const storage = safeLocalStorage();
export function AuthPage() { export function AuthPage() {
@@ -56,7 +54,7 @@ export function AuthPage() {
onClick={() => navigate(Path.Home)} onClick={() => navigate(Path.Home)}
></IconButton> ></IconButton>
</div> </div>
<div className={clsx("no-dark", styles["auth-logo"])}> <div className={`no-dark ${styles["auth-logo"]}`}>
<BotIcon /> <BotIcon />
</div> </div>
@@ -165,7 +163,7 @@ function TopBanner() {
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
<div className={clsx(styles["top-banner-inner"], "no-dark")}> <div className={`${styles["top-banner-inner"]} no-dark`}>
<Logo className={styles["top-banner-logo"]}></Logo> <Logo className={styles["top-banner-logo"]}></Logo>
<span> <span>
{Locale.Auth.TopTips} {Locale.Auth.TopTips}

View File

@@ -2,7 +2,6 @@ import * as React from "react";
import styles from "./button.module.scss"; import styles from "./button.module.scss";
import { CSSProperties } from "react"; import { CSSProperties } from "react";
import clsx from "clsx";
export type ButtonType = "primary" | "danger" | null; export type ButtonType = "primary" | "danger" | null;
@@ -23,16 +22,12 @@ export function IconButton(props: {
}) { }) {
return ( return (
<button <button
className={clsx( className={
"clickable", styles["icon-button"] +
styles["icon-button"], ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
{ props.className ?? ""
[styles.border]: props.bordered, } clickable ${styles[props.type ?? ""]}`
[styles.shadow]: props.shadow, }
},
styles[props.type ?? ""],
props.className,
)}
onClick={props.onClick} onClick={props.onClick}
title={props.title} title={props.title}
disabled={props.disabled} disabled={props.disabled}
@@ -45,9 +40,10 @@ export function IconButton(props: {
{props.icon && ( {props.icon && (
<div <div
aria-label={props.text || props.title} aria-label={props.text || props.title}
className={clsx(styles["icon-button-icon"], { className={
"no-dark": props.type === "primary", styles["icon-button-icon"] +
})} ` ${props.type === "primary" && "no-dark"}`
}
> >
{props.icon} {props.icon}
</div> </div>

View File

@@ -18,7 +18,6 @@ import { Mask } from "../store/mask";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { showConfirm } from "./ui-lib"; import { showConfirm } from "./ui-lib";
import { useMobileScreen } from "../utils"; import { useMobileScreen } from "../utils";
import clsx from "clsx";
export function ChatItem(props: { export function ChatItem(props: {
onClick?: () => void; onClick?: () => void;
@@ -46,11 +45,11 @@ export function ChatItem(props: {
<Draggable draggableId={`${props.id}`} index={props.index}> <Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => ( {(provided) => (
<div <div
className={clsx(styles["chat-item"], { className={`${styles["chat-item"]} ${
[styles["chat-item-selected"]]: props.selected &&
props.selected && (currentPath === Path.Chat || currentPath === Path.Home) &&
(currentPath === Path.Chat || currentPath === Path.Home), styles["chat-item-selected"]
})} }`}
onClick={props.onClick} onClick={props.onClick}
ref={(ele) => { ref={(ele) => {
draggableRef.current = ele; draggableRef.current = ele;
@@ -64,7 +63,7 @@ export function ChatItem(props: {
> >
{props.narrow ? ( {props.narrow ? (
<div className={styles["chat-item-narrow"]}> <div className={styles["chat-item-narrow"]}>
<div className={clsx(styles["chat-item-avatar"], "no-dark")}> <div className={styles["chat-item-avatar"] + " no-dark"}>
<MaskAvatar <MaskAvatar
avatar={props.mask.avatar} avatar={props.mask.avatar}
model={props.mask.modelConfig.model} model={props.mask.modelConfig.model}

View File

@@ -45,14 +45,6 @@
.chat-input-actions { .chat-input-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between;
gap: 5px;
&-end {
display: flex;
margin-left: auto;
gap: 5px;
}
.chat-input-action { .chat-input-action {
display: inline-flex; display: inline-flex;
@@ -70,6 +62,10 @@
width: var(--icon-width); width: var(--icon-width);
overflow: hidden; overflow: hidden;
&:not(:last-child) {
margin-right: 5px;
}
.text { .text {
white-space: nowrap; white-space: nowrap;
padding-left: 5px; padding-left: 5px;
@@ -79,6 +75,17 @@
pointer-events: none; pointer-events: none;
} }
.icon {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
}
}
&:hover { &:hover {
--delay: 0.5s; --delay: 0.5s;
width: var(--full-width); width: var(--full-width);
@@ -235,12 +242,10 @@
animation: slide-in ease 0.3s; animation: slide-in ease 0.3s;
$linear: linear-gradient( $linear: linear-gradient(to right,
to right, rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0), rgba(0, 0, 0, 1),
rgba(0, 0, 0, 1), rgba(0, 0, 0, 0));
rgba(0, 0, 0, 0)
);
mask-image: $linear; mask-image: $linear;
@mixin show { @mixin show {
@@ -379,7 +384,7 @@
} }
} }
.chat-message-user > .chat-message-container { .chat-message-user>.chat-message-container {
align-items: flex-end; align-items: flex-end;
} }
@@ -399,8 +404,8 @@
button { button {
padding: 7px; padding: 7px;
}
} }
}
/* Specific styles for iOS devices */ /* Specific styles for iOS devices */
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) { @media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
@@ -449,25 +454,6 @@
transition: all ease 0.3s; transition: all ease 0.3s;
} }
.chat-message-audio {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.05);
border: var(--border-in-light);
position: relative;
transition: all ease 0.3s;
margin-top: 10px;
font-size: 14px;
user-select: text;
word-break: break-word;
box-sizing: border-box;
audio {
height: 30px; /* 调整高度 */
}
}
.chat-message-item-image { .chat-message-item-image {
width: 100%; width: 100%;
margin-top: 10px; margin-top: 10px;
@@ -496,27 +482,23 @@
border: rgba($color: #888, $alpha: 0.2) 1px solid; border: rgba($color: #888, $alpha: 0.2) 1px solid;
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
$calc-image-width: calc(100vw / 3 * 2 / var(--image-count)); $calc-image-width: calc(100vw/3*2/var(--image-count));
.chat-message-item-image-multi { .chat-message-item-image-multi {
width: $calc-image-width; width: $calc-image-width;
height: $calc-image-width; height: $calc-image-width;
} }
.chat-message-item-image { .chat-message-item-image {
max-width: calc(100vw / 3 * 2); max-width: calc(100vw/3*2);
} }
} }
@media screen and (min-width: 600px) { @media screen and (min-width: 600px) {
$max-image-width: calc( $max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
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));
);
$image-width: calc(
calc(var(--window-width) - var(--sidebar-width)) / 3 * 2 /
var(--image-count)
);
.chat-message-item-image-multi { .chat-message-item-image-multi {
width: $image-width; width: $image-width;
@@ -526,7 +508,7 @@
} }
.chat-message-item-image { .chat-message-item-image {
max-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2); max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
} }
} }
@@ -544,7 +526,7 @@
z-index: 1; 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); background-color: var(--second);
&:hover { &:hover {
@@ -655,8 +637,7 @@
min-height: 68px; min-height: 68px;
} }
.chat-input:focus { .chat-input:focus {}
}
.chat-input-send { .chat-input-send {
background-color: var(--primary); background-color: var(--primary);
@@ -723,31 +704,4 @@
.shortcut-key span { .shortcut-key span {
font-size: 12px; font-size: 12px;
color: var(--black); color: var(--black);
} }
.chat-main {
display: flex;
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
.chat-body-container {
height: 100%;
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
}
.chat-side-panel {
position: absolute;
inset: 0;
background: var(--white);
overflow: hidden;
z-index: 10;
transform: translateX(100%);
transition: all ease 0.3s;
&-show {
transform: translateX(0);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -37,8 +37,7 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
return ( return (
<div className="no-dark"> <div className="no-dark">
{props.model?.startsWith("gpt-4") || {props.model?.startsWith("gpt-4") ||
props.model?.startsWith("chatgpt-4o") || props.model?.startsWith("chatgpt-4o") ? (
props.model?.startsWith("o1") ? (
<BlackBotIcon className="user-avatar" /> <BlackBotIcon className="user-avatar" />
) : ( ) : (
<BotIcon className="user-avatar" /> <BotIcon className="user-avatar" />

View File

@@ -40,7 +40,6 @@ import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { type ClientApi, getClientApi } from "../client/api"; import { type ClientApi, getClientApi } from "../client/api";
import { getMessageTextContent } from "../utils"; import { getMessageTextContent } from "../utils";
import clsx from "clsx";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />, loading: () => <LoadingIcon />,
@@ -119,10 +118,9 @@ function Steps<
return ( return (
<div <div
key={i} key={i}
className={clsx("clickable", styles["step"], { className={`${styles["step"]} ${
[styles["step-finished"]]: i <= props.index, styles[i <= props.index ? "step-finished" : ""]
[styles["step-current"]]: i === props.index, } ${i === props.index && styles["step-current"]} clickable`}
})}
onClick={() => { onClick={() => {
props.onStepChange?.(i); props.onStepChange?.(i);
}} }}
@@ -527,11 +525,11 @@ export function ImagePreviewer(props: {
messages={props.messages} messages={props.messages}
/> />
<div <div
className={clsx(styles["preview-body"], styles["default-theme"])} className={`${styles["preview-body"]} ${styles["default-theme"]}`}
ref={previewRef} ref={previewRef}
> >
<div className={styles["chat-info"]}> <div className={styles["chat-info"]}>
<div className={clsx(styles["logo"], "no-dark")}> <div className={styles["logo"] + " no-dark"}>
<NextImage <NextImage
src={ChatGptIcon.src} src={ChatGptIcon.src}
alt="logo" alt="logo"
@@ -572,7 +570,7 @@ export function ImagePreviewer(props: {
{props.messages.map((m, i) => { {props.messages.map((m, i) => {
return ( return (
<div <div
className={clsx(styles["message"], styles["message-" + m.role])} className={styles["message"] + " " + styles["message-" + m.role]}
key={i} key={i}
> >
<div className={styles["avatar"]}> <div className={styles["avatar"]}>

View File

@@ -3,6 +3,7 @@
require("../polyfill"); require("../polyfill");
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
import BotIcon from "../icons/bot.svg"; import BotIcon from "../icons/bot.svg";
@@ -28,11 +29,10 @@ import { AuthPage } from "./auth";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { type ClientApi, getClientApi } from "../client/api"; import { type ClientApi, getClientApi } from "../client/api";
import { useAccessStore } from "../store"; import { useAccessStore } from "../store";
import clsx from "clsx";
export function Loading(props: { noLogo?: boolean }) { export function Loading(props: { noLogo?: boolean }) {
return ( return (
<div className={clsx("no-dark", styles["loading-content"])}> <div className={styles["loading-content"] + " no-dark"}>
{!props.noLogo && <BotIcon />} {!props.noLogo && <BotIcon />}
<LoadingIcon /> <LoadingIcon />
</div> </div>
@@ -179,11 +179,7 @@ function Screen() {
if (isSdNew) return <Sd />; if (isSdNew) return <Sd />;
return ( return (
<> <>
<SideBar <SideBar className={isHome ? styles["sidebar-show"] : ""} />
className={clsx({
[styles["sidebar-show"]]: isHome,
})}
/>
<WindowContent> <WindowContent>
<Routes> <Routes>
<Route path={Path.Home} element={<Chat />} /> <Route path={Path.Home} element={<Chat />} />
@@ -201,10 +197,9 @@ function Screen() {
return ( return (
<div <div
className={clsx(styles.container, { className={`${styles.container} ${
[styles["tight-container"]]: shouldTightBorder, shouldTightBorder ? styles["tight-container"] : styles.container
[styles["rtl-screen"]]: getLang() === "ar", } ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
})}
> >
{renderContent()} {renderContent()}
</div> </div>

View File

@@ -1,6 +1,5 @@
import * as React from "react"; import * as React from "react";
import styles from "./input-range.module.scss"; import styles from "./input-range.module.scss";
import clsx from "clsx";
interface InputRangeProps { interface InputRangeProps {
onChange: React.ChangeEventHandler<HTMLInputElement>; onChange: React.ChangeEventHandler<HTMLInputElement>;
@@ -24,7 +23,7 @@ export function InputRange({
aria, aria,
}: InputRangeProps) { }: InputRangeProps) {
return ( return (
<div className={clsx(styles["input-range"], className)}> <div className={styles["input-range"] + ` ${className ?? ""}`}>
{title || value} {title || value}
<input <input
aria-label={aria} aria-label={aria}

View File

@@ -23,7 +23,6 @@ import { useChatStore } from "../store";
import { IconButton } from "./button"; import { IconButton } from "./button";
import { useAppConfig } from "../store/config"; import { useAppConfig } from "../store/config";
import clsx from "clsx";
export function Mermaid(props: { code: string }) { export function Mermaid(props: { code: string }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -58,7 +57,7 @@ export function Mermaid(props: { code: string }) {
return ( return (
<div <div
className={clsx("no-dark", "mermaid")} className="no-dark mermaid"
style={{ style={{
cursor: "pointer", cursor: "pointer",
overflow: "auto", overflow: "auto",
@@ -90,11 +89,7 @@ export function PreCode(props: { children: any }) {
const refText = ref.current.querySelector("code")?.innerText; const refText = ref.current.querySelector("code")?.innerText;
if (htmlDom) { if (htmlDom) {
setHtmlCode((htmlDom as HTMLElement).innerText); setHtmlCode((htmlDom as HTMLElement).innerText);
} else if ( } else if (refText?.startsWith("<!DOCTYPE")) {
refText?.startsWith("<!DOCTYPE") ||
refText?.startsWith("<svg") ||
refText?.startsWith("<?xml")
) {
setHtmlCode(refText); setHtmlCode(refText);
} }
}, 600); }, 600);
@@ -198,12 +193,7 @@ function CustomCode(props: { children: any; className?: string }) {
const renderShowMoreButton = () => { const renderShowMoreButton = () => {
if (showToggle && enableCodeFold && collapsed) { if (showToggle && enableCodeFold && collapsed) {
return ( return (
<div <div className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}>
className={clsx("show-hide-button", {
collapsed,
expanded: !collapsed,
})}
>
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button> <button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
</div> </div>
); );
@@ -213,7 +203,7 @@ function CustomCode(props: { children: any; className?: string }) {
return ( return (
<> <>
<code <code
className={clsx(props?.className)} className={props?.className}
ref={ref} ref={ref}
style={{ style={{
maxHeight: enableCodeFold && collapsed ? "400px" : "none", maxHeight: enableCodeFold && collapsed ? "400px" : "none",
@@ -248,10 +238,6 @@ function escapeBrackets(text: string) {
function tryWrapHtmlCode(text: string) { function tryWrapHtmlCode(text: string) {
// try add wrap html code (fixed: html codeblock include 2 newline) // try add wrap html code (fixed: html codeblock include 2 newline)
// ignore embed codeblock
if (text.includes("```")) {
return text;
}
return text return text
.replace( .replace(
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g, /([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,

View File

@@ -55,7 +55,6 @@ import {
OnDragEndResponder, OnDragEndResponder,
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { getMessageTextContent } from "../utils"; import { getMessageTextContent } from "../utils";
import clsx from "clsx";
// drag and drop helper function // drag and drop helper function
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] { function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
@@ -589,7 +588,7 @@ export function MaskPage() {
</div> </div>
<div className={styles["mask-title"]}> <div className={styles["mask-title"]}>
<div className={styles["mask-name"]}>{m.name}</div> <div className={styles["mask-name"]}>{m.name}</div>
<div className={clsx(styles["mask-info"], "one-line")}> <div className={styles["mask-info"] + " one-line"}>
{`${Locale.Mask.Item.Info(m.context.length)} / ${ {`${Locale.Mask.Item.Info(m.context.length)} / ${
ALL_LANG_OPTIONS[m.lang] ALL_LANG_OPTIONS[m.lang]
} / ${m.modelConfig.model}`} } / ${m.modelConfig.model}`}

View File

@@ -8,7 +8,6 @@ import Locale from "../locales";
import styles from "./message-selector.module.scss"; import styles from "./message-selector.module.scss";
import { getMessageTextContent } from "../utils"; import { getMessageTextContent } from "../utils";
import clsx from "clsx";
function useShiftRange() { function useShiftRange() {
const [startIndex, setStartIndex] = useState<number>(); const [startIndex, setStartIndex] = useState<number>();
@@ -72,7 +71,6 @@ export function MessageSelector(props: {
defaultSelectAll?: boolean; defaultSelectAll?: boolean;
onSelected?: (messages: ChatMessage[]) => void; onSelected?: (messages: ChatMessage[]) => void;
}) { }) {
const LATEST_COUNT = 4;
const chatStore = useChatStore(); const chatStore = useChatStore();
const session = chatStore.currentSession(); const session = chatStore.currentSession();
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming; const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
@@ -143,13 +141,15 @@ export function MessageSelector(props: {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [startIndex, endIndex]); }, [startIndex, endIndex]);
const LATEST_COUNT = 4;
return ( return (
<div className={styles["message-selector"]}> <div className={styles["message-selector"]}>
<div className={styles["message-filter"]}> <div className={styles["message-filter"]}>
<input <input
type="text" type="text"
placeholder={Locale.Select.Search} placeholder={Locale.Select.Search}
className={clsx(styles["filter-item"], styles["search-bar"])} className={styles["filter-item"] + " " + styles["search-bar"]}
value={searchInput} value={searchInput}
onInput={(e) => { onInput={(e) => {
setSearchInput(e.currentTarget.value); setSearchInput(e.currentTarget.value);
@@ -196,9 +196,9 @@ export function MessageSelector(props: {
return ( return (
<div <div
className={clsx(styles["message"], { className={`${styles["message"]} ${
[styles["message-selected"]]: props.selection.has(m.id!), props.selection.has(m.id!) && styles["message-selected"]
})} }`}
key={i} key={i}
onClick={() => { onClick={() => {
props.updateSelection((selection) => { props.updateSelection((selection) => {
@@ -221,7 +221,7 @@ export function MessageSelector(props: {
<div className={styles["date"]}> <div className={styles["date"]}>
{new Date(m.date).toLocaleString()} {new Date(m.date).toLocaleString()}
</div> </div>
<div className={clsx(styles["content"], "one-line")}> <div className={`${styles["content"]} one-line`}>
{getMessageTextContent(m)} {getMessageTextContent(m)}
</div> </div>
</div> </div>

View File

@@ -7,7 +7,6 @@ import { ListItem, Select } from "./ui-lib";
import { useAllModels } from "../utils/hooks"; import { useAllModels } from "../utils/hooks";
import { groupBy } from "lodash-es"; import { groupBy } from "lodash-es";
import styles from "./model-config.module.scss"; import styles from "./model-config.module.scss";
import { getModelProvider } from "../utils/model";
export function ModelConfigList(props: { export function ModelConfigList(props: {
modelConfig: ModelConfig; modelConfig: ModelConfig;
@@ -29,9 +28,7 @@ export function ModelConfigList(props: {
value={value} value={value}
align="left" align="left"
onChange={(e) => { onChange={(e) => {
const [model, providerName] = getModelProvider( const [model, providerName] = e.currentTarget.value.split("@");
e.currentTarget.value,
);
props.updateConfig((config) => { props.updateConfig((config) => {
config.model = ModalConfigValidator.model(model); config.model = ModalConfigValidator.model(model);
config.providerName = providerName as ServiceProvider; config.providerName = providerName as ServiceProvider;
@@ -250,9 +247,7 @@ export function ModelConfigList(props: {
aria-label={Locale.Settings.CompressModel.Title} aria-label={Locale.Settings.CompressModel.Title}
value={compressModelValue} value={compressModelValue}
onChange={(e) => { onChange={(e) => {
const [model, providerName] = getModelProvider( const [model, providerName] = e.currentTarget.value.split("@");
e.currentTarget.value,
);
props.updateConfig((config) => { props.updateConfig((config) => {
config.compressModel = ModalConfigValidator.model(model); config.compressModel = ModalConfigValidator.model(model);
config.compressProviderName = providerName as ServiceProvider; config.compressProviderName = providerName as ServiceProvider;

View File

@@ -16,7 +16,6 @@ import { MaskAvatar } from "./mask";
import { useCommand } from "../command"; import { useCommand } from "../command";
import { showConfirm } from "./ui-lib"; import { showConfirm } from "./ui-lib";
import { BUILTIN_MASK_STORE } from "../masks"; import { BUILTIN_MASK_STORE } from "../masks";
import clsx from "clsx";
function MaskItem(props: { mask: Mask; onClick?: () => void }) { function MaskItem(props: { mask: Mask; onClick?: () => void }) {
return ( return (
@@ -25,9 +24,7 @@ function MaskItem(props: { mask: Mask; onClick?: () => void }) {
avatar={props.mask.avatar} avatar={props.mask.avatar}
model={props.mask.modelConfig.model} model={props.mask.modelConfig.model}
/> />
<div className={clsx(styles["mask-name"], "one-line")}> <div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
{props.mask.name}
</div>
</div> </div>
); );
} }

View File

@@ -28,7 +28,6 @@ import {
import Locale from "../locales"; import Locale from "../locales";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import clsx from "clsx";
export function PluginPage() { export function PluginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -200,7 +199,7 @@ export function PluginPage() {
<div className={styles["mask-name"]}> <div className={styles["mask-name"]}>
{m.title}@<small>{m.version}</small> {m.title}@<small>{m.version}</small>
</div> </div>
<div className={clsx(styles["mask-info"], "one-line")}> <div className={styles["mask-info"] + " one-line"}>
{Locale.Plugin.Item.Info( {Locale.Plugin.Item.Info(
FunctionToolService.add(m).length, FunctionToolService.add(m).length,
)} )}
@@ -336,10 +335,7 @@ export function PluginPage() {
<ListItem <ListItem
subTitle={ subTitle={
<div <div
className={clsx( className={`markdown-body ${pluginStyles["plugin-content"]}`}
"markdown-body",
pluginStyles["plugin-content"],
)}
dir="auto" dir="auto"
> >
<pre> <pre>

View File

@@ -1 +0,0 @@
export * from "./realtime-chat";

View File

@@ -1,74 +0,0 @@
.realtime-chat {
width: 100%;
justify-content: center;
align-items: center;
position: relative;
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
box-sizing: border-box;
.circle-mic {
width: 150px;
height: 150px;
border-radius: 50%;
background: linear-gradient(to bottom right, #a0d8ef, #f0f8ff);
display: flex;
justify-content: center;
align-items: center;
}
.icon-center {
font-size: 24px;
}
.bottom-icons {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
position: absolute;
bottom: 20px;
box-sizing: border-box;
padding: 0 20px;
}
.icon-left,
.icon-right {
width: 46px;
height: 46px;
font-size: 36px;
background: var(--second);
border-radius: 50%;
padding: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
&.mobile {
display: none;
}
}
.pulse {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.1);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.7;
}
}

View File

@@ -1,359 +0,0 @@
import VoiceIcon from "@/app/icons/voice.svg";
import VoiceOffIcon from "@/app/icons/voice-off.svg";
import PowerIcon from "@/app/icons/power.svg";
import styles from "./realtime-chat.module.scss";
import clsx from "clsx";
import { useState, useRef, useEffect } from "react";
import { useChatStore, createMessage, useAppConfig } from "@/app/store";
import { IconButton } from "@/app/components/button";
import {
Modality,
RTClient,
RTInputAudioItem,
RTResponse,
TurnDetection,
} from "rt-client";
import { AudioHandler } from "@/app/lib/audio";
import { uploadImage } from "@/app/utils/chat";
import { VoicePrint } from "@/app/components/voice-print";
interface RealtimeChatProps {
onClose?: () => void;
onStartVoice?: () => void;
onPausedVoice?: () => void;
}
export function RealtimeChat({
onClose,
onStartVoice,
onPausedVoice,
}: RealtimeChatProps) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const [status, setStatus] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [modality, setModality] = useState("audio");
const [useVAD, setUseVAD] = useState(true);
const [frequencies, setFrequencies] = useState<Uint8Array | undefined>();
const clientRef = useRef<RTClient | null>(null);
const audioHandlerRef = useRef<AudioHandler | null>(null);
const initRef = useRef(false);
const temperature = config.realtimeConfig.temperature;
const apiKey = config.realtimeConfig.apiKey;
const model = config.realtimeConfig.model;
const azure = config.realtimeConfig.provider === "Azure";
const azureEndpoint = config.realtimeConfig.azure.endpoint;
const azureDeployment = config.realtimeConfig.azure.deployment;
const voice = config.realtimeConfig.voice;
const handleConnect = async () => {
if (isConnecting) return;
if (!isConnected) {
try {
setIsConnecting(true);
clientRef.current = azure
? new RTClient(
new URL(azureEndpoint),
{ key: apiKey },
{ deployment: azureDeployment },
)
: new RTClient({ key: apiKey }, { model });
const modalities: Modality[] =
modality === "audio" ? ["text", "audio"] : ["text"];
const turnDetection: TurnDetection = useVAD
? { type: "server_vad" }
: null;
await clientRef.current.configure({
instructions: "",
voice,
input_audio_transcription: { model: "whisper-1" },
turn_detection: turnDetection,
tools: [],
temperature,
modalities,
});
startResponseListener();
setIsConnected(true);
// TODO
// try {
// const recentMessages = chatStore.getMessagesWithMemory();
// for (const message of recentMessages) {
// const { role, content } = message;
// if (typeof content === "string") {
// await clientRef.current.sendItem({
// type: "message",
// role: role as any,
// content: [
// {
// type: (role === "assistant" ? "text" : "input_text") as any,
// text: content as string,
// },
// ],
// });
// }
// }
// // await clientRef.current.generateResponse();
// } catch (error) {
// console.error("Set message failed:", error);
// }
} catch (error) {
console.error("Connection failed:", error);
setStatus("Connection failed");
} finally {
setIsConnecting(false);
}
} else {
await disconnect();
}
};
const disconnect = async () => {
if (clientRef.current) {
try {
await clientRef.current.close();
clientRef.current = null;
setIsConnected(false);
} catch (error) {
console.error("Disconnect failed:", error);
}
}
};
const startResponseListener = async () => {
if (!clientRef.current) return;
try {
for await (const serverEvent of clientRef.current.events()) {
if (serverEvent.type === "response") {
await handleResponse(serverEvent);
} else if (serverEvent.type === "input_audio") {
await handleInputAudio(serverEvent);
}
}
} catch (error) {
if (clientRef.current) {
console.error("Response iteration error:", error);
}
}
};
const handleResponse = async (response: RTResponse) => {
for await (const item of response) {
if (item.type === "message" && item.role === "assistant") {
const botMessage = createMessage({
role: item.role,
content: "",
});
// add bot message first
chatStore.updateTargetSession(session, (session) => {
session.messages = session.messages.concat([botMessage]);
});
let hasAudio = false;
for await (const content of item) {
if (content.type === "text") {
for await (const text of content.textChunks()) {
botMessage.content += text;
}
} else if (content.type === "audio") {
const textTask = async () => {
for await (const text of content.transcriptChunks()) {
botMessage.content += text;
}
};
const audioTask = async () => {
audioHandlerRef.current?.startStreamingPlayback();
for await (const audio of content.audioChunks()) {
hasAudio = true;
audioHandlerRef.current?.playChunk(audio);
}
};
await Promise.all([textTask(), audioTask()]);
}
// update message.content
chatStore.updateTargetSession(session, (session) => {
session.messages = session.messages.concat();
});
}
if (hasAudio) {
// upload audio get audio_url
const blob = audioHandlerRef.current?.savePlayFile();
uploadImage(blob!).then((audio_url) => {
botMessage.audio_url = audio_url;
// update text and audio_url
chatStore.updateTargetSession(session, (session) => {
session.messages = session.messages.concat();
});
});
}
}
}
};
const handleInputAudio = async (item: RTInputAudioItem) => {
await item.waitForCompletion();
if (item.transcription) {
const userMessage = createMessage({
role: "user",
content: item.transcription,
});
chatStore.updateTargetSession(session, (session) => {
session.messages = session.messages.concat([userMessage]);
});
// save input audio_url, and update session
const { audioStartMillis, audioEndMillis } = item;
// upload audio get audio_url
const blob = audioHandlerRef.current?.saveRecordFile(
audioStartMillis,
audioEndMillis,
);
uploadImage(blob!).then((audio_url) => {
userMessage.audio_url = audio_url;
chatStore.updateTargetSession(session, (session) => {
session.messages = session.messages.concat();
});
});
}
// stop streaming play after get input audio.
audioHandlerRef.current?.stopStreamingPlayback();
};
const toggleRecording = async () => {
if (!isRecording && clientRef.current) {
try {
if (!audioHandlerRef.current) {
audioHandlerRef.current = new AudioHandler();
await audioHandlerRef.current.initialize();
}
await audioHandlerRef.current.startRecording(async (chunk) => {
await clientRef.current?.sendAudio(chunk);
});
setIsRecording(true);
} catch (error) {
console.error("Failed to start recording:", error);
}
} else if (audioHandlerRef.current) {
try {
audioHandlerRef.current.stopRecording();
if (!useVAD) {
const inputAudio = await clientRef.current?.commitAudio();
await handleInputAudio(inputAudio!);
await clientRef.current?.generateResponse();
}
setIsRecording(false);
} catch (error) {
console.error("Failed to stop recording:", error);
}
}
};
useEffect(() => {
// 防止重复初始化
if (initRef.current) return;
initRef.current = true;
const initAudioHandler = async () => {
const handler = new AudioHandler();
await handler.initialize();
audioHandlerRef.current = handler;
await handleConnect();
await toggleRecording();
};
initAudioHandler().catch((error) => {
setStatus(error);
console.error(error);
});
return () => {
if (isRecording) {
toggleRecording();
}
audioHandlerRef.current?.close().catch(console.error);
disconnect();
};
}, []);
useEffect(() => {
let animationFrameId: number;
if (isConnected && isRecording) {
const animationFrame = () => {
if (audioHandlerRef.current) {
const freqData = audioHandlerRef.current.getByteFrequencyData();
setFrequencies(freqData);
}
animationFrameId = requestAnimationFrame(animationFrame);
};
animationFrameId = requestAnimationFrame(animationFrame);
} else {
setFrequencies(undefined);
}
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, [isConnected, isRecording]);
// update session params
useEffect(() => {
clientRef.current?.configure({ voice });
}, [voice]);
useEffect(() => {
clientRef.current?.configure({ temperature });
}, [temperature]);
const handleClose = async () => {
onClose?.();
if (isRecording) {
await toggleRecording();
}
disconnect().catch(console.error);
};
return (
<div className={styles["realtime-chat"]}>
<div
className={clsx(styles["circle-mic"], {
[styles["pulse"]]: isRecording,
})}
>
<VoicePrint frequencies={frequencies} isActive={isRecording} />
</div>
<div className={styles["bottom-icons"]}>
<div>
<IconButton
icon={isRecording ? <VoiceIcon /> : <VoiceOffIcon />}
onClick={toggleRecording}
disabled={!isConnected}
shadow
bordered
/>
</div>
<div className={styles["icon-center"]}>{status}</div>
<div>
<IconButton
icon={<PowerIcon />}
onClick={handleClose}
shadow
bordered
/>
</div>
</div>
</div>
);
}

View File

@@ -1,173 +0,0 @@
import { RealtimeConfig } from "@/app/store";
import Locale from "@/app/locales";
import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib";
import { InputRange } from "@/app/components/input-range";
import { Voice } from "rt-client";
import { ServiceProvider } from "@/app/constant";
const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure];
const models = ["gpt-4o-realtime-preview-2024-10-01"];
const voice = ["alloy", "shimmer", "echo"];
export function RealtimeConfigList(props: {
realtimeConfig: RealtimeConfig;
updateConfig: (updater: (config: RealtimeConfig) => void) => void;
}) {
const azureConfigComponent = props.realtimeConfig.provider ===
ServiceProvider.Azure && (
<>
<ListItem
title={Locale.Settings.Realtime.Azure.Endpoint.Title}
subTitle={Locale.Settings.Realtime.Azure.Endpoint.SubTitle}
>
<input
value={props.realtimeConfig?.azure?.endpoint}
type="text"
placeholder={Locale.Settings.Realtime.Azure.Endpoint.Title}
onChange={(e) => {
props.updateConfig(
(config) => (config.azure.endpoint = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Realtime.Azure.Deployment.Title}
subTitle={Locale.Settings.Realtime.Azure.Deployment.SubTitle}
>
<input
value={props.realtimeConfig?.azure?.deployment}
type="text"
placeholder={Locale.Settings.Realtime.Azure.Deployment.Title}
onChange={(e) => {
props.updateConfig(
(config) => (config.azure.deployment = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
return (
<>
<ListItem
title={Locale.Settings.Realtime.Enable.Title}
subTitle={Locale.Settings.Realtime.Enable.SubTitle}
>
<input
type="checkbox"
checked={props.realtimeConfig.enable}
onChange={(e) =>
props.updateConfig(
(config) => (config.enable = e.currentTarget.checked),
)
}
></input>
</ListItem>
{props.realtimeConfig.enable && (
<>
<ListItem
title={Locale.Settings.Realtime.Provider.Title}
subTitle={Locale.Settings.Realtime.Provider.SubTitle}
>
<Select
aria-label={Locale.Settings.Realtime.Provider.Title}
value={props.realtimeConfig.provider}
onChange={(e) => {
props.updateConfig(
(config) =>
(config.provider = e.target.value as ServiceProvider),
);
}}
>
{providers.map((v, i) => (
<option value={v} key={i}>
{v}
</option>
))}
</Select>
</ListItem>
<ListItem
title={Locale.Settings.Realtime.Model.Title}
subTitle={Locale.Settings.Realtime.Model.SubTitle}
>
<Select
aria-label={Locale.Settings.Realtime.Model.Title}
value={props.realtimeConfig.model}
onChange={(e) => {
props.updateConfig((config) => (config.model = e.target.value));
}}
>
{models.map((v, i) => (
<option value={v} key={i}>
{v}
</option>
))}
</Select>
</ListItem>
<ListItem
title={Locale.Settings.Realtime.ApiKey.Title}
subTitle={Locale.Settings.Realtime.ApiKey.SubTitle}
>
<PasswordInput
aria={Locale.Settings.ShowPassword}
aria-label={Locale.Settings.Realtime.ApiKey.Title}
value={props.realtimeConfig.apiKey}
type="text"
placeholder={Locale.Settings.Realtime.ApiKey.Placeholder}
onChange={(e) => {
props.updateConfig(
(config) => (config.apiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
{azureConfigComponent}
<ListItem
title={Locale.Settings.TTS.Voice.Title}
subTitle={Locale.Settings.TTS.Voice.SubTitle}
>
<Select
value={props.realtimeConfig.voice}
onChange={(e) => {
props.updateConfig(
(config) => (config.voice = e.currentTarget.value as Voice),
);
}}
>
{voice.map((v, i) => (
<option value={v} key={i}>
{v}
</option>
))}
</Select>
</ListItem>
<ListItem
title={Locale.Settings.Realtime.Temperature.Title}
subTitle={Locale.Settings.Realtime.Temperature.SubTitle}
>
<InputRange
aria={Locale.Settings.Temperature.Title}
value={props.realtimeConfig?.temperature?.toFixed(1)}
min="0.6"
max="1"
step="0.1"
onChange={(e) => {
props.updateConfig(
(config) =>
(config.temperature = e.currentTarget.valueAsNumber),
);
}}
></InputRange>
</ListItem>
</>
)}
</>
);
}

View File

@@ -4,7 +4,6 @@ import { Select } from "@/app/components/ui-lib";
import { IconButton } from "@/app/components/button"; import { IconButton } from "@/app/components/button";
import Locale from "@/app/locales"; import Locale from "@/app/locales";
import { useSdStore } from "@/app/store/sd"; import { useSdStore } from "@/app/store/sd";
import clsx from "clsx";
export const params = [ export const params = [
{ {
@@ -137,7 +136,7 @@ export function ControlParamItem(props: {
className?: string; className?: string;
}) { }) {
return ( return (
<div className={clsx(styles["ctrl-param-item"], props.className)}> <div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
<div className={styles["ctrl-param-item-header"]}> <div className={styles["ctrl-param-item-header"]}>
<div className={styles["ctrl-param-item-title"]}> <div className={styles["ctrl-param-item-title"]}>
<div> <div>

View File

@@ -36,7 +36,6 @@ import { removeImage } from "@/app/utils/chat";
import { SideBar } from "./sd-sidebar"; import { SideBar } from "./sd-sidebar";
import { WindowContent } from "@/app/components/home"; import { WindowContent } from "@/app/components/home";
import { params } from "./sd-panel"; import { params } from "./sd-panel";
import clsx from "clsx";
function getSdTaskStatus(item: any) { function getSdTaskStatus(item: any) {
let s: string; let s: string;
@@ -105,7 +104,7 @@ export function Sd() {
return ( return (
<> <>
<SideBar className={clsx({ [homeStyles["sidebar-show"]]: isSd })} /> <SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
<WindowContent> <WindowContent>
<div className={chatStyles.chat} key={"1"}> <div className={chatStyles.chat} key={"1"}>
<div className="window-header" data-tauri-drag-region> <div className="window-header" data-tauri-drag-region>
@@ -122,10 +121,7 @@ export function Sd() {
</div> </div>
)} )}
<div <div
className={clsx( className={`window-header-title ${chatStyles["chat-body-title"]}`}
"window-header-title",
chatStyles["chat-body-title"],
)}
> >
<div className={`window-header-main-title`}>Stability AI</div> <div className={`window-header-main-title`}>Stability AI</div>
<div className="window-header-sub-title"> <div className="window-header-sub-title">

View File

@@ -85,7 +85,6 @@ import { nanoid } from "nanoid";
import { useMaskStore } from "../store/mask"; import { useMaskStore } from "../store/mask";
import { ProviderType } from "../utils/cloud"; import { ProviderType } from "../utils/cloud";
import { TTSConfigList } from "./tts-config"; import { TTSConfigList } from "./tts-config";
import { RealtimeConfigList } from "./realtime-chat/realtime-config";
function EditPromptModal(props: { id: string; onClose: () => void }) { function EditPromptModal(props: { id: string; onClose: () => void }) {
const promptStore = usePromptStore(); const promptStore = usePromptStore();
@@ -965,7 +964,75 @@ export function Settings() {
</ListItem> </ListItem>
</> </>
); );
const bedrockConfigComponent = accessStore.provider ===
ServiceProvider.Bedrock && (
<>
<ListItem
title={Locale.Settings.Access.Bedrock.Region.Title}
subTitle={Locale.Settings.Access.Bedrock.Region.SubTitle}
>
<input
aria-label={Locale.Settings.Access.Bedrock.Region.Title}
type="text"
value={accessStore.awsRegion}
placeholder="us-west-2"
onChange={(e) =>
accessStore.update(
(access) => (access.awsRegion = e.currentTarget.value),
)
}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Bedrock.AccessKey.Title}
subTitle={Locale.Settings.Access.Bedrock.AccessKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Bedrock.AccessKey.Title}
value={accessStore.awsAccessKey}
type="text"
placeholder={Locale.Settings.Access.Bedrock.AccessKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.awsAccessKey = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Bedrock.SecretKey.Title}
subTitle={Locale.Settings.Access.Bedrock.SecretKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Bedrock.SecretKey.Title}
value={accessStore.awsSecretKey}
type="text"
placeholder={Locale.Settings.Access.Bedrock.SecretKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.awsSecretKey = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Bedrock.SessionToken.Title}
subTitle={Locale.Settings.Access.Bedrock.SessionToken.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Bedrock.SessionToken.Title}
value={accessStore.awsSessionToken}
type="text"
placeholder={Locale.Settings.Access.Bedrock.SessionToken.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.awsSessionToken = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
const baiduConfigComponent = accessStore.provider === const baiduConfigComponent = accessStore.provider ===
ServiceProvider.Baidu && ( ServiceProvider.Baidu && (
<> <>
@@ -1725,6 +1792,7 @@ export function Settings() {
</ListItem> </ListItem>
{openAIConfigComponent} {openAIConfigComponent}
{bedrockConfigComponent}
{azureConfigComponent} {azureConfigComponent}
{googleConfigComponent} {googleConfigComponent}
{anthropicConfigComponent} {anthropicConfigComponent}
@@ -1771,11 +1839,9 @@ export function Settings() {
<ListItem <ListItem
title={Locale.Settings.Access.CustomModel.Title} title={Locale.Settings.Access.CustomModel.Title}
subTitle={Locale.Settings.Access.CustomModel.SubTitle} subTitle={Locale.Settings.Access.CustomModel.SubTitle}
vertical={true}
> >
<input <input
aria-label={Locale.Settings.Access.CustomModel.Title} aria-label={Locale.Settings.Access.CustomModel.Title}
style={{ width: "100%", maxWidth: "unset", textAlign: "left" }}
type="text" type="text"
value={config.customModels} value={config.customModels}
placeholder="model1,model2,model3" placeholder="model1,model2,model3"
@@ -1802,18 +1868,7 @@ export function Settings() {
{shouldShowPromptModal && ( {shouldShowPromptModal && (
<UserPromptModal onClose={() => setShowPromptModal(false)} /> <UserPromptModal onClose={() => setShowPromptModal(false)} />
)} )}
<List>
<RealtimeConfigList
realtimeConfig={config.realtimeConfig}
updateConfig={(updater) => {
const realtimeConfig = { ...config.realtimeConfig };
updater(realtimeConfig);
config.update(
(config) => (config.realtimeConfig = realtimeConfig),
);
}}
/>
</List>
<List> <List>
<TTSConfigList <TTSConfigList
ttsConfig={config.ttsConfig} ttsConfig={config.ttsConfig}

View File

@@ -30,7 +30,6 @@ import { Link, useNavigate } from "react-router-dom";
import { isIOS, useMobileScreen } from "../utils"; import { isIOS, useMobileScreen } from "../utils";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { showConfirm, Selector } from "./ui-lib"; import { showConfirm, Selector } from "./ui-lib";
import clsx from "clsx";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null, loading: () => null,
@@ -142,9 +141,9 @@ export function SideBarContainer(props: {
const { children, className, onDragStart, shouldNarrow } = props; const { children, className, onDragStart, shouldNarrow } = props;
return ( return (
<div <div
className={clsx(styles.sidebar, className, { className={`${styles.sidebar} ${className} ${
[styles["narrow-sidebar"]]: shouldNarrow, shouldNarrow && styles["narrow-sidebar"]
})} }`}
style={{ style={{
// #3016 disable transition on ios mobile screen // #3016 disable transition on ios mobile screen
transition: isMobileScreen && isIOSMobile ? "none" : undefined, transition: isMobileScreen && isIOSMobile ? "none" : undefined,
@@ -172,9 +171,9 @@ export function SideBarHeader(props: {
return ( return (
<Fragment> <Fragment>
<div <div
className={clsx(styles["sidebar-header"], { className={`${styles["sidebar-header"]} ${
[styles["sidebar-header-narrow"]]: shouldNarrow, shouldNarrow ? styles["sidebar-header-narrow"] : ""
})} }`}
data-tauri-drag-region data-tauri-drag-region
> >
<div className={styles["sidebar-title-container"]}> <div className={styles["sidebar-title-container"]}>
@@ -183,7 +182,7 @@ export function SideBarHeader(props: {
</div> </div>
<div className={styles["sidebar-sub-title"]}>{subTitle}</div> <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
</div> </div>
<div className={clsx(styles["sidebar-logo"], "no-dark")}>{logo}</div> <div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
</div> </div>
{children} {children}
</Fragment> </Fragment>
@@ -287,7 +286,7 @@ export function SideBar(props: { className?: string }) {
<SideBarTail <SideBarTail
primaryAction={ primaryAction={
<> <>
<div className={clsx(styles["sidebar-action"], styles.mobile)}> <div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton <IconButton
icon={<DeleteIcon />} icon={<DeleteIcon />}
onClick={async () => { onClick={async () => {

View File

@@ -23,7 +23,6 @@ import React, {
useRef, useRef,
} from "react"; } from "react";
import { IconButton } from "./button"; import { IconButton } from "./button";
import clsx from "clsx";
export function Popover(props: { export function Popover(props: {
children: JSX.Element; children: JSX.Element;
@@ -46,7 +45,7 @@ export function Popover(props: {
export function Card(props: { children: JSX.Element[]; className?: string }) { export function Card(props: { children: JSX.Element[]; className?: string }) {
return ( return (
<div className={clsx(styles.card, props.className)}>{props.children}</div> <div className={styles.card + " " + props.className}>{props.children}</div>
); );
} }
@@ -61,13 +60,11 @@ export function ListItem(props: {
}) { }) {
return ( return (
<div <div
className={clsx( className={
styles["list-item"], styles["list-item"] +
{ ` ${props.vertical ? styles["vertical"] : ""} ` +
[styles["vertical"]]: props.vertical, ` ${props.className || ""}`
}, }
props.className,
)}
onClick={props.onClick} onClick={props.onClick}
> >
<div className={styles["list-header"]}> <div className={styles["list-header"]}>
@@ -138,9 +135,9 @@ export function Modal(props: ModalProps) {
return ( return (
<div <div
className={clsx(styles["modal-container"], { className={
[styles["modal-container-max"]]: isMax, styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
})} }
> >
<div className={styles["modal-header"]}> <div className={styles["modal-header"]}>
<div className={styles["modal-title"]}>{props.title}</div> <div className={styles["modal-title"]}>{props.title}</div>
@@ -263,7 +260,7 @@ export function Input(props: InputProps) {
return ( return (
<textarea <textarea
{...props} {...props}
className={clsx(styles["input"], props.className)} className={`${styles["input"]} ${props.className}`}
></textarea> ></textarea>
); );
} }
@@ -304,13 +301,9 @@ export function Select(
const { className, children, align, ...otherProps } = props; const { className, children, align, ...otherProps } = props;
return ( return (
<div <div
className={clsx( className={`${styles["select-with-icon"]} ${
styles["select-with-icon"], align === "left" ? styles["left-align-option"] : ""
{ } ${className}`}
[styles["left-align-option"]]: align === "left",
},
className,
)}
> >
<select className={styles["select-with-icon-select"]} {...otherProps}> <select className={styles["select-with-icon-select"]} {...otherProps}>
{children} {children}
@@ -516,9 +509,9 @@ export function Selector<T>(props: {
const selected = selectedValues.includes(item.value); const selected = selectedValues.includes(item.value);
return ( return (
<ListItem <ListItem
className={clsx(styles["selector-item"], { className={`${styles["selector-item"]} ${
[styles["selector-item-disabled"]]: item.disable, item.disable && styles["selector-item-disabled"]
})} }`}
key={i} key={i}
title={item.title} title={item.title}
subTitle={item.subTitle} subTitle={item.subTitle}

View File

@@ -1 +0,0 @@
export * from "./voice-print";

View File

@@ -1,11 +0,0 @@
.voice-print {
width: 100%;
height: 60px;
margin: 20px 0;
canvas {
width: 100%;
height: 100%;
filter: brightness(1.2); // 增加整体亮度
}
}

View File

@@ -1,180 +0,0 @@
import { useEffect, useRef, useCallback } from "react";
import styles from "./voice-print.module.scss";
interface VoicePrintProps {
frequencies?: Uint8Array;
isActive?: boolean;
}
export function VoicePrint({ frequencies, isActive }: VoicePrintProps) {
// Canvas引用用于获取绘图上下文
const canvasRef = useRef<HTMLCanvasElement>(null);
// 存储历史频率数据,用于平滑处理
const historyRef = useRef<number[][]>([]);
// 控制保留的历史数据帧数,影响平滑度
const historyLengthRef = useRef(10);
// 存储动画帧ID用于清理
const animationFrameRef = useRef<number>();
/**
* 更新频率历史数据
* 使用FIFO队列维护固定长度的历史记录
*/
const updateHistory = useCallback((freqArray: number[]) => {
historyRef.current.push(freqArray);
if (historyRef.current.length > historyLengthRef.current) {
historyRef.current.shift();
}
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
/**
* 处理高DPI屏幕显示
* 根据设备像素比例调整canvas实际渲染分辨率
*/
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);
/**
* 主要绘制函数
* 使用requestAnimationFrame实现平滑动画
* 包含以下步骤:
* 1. 清空画布
* 2. 更新历史数据
* 3. 计算波形点
* 4. 绘制上下对称的声纹
*/
const draw = () => {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!frequencies || !isActive) {
historyRef.current = [];
return;
}
const freqArray = Array.from(frequencies);
updateHistory(freqArray);
// 绘制声纹
const points: [number, number][] = [];
const centerY = canvas.height / 2;
const width = canvas.width;
const sliceWidth = width / (frequencies.length - 1);
// 绘制主波形
ctx.beginPath();
ctx.moveTo(0, centerY);
/**
* 声纹绘制算法:
* 1. 使用历史数据平均值实现平滑过渡
* 2. 通过正弦函数添加自然波动
* 3. 使用贝塞尔曲线连接点,使曲线更平滑
* 4. 绘制对称部分形成完整声纹
*/
for (let i = 0; i < frequencies.length; i++) {
const x = i * sliceWidth;
let avgFrequency = frequencies[i];
/**
* 波形平滑处理:
* 1. 收集历史数据中对应位置的频率值
* 2. 计算当前值与历史值的加权平均
* 3. 根据平均值计算实际显示高度
*/
if (historyRef.current.length > 0) {
const historicalValues = historyRef.current.map((h) => h[i] || 0);
avgFrequency =
(avgFrequency + historicalValues.reduce((a, b) => a + b, 0)) /
(historyRef.current.length + 1);
}
/**
* 波形变换:
* 1. 归一化频率值到0-1范围
* 2. 添加时间相关的正弦变换
* 3. 使用贝塞尔曲线平滑连接点
*/
const normalized = avgFrequency / 255.0;
const height = normalized * (canvas.height / 2);
const y = centerY + height * Math.sin(i * 0.2 + Date.now() * 0.002);
points.push([x, y]);
if (i === 0) {
ctx.moveTo(x, y);
} else {
// 使用贝塞尔曲线使波形更平滑
const prevPoint = points[i - 1];
const midX = (prevPoint[0] + x) / 2;
ctx.quadraticCurveTo(
prevPoint[0],
prevPoint[1],
midX,
(prevPoint[1] + y) / 2,
);
}
}
// 绘制对称的下半部分
for (let i = points.length - 1; i >= 0; i--) {
const [x, y] = points[i];
const symmetricY = centerY - (y - centerY);
if (i === points.length - 1) {
ctx.lineTo(x, symmetricY);
} else {
const nextPoint = points[i + 1];
const midX = (nextPoint[0] + x) / 2;
ctx.quadraticCurveTo(
nextPoint[0],
centerY - (nextPoint[1] - centerY),
midX,
centerY - ((nextPoint[1] + y) / 2 - centerY),
);
}
}
ctx.closePath();
/**
* 渐变效果:
* 从左到右应用三色渐变,带透明度
* 使用蓝色系配色提升视觉效果
*/
const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)");
gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)");
gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)");
ctx.fillStyle = gradient;
ctx.fill();
animationFrameRef.current = requestAnimationFrame(draw);
};
// 启动动画循环
draw();
// 清理函数:在组件卸载时取消动画
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [frequencies, isActive, updateHistory]);
return (
<div className={styles["voice-print"]}>
<canvas ref={canvasRef} />
</div>
);
}

View File

@@ -40,7 +40,6 @@ export const getBuildConfig = () => {
buildMode, buildMode,
isApp, isApp,
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE, template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
visionModels: process.env.VISION_MODELS || "",
}; };
}; };

View File

@@ -12,6 +12,11 @@ declare global {
BASE_URL?: string; BASE_URL?: string;
OPENAI_ORG_ID?: string; // openai only OPENAI_ORG_ID?: string; // openai only
// bedrock only
BEDROCK_REGION?: string;
BEDROCK_API_KEY?: string;
BEDROCK_API_SECRET?: string;
VERCEL?: string; VERCEL?: string;
BUILD_MODE?: "standalone" | "export"; BUILD_MODE?: "standalone" | "export";
BUILD_APP?: string; // is building desktop app BUILD_APP?: string; // is building desktop app
@@ -129,22 +134,21 @@ export const getServerSideConfig = () => {
if (customModels) customModels += ","; if (customModels) customModels += ",";
customModels += DEFAULT_MODELS.filter( customModels += DEFAULT_MODELS.filter(
(m) => (m) =>
(m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o") || m.name.startsWith("o1")) && (m.name.startsWith("gpt-4") || m.name.startsWith("chatgpt-4o")) &&
!m.name.startsWith("gpt-4o-mini"), !m.name.startsWith("gpt-4o-mini"),
) )
.map((m) => "-" + m.name) .map((m) => "-" + m.name)
.join(","); .join(",");
if ( if (
(defaultModel.startsWith("gpt-4") || (defaultModel.startsWith("gpt-4") ||
defaultModel.startsWith("chatgpt-4o") || defaultModel.startsWith("chatgpt-4o")) &&
defaultModel.startsWith("o1")) &&
!defaultModel.startsWith("gpt-4o-mini") !defaultModel.startsWith("gpt-4o-mini")
) )
defaultModel = ""; defaultModel = "";
} }
const isStability = !!process.env.STABILITY_API_KEY; const isStability = !!process.env.STABILITY_API_KEY;
const isBedrock = !!process.env.BEDROCK_API_KEY;
const isAzure = !!process.env.AZURE_URL; const isAzure = !!process.env.AZURE_URL;
const isGoogle = !!process.env.GOOGLE_API_KEY; const isGoogle = !!process.env.GOOGLE_API_KEY;
const isAnthropic = !!process.env.ANTHROPIC_API_KEY; const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
@@ -174,6 +178,11 @@ export const getServerSideConfig = () => {
apiKey: getApiKey(process.env.OPENAI_API_KEY), apiKey: getApiKey(process.env.OPENAI_API_KEY),
openaiOrgId: process.env.OPENAI_ORG_ID, openaiOrgId: process.env.OPENAI_ORG_ID,
isBedrock,
awsRegion: process.env.AWS_REGION,
awsAccessKey: process.env.AWS_ACCESS_KEY,
awsSecretKey: process.env.AWS_SECRET_KEY,
isStability, isStability,
stabilityUrl: process.env.STABILITY_URL, stabilityUrl: process.env.STABILITY_URL,
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY), stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),

View File

@@ -12,6 +12,8 @@ export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export const STABILITY_BASE_URL = "https://api.stability.ai"; export const STABILITY_BASE_URL = "https://api.stability.ai";
export const OPENAI_BASE_URL = "https://api.openai.com"; export const OPENAI_BASE_URL = "https://api.openai.com";
export const BEDROCK_BASE_URL =
"https://bedrock-runtime.us-west-2.amazonaws.com";
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/"; export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
@@ -51,6 +53,7 @@ export enum Path {
export enum ApiPath { export enum ApiPath {
Cors = "", Cors = "",
Bedrock = "/api/bedrock",
Azure = "/api/azure", Azure = "/api/azure",
OpenAI = "/api/openai", OpenAI = "/api/openai",
Anthropic = "/api/anthropic", Anthropic = "/api/anthropic",
@@ -118,6 +121,7 @@ export enum ServiceProvider {
Stability = "Stability", Stability = "Stability",
Iflytek = "Iflytek", Iflytek = "Iflytek",
XAI = "XAI", XAI = "XAI",
Bedrock = "Bedrock",
ChatGLM = "ChatGLM", ChatGLM = "ChatGLM",
} }
@@ -132,6 +136,7 @@ export enum GoogleSafetySettingsThreshold {
export enum ModelProvider { export enum ModelProvider {
Stability = "Stability", Stability = "Stability",
Bedrock = "Bedrock",
GPT = "GPT", GPT = "GPT",
GeminiPro = "GeminiPro", GeminiPro = "GeminiPro",
Claude = "Claude", Claude = "Claude",
@@ -232,9 +237,11 @@ export const XAI = {
export const ChatGLM = { export const ChatGLM = {
ExampleEndpoint: CHATGLM_BASE_URL, ExampleEndpoint: CHATGLM_BASE_URL,
ChatPath: "api/paas/v4/chat/completions", ChatPath: "/api/paas/v4/chat/completions",
ImagePath: "api/paas/v4/images/generations", };
VideoPath: "api/paas/v4/videos/generations",
export const Bedrock = {
ChatPath: "converse",
}; };
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
@@ -266,7 +273,6 @@ export const KnowledgeCutOffDate: Record<string, string> = {
"gpt-4o": "2023-10", "gpt-4o": "2023-10",
"gpt-4o-2024-05-13": "2023-10", "gpt-4o-2024-05-13": "2023-10",
"gpt-4o-2024-08-06": "2023-10", "gpt-4o-2024-08-06": "2023-10",
"gpt-4o-2024-11-20": "2023-10",
"chatgpt-4o-latest": "2023-10", "chatgpt-4o-latest": "2023-10",
"gpt-4o-mini": "2023-10", "gpt-4o-mini": "2023-10",
"gpt-4o-mini-2024-07-18": "2023-10", "gpt-4o-mini-2024-07-18": "2023-10",
@@ -293,22 +299,6 @@ export const DEFAULT_TTS_VOICES = [
"shimmer", "shimmer",
]; ];
export const VISION_MODEL_REGEXES = [
/vision/,
/gpt-4o/,
/claude-3/,
/gemini-1\.5/,
/gemini-exp/,
/gemini-2\.0/,
/learnlm/,
/qwen-vl/,
/qwen2-vl/,
/gpt-4-turbo(?!.*preview)/, // Matches "gpt-4-turbo" but not "gpt-4-turbo-preview"
/^dall-e-3$/, // Matches exactly "dall-e-3"
];
export const EXCLUDE_VISION_MODEL_REGEXES = [/claude-3-5-haiku-20241022/];
const openaiModels = [ const openaiModels = [
"gpt-3.5-turbo", "gpt-3.5-turbo",
"gpt-3.5-turbo-1106", "gpt-3.5-turbo-1106",
@@ -322,7 +312,6 @@ const openaiModels = [
"gpt-4o", "gpt-4o",
"gpt-4o-2024-05-13", "gpt-4o-2024-05-13",
"gpt-4o-2024-08-06", "gpt-4o-2024-08-06",
"gpt-4o-2024-11-20",
"chatgpt-4o-latest", "chatgpt-4o-latest",
"gpt-4o-mini", "gpt-4o-mini",
"gpt-4o-mini-2024-07-18", "gpt-4o-mini-2024-07-18",
@@ -334,24 +323,27 @@ const openaiModels = [
"o1-preview", "o1-preview",
]; ];
const bedrockModels = [
// Claude Models
"anthropic.claude-3-haiku-20240307-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-opus-20240229-v1:0",
// Meta Llama Models
"us.meta.llama3-2-11b-instruct-v1:0",
"us.meta.llama3-2-90b-instruct-v1:0",
//Mistral
"mistral.mistral-large-2402-v1:0",
"mistral.mistral-large-2407-v1:0",
];
const googleModels = [ const googleModels = [
"gemini-1.0-pro", // Deprecated on 2/15/2025 "gemini-1.0-pro",
"gemini-1.5-pro-latest", "gemini-1.5-pro-latest",
"gemini-1.5-pro",
"gemini-1.5-pro-002",
"gemini-1.5-pro-exp-0827",
"gemini-1.5-flash-latest", "gemini-1.5-flash-latest",
"gemini-1.5-flash-8b-latest", "gemini-pro-vision",
"gemini-1.5-flash",
"gemini-1.5-flash-8b",
"gemini-1.5-flash-002",
"gemini-1.5-flash-exp-0827",
"learnlm-1.5-pro-experimental",
"gemini-exp-1114",
"gemini-exp-1121",
"gemini-exp-1206",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp-1219",
]; ];
const anthropicModels = [ const anthropicModels = [
@@ -362,11 +354,10 @@ const anthropicModels = [
"claude-3-opus-20240229", "claude-3-opus-20240229",
"claude-3-opus-latest", "claude-3-opus-latest",
"claude-3-haiku-20240307", "claude-3-haiku-20240307",
"claude-3-5-haiku-20241022",
"claude-3-5-haiku-latest",
"claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest",
"claude-3-5-haiku-latest",
]; ];
const baiduModels = [ const baiduModels = [
@@ -433,15 +424,6 @@ const chatglmModels = [
"glm-4-long", "glm-4-long",
"glm-4-flashx", "glm-4-flashx",
"glm-4-flash", "glm-4-flash",
"glm-4v-plus",
"glm-4v",
"glm-4v-flash", // free
"cogview-3-plus",
"cogview-3",
"cogview-3-flash", // free
// 目前无法适配轮询任务
// "cogvideox",
// "cogvideox-flash", // free
]; ];
let seq = 1000; // 内置的模型序号生成器从1000开始 let seq = 1000; // 内置的模型序号生成器从1000开始
@@ -567,6 +549,7 @@ export const DEFAULT_MODELS = [
sorted: 11, sorted: 11,
}, },
})), })),
...chatglmModels.map((name) => ({ ...chatglmModels.map((name) => ({
name, name,
available: true, available: true,
@@ -578,6 +561,18 @@ export const DEFAULT_MODELS = [
sorted: 12, sorted: 12,
}, },
})), })),
...bedrockModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "bedrock",
providerName: "Bedrock",
providerType: "bedrock",
sorted: 13,
},
})),
] as const; ] as const;
export const CHAT_PAGE_SIZE = 15; export const CHAT_PAGE_SIZE = 15;

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 28C4 26.8954 4.89543 26 6 26H10V38H6C4.89543 38 4 37.1046 4 36V28Z" fill="none" />
<path d="M38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
fill="none" />
<path
d="M10 36V24C10 16.268 16.268 10 24 10C31.732 10 38 16.268 38 24V36M10 26H6C4.89543 26 4 26.8954 4 28V36C4 37.1046 4.89543 38 6 38H10V26ZM38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16 32H20L22 26L26 38L28 32H32" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 808 B

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M14.5 8C13.8406 8.37652 13.2062 8.79103 12.6 9.24051C11.5625 10.0097 10.6074 10.8814 9.75 11.8402C6.79377 15.1463 5 19.4891 5 24.2455C5 34.6033 13.5066 43 24 43C34.4934 43 43 34.6033 43 24.2455C43 19.4891 41.2062 15.1463 38.25 11.8402C37.3926 10.8814 36.4375 10.0097 35.4 9.24051C34.7938 8.79103 34.1594 8.37652 33.5 8"
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M24 4V24" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 675 B

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M31 24V11C31 7.13401 27.866 4 24 4C20.134 4 17 7.13401 17 11V24C17 27.866 20.134 31 24 31C27.866 31 31 27.866 31 24Z"
stroke="#d0021b" stroke-width="4" stroke-linejoin="round" />
<path
d="M9 23C9 31.2843 15.7157 38 24 38C25.7532 38 27.4361 37.6992 29 37.1465M39 23C39 25.1333 38.5547 27.1626 37.7519 29"
stroke="#d0021b" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M24 38V44" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M42 42L6 6" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 811 B

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="17" y="4" width="14" height="27" rx="7" fill="none" stroke="#333" stroke-width="4"
stroke-linejoin="round" />
<path d="M9 23C9 31.2843 15.7157 38 24 38C32.2843 38 39 31.2843 39 23" stroke="#333"
stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M24 38V44" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 549 B

View File

@@ -1,200 +0,0 @@
export class AudioHandler {
private context: AudioContext;
private mergeNode: ChannelMergerNode;
private analyserData: Uint8Array;
public analyser: AnalyserNode;
private workletNode: AudioWorkletNode | null = null;
private stream: MediaStream | null = null;
private source: MediaStreamAudioSourceNode | null = null;
private recordBuffer: Int16Array[] = [];
private readonly sampleRate = 24000;
private nextPlayTime: number = 0;
private isPlaying: boolean = false;
private playbackQueue: AudioBufferSourceNode[] = [];
private playBuffer: Int16Array[] = [];
constructor() {
this.context = new AudioContext({ sampleRate: this.sampleRate });
// using ChannelMergerNode to get merged audio data, and then get analyser data.
this.mergeNode = new ChannelMergerNode(this.context, { numberOfInputs: 2 });
this.analyser = new AnalyserNode(this.context, { fftSize: 256 });
this.analyserData = new Uint8Array(this.analyser.frequencyBinCount);
this.mergeNode.connect(this.analyser);
}
getByteFrequencyData() {
this.analyser.getByteFrequencyData(this.analyserData);
return this.analyserData;
}
async initialize() {
await this.context.audioWorklet.addModule("/audio-processor.js");
}
async startRecording(onChunk: (chunk: Uint8Array) => void) {
try {
if (!this.workletNode) {
await this.initialize();
}
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: this.sampleRate,
echoCancellation: true,
noiseSuppression: true,
},
});
await this.context.resume();
this.source = this.context.createMediaStreamSource(this.stream);
this.workletNode = new AudioWorkletNode(
this.context,
"audio-recorder-processor",
);
this.workletNode.port.onmessage = (event) => {
if (event.data.eventType === "audio") {
const float32Data = event.data.audioData;
const int16Data = new Int16Array(float32Data.length);
for (let i = 0; i < float32Data.length; i++) {
const s = Math.max(-1, Math.min(1, float32Data[i]));
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
const uint8Data = new Uint8Array(int16Data.buffer);
onChunk(uint8Data);
// save recordBuffer
// @ts-ignore
this.recordBuffer.push.apply(this.recordBuffer, int16Data);
}
};
this.source.connect(this.workletNode);
this.source.connect(this.mergeNode, 0, 0);
this.workletNode.connect(this.context.destination);
this.workletNode.port.postMessage({ command: "START_RECORDING" });
} catch (error) {
console.error("Error starting recording:", error);
throw error;
}
}
stopRecording() {
if (!this.workletNode || !this.source || !this.stream) {
throw new Error("Recording not started");
}
this.workletNode.port.postMessage({ command: "STOP_RECORDING" });
this.workletNode.disconnect();
this.source.disconnect();
this.stream.getTracks().forEach((track) => track.stop());
}
startStreamingPlayback() {
this.isPlaying = true;
this.nextPlayTime = this.context.currentTime;
}
stopStreamingPlayback() {
this.isPlaying = false;
this.playbackQueue.forEach((source) => source.stop());
this.playbackQueue = [];
this.playBuffer = [];
}
playChunk(chunk: Uint8Array) {
if (!this.isPlaying) return;
const int16Data = new Int16Array(chunk.buffer);
// @ts-ignore
this.playBuffer.push.apply(this.playBuffer, int16Data); // save playBuffer
const float32Data = new Float32Array(int16Data.length);
for (let i = 0; i < int16Data.length; i++) {
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7fff);
}
const audioBuffer = this.context.createBuffer(
1,
float32Data.length,
this.sampleRate,
);
audioBuffer.getChannelData(0).set(float32Data);
const source = this.context.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.context.destination);
source.connect(this.mergeNode, 0, 1);
const chunkDuration = audioBuffer.length / this.sampleRate;
source.start(this.nextPlayTime);
this.playbackQueue.push(source);
source.onended = () => {
const index = this.playbackQueue.indexOf(source);
if (index > -1) {
this.playbackQueue.splice(index, 1);
}
};
this.nextPlayTime += chunkDuration;
if (this.nextPlayTime < this.context.currentTime) {
this.nextPlayTime = this.context.currentTime;
}
}
_saveData(data: Int16Array, bytesPerSample = 16): Blob {
const headerLength = 44;
const numberOfChannels = 1;
const byteLength = data.buffer.byteLength;
const header = new Uint8Array(headerLength);
const view = new DataView(header.buffer);
view.setUint32(0, 1380533830, false); // RIFF identifier 'RIFF'
view.setUint32(4, 36 + byteLength, true); // file length minus RIFF identifier length and file description length
view.setUint32(8, 1463899717, false); // RIFF type 'WAVE'
view.setUint32(12, 1718449184, false); // format chunk identifier 'fmt '
view.setUint32(16, 16, true); // format chunk length
view.setUint16(20, 1, true); // sample format (raw)
view.setUint16(22, numberOfChannels, true); // channel count
view.setUint32(24, this.sampleRate, true); // sample rate
view.setUint32(28, this.sampleRate * 4, true); // byte rate (sample rate * block align)
view.setUint16(32, numberOfChannels * 2, true); // block align (channel count * bytes per sample)
view.setUint16(34, bytesPerSample, true); // bits per sample
view.setUint32(36, 1684108385, false); // data chunk identifier 'data'
view.setUint32(40, byteLength, true); // data chunk length
// using data.buffer, so no need to setUint16 to view.
return new Blob([view, data.buffer], { type: "audio/mpeg" });
}
savePlayFile() {
// @ts-ignore
return this._saveData(new Int16Array(this.playBuffer));
}
saveRecordFile(
audioStartMillis: number | undefined,
audioEndMillis: number | undefined,
) {
const startIndex = audioStartMillis
? Math.floor((audioStartMillis * this.sampleRate) / 1000)
: 0;
const endIndex = audioEndMillis
? Math.floor((audioEndMillis * this.sampleRate) / 1000)
: this.recordBuffer.length;
return this._saveData(
// @ts-ignore
new Int16Array(this.recordBuffer.slice(startIndex, endIndex)),
);
}
async close() {
this.recordBuffer = [];
this.workletNode?.disconnect();
this.source?.disconnect();
this.stream?.getTracks().forEach((track) => track.stop());
await this.context.close();
}
}

View File

@@ -81,6 +81,7 @@ const cn = {
Clear: "清除聊天", Clear: "清除聊天",
Settings: "对话设置", Settings: "对话设置",
UploadImage: "上传图片", UploadImage: "上传图片",
UploadDocument: "上传文档",
}, },
Rename: "重命名对话", Rename: "重命名对话",
Typing: "正在输入…", Typing: "正在输入…",
@@ -342,6 +343,32 @@ const cn = {
SubTitle: "除默认地址外,必须包含 http(s)://", SubTitle: "除默认地址外,必须包含 http(s)://",
}, },
}, },
Bedrock: {
Region: {
Title: "AWS Region",
SubTitle: "The AWS region where Bedrock service is located",
Placeholder: "us-west-2",
},
AccessKey: {
Title: "AWS Access Key ID",
SubTitle: "Your AWS access key ID for Bedrock service",
Placeholder: "AKIA...",
},
SecretKey: {
Title: "AWS Secret Access Key",
SubTitle: "Your AWS secret access key for Bedrock service",
Placeholder: "****",
},
SessionToken: {
Title: "AWS Session Token (Optional)",
SubTitle: "Your AWS session token if using temporary credentials",
Placeholder: "Optional session token",
},
Endpoint: {
Title: "AWS Bedrock Endpoint",
SubTitle: "Custom endpoint for AWS Bedrock API. Default: ",
},
},
Azure: { Azure: {
ApiKey: { ApiKey: {
Title: "接口密钥", Title: "接口密钥",
@@ -562,39 +589,6 @@ const cn = {
SubTitle: "生成语音的速度", SubTitle: "生成语音的速度",
}, },
}, },
Realtime: {
Enable: {
Title: "实时聊天",
SubTitle: "开启实时聊天功能",
},
Provider: {
Title: "模型服务商",
SubTitle: "切换不同的服务商",
},
Model: {
Title: "模型",
SubTitle: "选择一个模型",
},
ApiKey: {
Title: "API Key",
SubTitle: "API Key",
Placeholder: "API Key",
},
Azure: {
Endpoint: {
Title: "接口地址",
SubTitle: "接口地址",
},
Deployment: {
Title: "部署名称",
SubTitle: "部署名称",
},
},
Temperature: {
Title: "随机性 (temperature)",
SubTitle: "值越大,回复越随机",
},
},
}, },
Store: { Store: {
DefaultTopic: "新的聊天", DefaultTopic: "新的聊天",

View File

@@ -82,6 +82,7 @@ const en: LocaleType = {
Clear: "Clear Context", Clear: "Clear Context",
Settings: "Settings", Settings: "Settings",
UploadImage: "Upload Images", UploadImage: "Upload Images",
UploadDocument: "Upload Documents",
}, },
Rename: "Rename Chat", Rename: "Rename Chat",
Typing: "Typing…", Typing: "Typing…",
@@ -346,6 +347,32 @@ const en: LocaleType = {
SubTitle: "Must start with http(s):// or use /api/openai as default", SubTitle: "Must start with http(s):// or use /api/openai as default",
}, },
}, },
Bedrock: {
Region: {
Title: "AWS Region",
SubTitle: "The AWS region where Bedrock service is located",
Placeholder: "us-west-2",
},
AccessKey: {
Title: "AWS Access Key ID",
SubTitle: "Your AWS access key ID for Bedrock service",
Placeholder: "AKIA...",
},
SecretKey: {
Title: "AWS Secret Access Key",
SubTitle: "Your AWS secret access key for Bedrock service",
Placeholder: "****",
},
SessionToken: {
Title: "AWS Session Token (Optional)",
SubTitle: "Your AWS session token if using temporary credentials",
Placeholder: "Optional session token",
},
Endpoint: {
Title: "AWS Bedrock Endpoint",
SubTitle: "Custom endpoint for AWS Bedrock API. Default: ",
},
},
Azure: { Azure: {
ApiKey: { ApiKey: {
Title: "Azure Api Key", Title: "Azure Api Key",
@@ -570,39 +597,6 @@ const en: LocaleType = {
}, },
Engine: "TTS Engine", Engine: "TTS Engine",
}, },
Realtime: {
Enable: {
Title: "Realtime Chat",
SubTitle: "Enable realtime chat feature",
},
Provider: {
Title: "Model Provider",
SubTitle: "Switch between different providers",
},
Model: {
Title: "Model",
SubTitle: "Select a model",
},
ApiKey: {
Title: "API Key",
SubTitle: "API Key",
Placeholder: "API Key",
},
Azure: {
Endpoint: {
Title: "Endpoint",
SubTitle: "Endpoint",
},
Deployment: {
Title: "Deployment Name",
SubTitle: "Deployment Name",
},
},
Temperature: {
Title: "Randomness (temperature)",
SubTitle: "Higher values result in more random responses",
},
},
}, },
Store: { Store: {
DefaultTopic: "New Conversation", DefaultTopic: "New Conversation",

View File

@@ -3,7 +3,7 @@ import { BuiltinMask } from "./typing";
export const CN_MASKS: BuiltinMask[] = [ export const CN_MASKS: BuiltinMask[] = [
{ {
avatar: "1f5bc-fe0f", avatar: "1f5bc-fe0f",
name: "AI文生图", name: "以文搜图",
context: [ context: [
{ {
id: "text-to-pic-0", id: "text-to-pic-0",
@@ -28,7 +28,7 @@ export const CN_MASKS: BuiltinMask[] = [
id: "text-to-pic-3", id: "text-to-pic-3",
role: "system", role: "system",
content: content:
"助手善于判断用户意图当确定需要提供图片时助手会变得沉默寡言只使用以下格式输出markdown图片![description](https://image.pollinations.ai/prompt/description?nologo=true)因为这个语法可以自动按照提示生成并渲染图片。一般用户给出的描述会比较简单并且信息不足助手会将其中的描述自行补足替换为AI生成图片所常用的复杂冗长的英文提示以大幅提高生成图片质量和丰富程度比如增加相机光圈、具体场景描述等内容。助手会避免用代码块或原始块包围markdown标记因为那样只会渲染出代码块或原始块而不是图片。url中的空格等符号需要转义。", "助手善于判断用户意图当确定需要提供图片时助手会变得沉默寡言只使用以下格式输出markdown图片![描述](https://image.pollinations.ai/prompt/描述)因为这个语法可以自动按照提示生成并渲染图片。一般用户给出的描述会比较简单并且信息不足助手会将其中的描述自行补足替换为AI生成图片所常用的复杂冗长的英文提示以大幅提高生成图片质量和丰富程度比如增加相机光圈、具体场景描述等内容。助手会避免用代码块或原始块包围markdown标记因为那样只会渲染出代码块或原始块而不是图片。",
date: "", date: "",
}, },
], ],

View File

@@ -4,6 +4,7 @@ import {
StoreKey, StoreKey,
ApiPath, ApiPath,
OPENAI_BASE_URL, OPENAI_BASE_URL,
BEDROCK_BASE_URL,
ANTHROPIC_BASE_URL, ANTHROPIC_BASE_URL,
GEMINI_BASE_URL, GEMINI_BASE_URL,
BAIDU_BASE_URL, BAIDU_BASE_URL,
@@ -21,13 +22,13 @@ import { getClientConfig } from "../config/client";
import { createPersistStore } from "../utils/store"; import { createPersistStore } from "../utils/store";
import { ensure } from "../utils/clone"; import { ensure } from "../utils/clone";
import { DEFAULT_CONFIG } from "./config"; import { DEFAULT_CONFIG } from "./config";
import { getModelProvider } from "../utils/model";
let fetchState = 0; // 0 not fetch, 1 fetching, 2 done let fetchState = 0; // 0 not fetch, 1 fetching, 2 done
const isApp = getClientConfig()?.buildMode === "export"; const isApp = getClientConfig()?.buildMode === "export";
const DEFAULT_OPENAI_URL = isApp ? OPENAI_BASE_URL : ApiPath.OpenAI; const DEFAULT_OPENAI_URL = isApp ? OPENAI_BASE_URL : ApiPath.OpenAI;
const DEFAULT_BEDROCK_URL = isApp ? BEDROCK_BASE_URL : ApiPath.Bedrock;
const DEFAULT_GOOGLE_URL = isApp ? GEMINI_BASE_URL : ApiPath.Google; const DEFAULT_GOOGLE_URL = isApp ? GEMINI_BASE_URL : ApiPath.Google;
@@ -61,6 +62,13 @@ const DEFAULT_ACCESS_STATE = {
openaiUrl: DEFAULT_OPENAI_URL, openaiUrl: DEFAULT_OPENAI_URL,
openaiApiKey: "", openaiApiKey: "",
// bedrock
awsRegion: "",
awsAccessKey: "",
awsSecretKey: "",
awsSessionToken: "",
awsCognitoUser: false,
// azure // azure
azureUrl: "", azureUrl: "",
azureApiKey: "", azureApiKey: "",
@@ -149,6 +157,10 @@ export const useAccessStore = createPersistStore(
return ensure(get(), ["openaiApiKey"]); return ensure(get(), ["openaiApiKey"]);
}, },
isValidBedrock() {
return ensure(get(), ["awsAccessKey", "awsSecretKey", "awsRegion"]);
},
isValidAzure() { isValidAzure() {
return ensure(get(), ["azureUrl", "azureApiKey", "azureApiVersion"]); return ensure(get(), ["azureUrl", "azureApiKey", "azureApiVersion"]);
}, },
@@ -198,6 +210,7 @@ export const useAccessStore = createPersistStore(
// has token or has code or disabled access control // has token or has code or disabled access control
return ( return (
this.isValidOpenAI() || this.isValidOpenAI() ||
this.isValidBedrock() ||
this.isValidAzure() || this.isValidAzure() ||
this.isValidGoogle() || this.isValidGoogle() ||
this.isValidAnthropic() || this.isValidAnthropic() ||
@@ -227,9 +240,9 @@ export const useAccessStore = createPersistStore(
.then((res) => { .then((res) => {
const defaultModel = res.defaultModel ?? ""; const defaultModel = res.defaultModel ?? "";
if (defaultModel !== "") { if (defaultModel !== "") {
const [model, providerName] = getModelProvider(defaultModel); const [model, providerName] = defaultModel.split("@");
DEFAULT_CONFIG.modelConfig.model = model; DEFAULT_CONFIG.modelConfig.model = model;
DEFAULT_CONFIG.modelConfig.providerName = providerName as any; DEFAULT_CONFIG.modelConfig.providerName = providerName;
} }
return res; return res;

View File

@@ -52,7 +52,6 @@ export type ChatMessage = RequestMessage & {
id: string; id: string;
model?: ModelType; model?: ModelType;
tools?: ChatMessageTool[]; tools?: ChatMessageTool[];
audio_url?: string;
}; };
export function createMessage(override: Partial<ChatMessage>): ChatMessage { export function createMessage(override: Partial<ChatMessage>): ChatMessage {
@@ -358,7 +357,7 @@ export const useChatStore = createPersistStore(
session.messages = session.messages.concat(); session.messages = session.messages.concat();
session.lastUpdate = Date.now(); session.lastUpdate = Date.now();
}); });
get().updateStat(message, targetSession); get().updateStat(message);
get().summarizeSession(false, targetSession); get().summarizeSession(false, targetSession);
}, },
@@ -397,10 +396,10 @@ export const useChatStore = createPersistStore(
// get recent messages // get recent messages
const recentMessages = get().getMessagesWithMemory(); const recentMessages = get().getMessagesWithMemory();
const sendMessages = recentMessages.concat(userMessage); const sendMessages = recentMessages.concat(userMessage);
const messageIndex = session.messages.length + 1; const messageIndex = get().currentSession().messages.length + 1;
// save user's and bot's message // save user's and bot's message
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
const savedUserMessage = { const savedUserMessage = {
...userMessage, ...userMessage,
content: mContent, content: mContent,
@@ -421,7 +420,7 @@ export const useChatStore = createPersistStore(
if (message) { if (message) {
botMessage.content = message; botMessage.content = message;
} }
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
}, },
@@ -429,14 +428,13 @@ export const useChatStore = createPersistStore(
botMessage.streaming = false; botMessage.streaming = false;
if (message) { if (message) {
botMessage.content = message; botMessage.content = message;
botMessage.date = new Date().toLocaleString();
get().onNewMessage(botMessage, session); get().onNewMessage(botMessage, session);
} }
ChatControllerPool.remove(session.id, botMessage.id); ChatControllerPool.remove(session.id, botMessage.id);
}, },
onBeforeTool(tool: ChatMessageTool) { onBeforeTool(tool: ChatMessageTool) {
(botMessage.tools = botMessage?.tools || []).push(tool); (botMessage.tools = botMessage?.tools || []).push(tool);
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
}, },
@@ -446,7 +444,7 @@ export const useChatStore = createPersistStore(
tools[i] = { ...tool }; tools[i] = { ...tool };
} }
}); });
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
}, },
@@ -461,7 +459,7 @@ export const useChatStore = createPersistStore(
botMessage.streaming = false; botMessage.streaming = false;
userMessage.isError = !isAborted; userMessage.isError = !isAborted;
botMessage.isError = !isAborted; botMessage.isError = !isAborted;
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
session.messages = session.messages.concat(); session.messages = session.messages.concat();
}); });
ChatControllerPool.remove( ChatControllerPool.remove(
@@ -593,8 +591,8 @@ export const useChatStore = createPersistStore(
set(() => ({ sessions })); set(() => ({ sessions }));
}, },
resetSession(session: ChatSession) { resetSession() {
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
session.messages = []; session.messages = [];
session.memoryPrompt = ""; session.memoryPrompt = "";
}); });
@@ -738,12 +736,19 @@ export const useChatStore = createPersistStore(
} }
}, },
updateStat(message: ChatMessage, session: ChatSession) { updateStat(message: ChatMessage) {
get().updateTargetSession(session, (session) => { get().updateCurrentSession((session) => {
session.stat.charCount += message.content.length; session.stat.charCount += message.content.length;
// TODO: should update chat count and word count // TODO: should update chat count and word count
}); });
}, },
updateCurrentSession(updater: (session: ChatSession) => void) {
const sessions = get().sessions;
const index = get().currentSessionIndex;
updater(sessions[index]);
set(() => ({ sessions }));
},
updateTargetSession( updateTargetSession(
targetSession: ChatSession, targetSession: ChatSession,
updater: (session: ChatSession) => void, updater: (session: ChatSession) => void,

View File

@@ -1,5 +1,5 @@
import { LLMModel } from "../client/api"; import { LLMModel } from "../client/api";
import { DalleQuality, DalleStyle, ModelSize } from "../typing"; import { DalleSize, DalleQuality, DalleStyle } from "../typing";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { import {
DEFAULT_INPUT_TEMPLATE, DEFAULT_INPUT_TEMPLATE,
@@ -15,7 +15,6 @@ import {
ServiceProvider, ServiceProvider,
} from "../constant"; } from "../constant";
import { createPersistStore } from "../utils/store"; import { createPersistStore } from "../utils/store";
import type { Voice } from "rt-client";
export type ModelType = (typeof DEFAULT_MODELS)[number]["name"]; export type ModelType = (typeof DEFAULT_MODELS)[number]["name"];
export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number]; export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number];
@@ -78,7 +77,7 @@ export const DEFAULT_CONFIG = {
compressProviderName: "", compressProviderName: "",
enableInjectSystemPrompts: true, enableInjectSystemPrompts: true,
template: config?.template ?? DEFAULT_INPUT_TEMPLATE, template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
size: "1024x1024" as ModelSize, size: "1024x1024" as DalleSize,
quality: "standard" as DalleQuality, quality: "standard" as DalleQuality,
style: "vivid" as DalleStyle, style: "vivid" as DalleStyle,
}, },
@@ -91,26 +90,12 @@ export const DEFAULT_CONFIG = {
voice: DEFAULT_TTS_VOICE, voice: DEFAULT_TTS_VOICE,
speed: 1.0, speed: 1.0,
}, },
realtimeConfig: {
enable: false,
provider: "OpenAI" as ServiceProvider,
model: "gpt-4o-realtime-preview-2024-10-01",
apiKey: "",
azure: {
endpoint: "",
deployment: "",
},
temperature: 0.9,
voice: "alloy" as Voice,
},
}; };
export type ChatConfig = typeof DEFAULT_CONFIG; export type ChatConfig = typeof DEFAULT_CONFIG;
export type ModelConfig = ChatConfig["modelConfig"]; export type ModelConfig = ChatConfig["modelConfig"];
export type TTSConfig = ChatConfig["ttsConfig"]; export type TTSConfig = ChatConfig["ttsConfig"];
export type RealtimeConfig = ChatConfig["realtimeConfig"];
export function limitNumber( export function limitNumber(
x: number, x: number,

View File

@@ -11,14 +11,3 @@ export interface RequestMessage {
export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792"; export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792";
export type DalleQuality = "standard" | "hd"; export type DalleQuality = "standard" | "hd";
export type DalleStyle = "vivid" | "natural"; export type DalleStyle = "vivid" | "natural";
export type ModelSize =
| "1024x1024"
| "1792x1024"
| "1024x1792"
| "768x1344"
| "864x1152"
| "1344x768"
| "1152x864"
| "1440x720"
| "720x1440";

View File

@@ -5,9 +5,6 @@ import { RequestMessage } from "./client/api";
import { ServiceProvider } from "./constant"; import { ServiceProvider } from "./constant";
// import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http"; // import { fetch as tauriFetch, ResponseType } from "@tauri-apps/api/http";
import { fetch as tauriStreamFetch } from "./utils/stream"; import { fetch as tauriStreamFetch } from "./utils/stream";
import { VISION_MODEL_REGEXES, EXCLUDE_VISION_MODEL_REGEXES } from "./constant";
import { getClientConfig } from "./config/client";
import { ModelSize } from "./typing";
export function trimTopic(topic: string) { export function trimTopic(topic: string) {
// Fix an issue where double quotes still show in the Indonesian language // Fix an issue where double quotes still show in the Indonesian language
@@ -255,16 +252,23 @@ export function getMessageImages(message: RequestMessage): string[] {
} }
export function isVisionModel(model: string) { export function isVisionModel(model: string) {
const clientConfig = getClientConfig(); // Note: This is a better way using the TypeScript feature instead of `&&` or `||` (ts v5.5.0-dev.20240314 I've been using)
const envVisionModels = clientConfig?.visionModels
?.split(",") const visionKeywords = [
.map((m) => m.trim()); "vision",
if (envVisionModels?.includes(model)) { "claude-3",
return true; "gemini-1.5-pro",
} "gemini-1.5-flash",
"gpt-4o",
"gpt-4o-mini",
];
const isGpt4Turbo =
model.includes("gpt-4-turbo") && !model.includes("preview");
return ( return (
!EXCLUDE_VISION_MODEL_REGEXES.some((regex) => regex.test(model)) && visionKeywords.some((keyword) => model.includes(keyword)) ||
VISION_MODEL_REGEXES.some((regex) => regex.test(model)) isGpt4Turbo ||
isDalle3(model)
); );
} }
@@ -272,28 +276,6 @@ export function isDalle3(model: string) {
return "dall-e-3" === model; return "dall-e-3" === model;
} }
export function getModelSizes(model: string): ModelSize[] {
if (isDalle3(model)) {
return ["1024x1024", "1792x1024", "1024x1792"];
}
if (model.toLowerCase().includes("cogview")) {
return [
"1024x1024",
"768x1344",
"864x1152",
"1344x768",
"1152x864",
"1440x720",
"720x1440",
];
}
return [];
}
export function supportsCustomSize(model: string): boolean {
return getModelSizes(model).length > 0;
}
export function showPlugins(provider: ServiceProvider, model: string) { export function showPlugins(provider: ServiceProvider, model: string) {
if ( if (
provider == ServiceProvider.OpenAI || provider == ServiceProvider.OpenAI ||
@@ -306,6 +288,9 @@ export function showPlugins(provider: ServiceProvider, model: string) {
if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) { if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
return true; return true;
} }
if (provider == ServiceProvider.Bedrock && model.includes("claude-3")) {
return true;
}
if (provider == ServiceProvider.Google && !model.includes("vision")) { if (provider == ServiceProvider.Google && !model.includes("vision")) {
return true; return true;
} }

View File

@@ -138,7 +138,7 @@ export function uploadImage(file: Blob): Promise<string> {
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
// console.log("res", res); console.log("res", res);
if (res?.code == 0 && res?.data) { if (res?.code == 0 && res?.data) {
return res?.data; return res?.data;
} }

View File

@@ -37,17 +37,6 @@ const sortModelTable = (models: ReturnType<typeof collectModels>) =>
} }
}); });
/**
* get model name and provider from a formatted string,
* e.g. `gpt-4@OpenAi` or `claude-3-5-sonnet@20240620@Google`
* @param modelWithProvider model name with provider separated by last `@` char,
* @returns [model, provider] tuple, if no `@` char found, provider is undefined
*/
export function getModelProvider(modelWithProvider: string): [string, string?] {
const [model, provider] = modelWithProvider.split(/@(?!.*@)/);
return [model, provider];
}
export function collectModelTable( export function collectModelTable(
models: readonly LLMModel[], models: readonly LLMModel[],
customModels: string, customModels: string,
@@ -90,10 +79,10 @@ export function collectModelTable(
); );
} else { } else {
// 1. find model by name, and set available value // 1. find model by name, and set available value
const [customModelName, customProviderName] = getModelProvider(name); const [customModelName, customProviderName] = name.split("@");
let count = 0; let count = 0;
for (const fullName in modelTable) { for (const fullName in modelTable) {
const [modelName, providerName] = getModelProvider(fullName); const [modelName, providerName] = fullName.split("@");
if ( if (
customModelName == modelName && customModelName == modelName &&
(customProviderName === undefined || (customProviderName === undefined ||
@@ -113,7 +102,7 @@ export function collectModelTable(
} }
// 2. if model not exists, create new model with available value // 2. if model not exists, create new model with available value
if (count === 0) { if (count === 0) {
let [customModelName, customProviderName] = getModelProvider(name); let [customModelName, customProviderName] = name.split("@");
const provider = customProvider( const provider = customProvider(
customProviderName || customModelName, customProviderName || customModelName,
); );
@@ -150,7 +139,7 @@ export function collectModelTableWithDefaultModel(
for (const key of Object.keys(modelTable)) { for (const key of Object.keys(modelTable)) {
if ( if (
modelTable[key].available && modelTable[key].available &&
getModelProvider(key)[0] == defaultModel key.split("@").shift() == defaultModel
) { ) {
modelTable[key].isDefault = true; modelTable[key].isDefault = true;
break; break;

View File

@@ -27,7 +27,6 @@
"@vercel/analytics": "^0.1.11", "@vercel/analytics": "^0.1.11",
"@vercel/speed-insights": "^1.0.2", "@vercel/speed-insights": "^1.0.2",
"axios": "^1.7.5", "axios": "^1.7.5",
"clsx": "^2.1.1",
"emoji-picker-react": "^4.9.2", "emoji-picker-react": "^4.9.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
@@ -53,14 +52,14 @@
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"use-debounce": "^9.0.4", "use-debounce": "^9.0.4",
"zustand": "^4.3.8", "zustand": "^4.3.8",
"rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz" "@aws-sdk/client-bedrock-runtime": "^3.679.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "1.5.11", "@tauri-apps/cli": "1.5.11",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.2",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",

View File

@@ -1,48 +0,0 @@
// @ts-nocheck
class AudioRecorderProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.isRecording = false;
this.bufferSize = 2400; // 100ms at 24kHz
this.currentBuffer = [];
this.port.onmessage = (event) => {
if (event.data.command === "START_RECORDING") {
this.isRecording = true;
} else if (event.data.command === "STOP_RECORDING") {
this.isRecording = false;
if (this.currentBuffer.length > 0) {
this.sendBuffer();
}
}
};
}
sendBuffer() {
if (this.currentBuffer.length > 0) {
const audioData = new Float32Array(this.currentBuffer);
this.port.postMessage({
eventType: "audio",
audioData: audioData,
});
this.currentBuffer = [];
}
}
process(inputs) {
const input = inputs[0];
if (input.length > 0 && this.isRecording) {
const audioData = input[0];
this.currentBuffer.push(...audioData);
if (this.currentBuffer.length >= this.bufferSize) {
this.sendBuffer();
}
}
return true;
}
}
registerProcessor("audio-recorder-processor", AudioRecorderProcessor);

View File

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

View File

@@ -1,31 +0,0 @@
import { getModelProvider } from "../app/utils/model";
describe("getModelProvider", () => {
test("should return model and provider when input contains '@'", () => {
const input = "model@provider";
const [model, provider] = getModelProvider(input);
expect(model).toBe("model");
expect(provider).toBe("provider");
});
test("should return model and undefined provider when input does not contain '@'", () => {
const input = "model";
const [model, provider] = getModelProvider(input);
expect(model).toBe("model");
expect(provider).toBeUndefined();
});
test("should handle multiple '@' characters correctly", () => {
const input = "model@provider@extra";
const [model, provider] = getModelProvider(input);
expect(model).toBe("model@provider");
expect(provider).toBe("extra");
});
test("should return empty strings when input is empty", () => {
const input = "";
const [model, provider] = getModelProvider(input);
expect(model).toBe("");
expect(provider).toBeUndefined();
});
});

View File

@@ -1,67 +0,0 @@
import { isVisionModel } from "../app/utils";
describe("isVisionModel", () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
test("should identify vision models using regex patterns", () => {
const visionModels = [
"gpt-4-vision",
"claude-3-opus",
"gemini-1.5-pro",
"gemini-2.0",
"gemini-exp-vision",
"learnlm-vision",
"qwen-vl-max",
"qwen2-vl-max",
"gpt-4-turbo",
"dall-e-3",
];
visionModels.forEach((model) => {
expect(isVisionModel(model)).toBe(true);
});
});
test("should exclude specific models", () => {
expect(isVisionModel("claude-3-5-haiku-20241022")).toBe(false);
});
test("should not identify non-vision models", () => {
const nonVisionModels = [
"gpt-3.5-turbo",
"gpt-4-turbo-preview",
"claude-2",
"regular-model",
];
nonVisionModels.forEach((model) => {
expect(isVisionModel(model)).toBe(false);
});
});
test("should identify models from VISION_MODELS env var", () => {
process.env.VISION_MODELS = "custom-vision-model,another-vision-model";
expect(isVisionModel("custom-vision-model")).toBe(true);
expect(isVisionModel("another-vision-model")).toBe(true);
expect(isVisionModel("unrelated-model")).toBe(false);
});
test("should handle empty or missing VISION_MODELS", () => {
process.env.VISION_MODELS = "";
expect(isVisionModel("unrelated-model")).toBe(false);
delete process.env.VISION_MODELS;
expect(isVisionModel("unrelated-model")).toBe(false);
expect(isVisionModel("gpt-4-vision")).toBe(true);
});
});

View File

@@ -2114,10 +2114,10 @@
lz-string "^1.5.0" lz-string "^1.5.0"
pretty-format "^27.0.2" pretty-format "^27.0.2"
"@testing-library/jest-dom@^6.6.3": "@testing-library/jest-dom@^6.6.2":
version "6.6.3" version "6.6.2"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz#26ba906cf928c0f8172e182c6fe214eb4f9f2bd2" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.2.tgz#8186aa9a07263adef9cc5a59a4772db8c31f4a5b"
integrity sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA== integrity sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw==
dependencies: dependencies:
"@adobe/css-tools" "^4.4.0" "@adobe/css-tools" "^4.4.0"
aria-query "^5.0.0" aria-query "^5.0.0"
@@ -2127,10 +2127,10 @@
lodash "^4.17.21" lodash "^4.17.21"
redent "^3.0.0" redent "^3.0.0"
"@testing-library/react@^16.1.0": "@testing-library/react@^16.0.1":
version "16.1.0" version "16.0.1"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.1.0.tgz#aa0c61398bac82eaf89776967e97de41ac742d71" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.1.tgz#29c0ee878d672703f5e7579f239005e4e0faa875"
integrity sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg== integrity sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
@@ -3189,11 +3189,6 @@ cliui@^8.0.1:
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
wrap-ansi "^7.0.0" wrap-ansi "^7.0.0"
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
co@^4.6.0: co@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.npmmirror.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" resolved "https://registry.npmmirror.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -7455,12 +7450,6 @@ robust-predicates@^3.0.0:
resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a" resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a"
integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g== integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==
"rt-client@https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz":
version "0.5.0"
resolved "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz#abf2e9a850201e3571b8d36830f77bc52af3de9b"
dependencies:
ws "^8.18.0"
run-parallel@^1.1.9: run-parallel@^1.1.9:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -8504,9 +8493,9 @@ write-file-atomic@^4.0.2:
imurmurhash "^0.1.4" imurmurhash "^0.1.4"
signal-exit "^3.0.7" signal-exit "^3.0.7"
ws@^8.11.0, ws@^8.18.0: ws@^8.11.0:
version "8.18.0" version "8.18.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" resolved "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
xml-name-validator@^4.0.0: xml-name-validator@^4.0.0: