Merge branch 'main' into feat-i18n

This commit is contained in:
Lloyd Zhou 2024-08-16 16:13:13 +08:00 committed by GitHub
commit e033bef544
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 5018 additions and 404 deletions

2
.gitignore vendored
View File

@ -44,3 +44,5 @@ dev
*.key *.key
*.key.pub *.key.pub
masks.json

View File

@ -88,10 +88,15 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
- [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) - [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
- [x] Desktop App with tauri - [x] Desktop App with tauri
- [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc. - [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) - [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 artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [x] artifacts
- [ ] network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [ ] local knowledge base
## What's New ## What's New
- 🚀 v2.14.0 Now supports Artifacts & SD
- 🚀 v2.10.1 support Google Gemini Pro model. - 🚀 v2.10.1 support Google Gemini Pro model.
- 🚀 v2.9.11 you can use azure endpoint now. - 🚀 v2.9.11 you can use azure endpoint now.
- 🚀 v2.8 now we have a client that runs across all platforms! - 🚀 v2.8 now we have a client that runs across all platforms!
@ -120,15 +125,21 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
- [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741) - [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
- [x] 使用 tauri 打包桌面应用 - [x] 使用 tauri 打包桌面应用
- [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm) - [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165) - [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
- [x] 插件机制,支持 artifacts联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [x] artifacts
- [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
- [ ] 本地知识库
## 最新动态 ## 最新动态
- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
- 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。 - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
- 💡 想要更方便地随时随地使用本项目可以试下这款桌面插件https://github.com/mushan0x0/AI0x0.com - 💡 想要更方便地随时随地使用本项目可以试下这款桌面插件https://github.com/mushan0x0/AI0x0.com
- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
## Get Started ## Get Started
@ -271,6 +282,18 @@ Alibaba Cloud Api Key.
Alibaba Cloud Api Url. Alibaba Cloud Api Url.
### `IFLYTEK_URL` (Optional)
iflytek Api Url.
### `IFLYTEK_API_KEY` (Optional)
iflytek Api Key.
### `IFLYTEK_API_SECRET` (Optional)
iflytek Api Secret.
### `HIDE_USER_API_KEY` (optional) ### `HIDE_USER_API_KEY` (optional)
> Default: Empty > Default: Empty
@ -326,6 +349,14 @@ You can use this option if you want to increase the number of webdav service add
Customize the default template used to initialize the User Input Preprocessing configuration item in Settings. Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
### `STABILITY_API_KEY` (optional)
Stability API key.
### `STABILITY_URL` (optional)
Customize Stability API url.
## Requirements ## Requirements
NodeJS >= 18, Docker >= 20 NodeJS >= 18, Docker >= 20

View File

@ -172,6 +172,20 @@ ByteDance Api Url.
阿里云千问Api Url. 阿里云千问Api Url.
### `IFLYTEK_URL` (可选)
讯飞星火Api Url.
### `IFLYTEK_API_KEY` (可选)
讯飞星火Api Key.
### `IFLYTEK_API_SECRET` (可选)
讯飞星火Api Secret.
### `HIDE_USER_API_KEY` (可选) ### `HIDE_USER_API_KEY` (可选)
如果你不想让用户自行填入 API Key将此环境变量设置为 1 即可。 如果你不想让用户自行填入 API Key将此环境变量设置为 1 即可。
@ -218,6 +232,15 @@ ByteDance Api Url.
自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项 自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项
### `STABILITY_API_KEY` (optional)
Stability API密钥
### `STABILITY_URL` (optional)
自定义的Stability API请求地址
## 开发 ## 开发
点击下方按钮,开始二次开发: 点击下方按钮,开始二次开发:

View File

@ -0,0 +1,66 @@
import { ApiPath } from "@/app/constant";
import { NextRequest, NextResponse } from "next/server";
import { handle as openaiHandler } from "../../openai";
import { handle as azureHandler } from "../../azure";
import { handle as googleHandler } from "../../google";
import { handle as anthropicHandler } from "../../anthropic";
import { handle as baiduHandler } from "../../baidu";
import { handle as bytedanceHandler } from "../../bytedance";
import { handle as alibabaHandler } from "../../alibaba";
import { handle as moonshotHandler } from "../../moonshot";
import { handle as stabilityHandler } from "../../stability";
import { handle as iflytekHandler } from "../../iflytek";
async function handle(
req: NextRequest,
{ params }: { params: { provider: string; path: string[] } },
) {
const apiPath = `/api/${params.provider}`;
console.log(`[${params.provider} Route] params `, params);
switch (apiPath) {
case ApiPath.Azure:
return azureHandler(req, { params });
case ApiPath.Google:
return googleHandler(req, { params });
case ApiPath.Anthropic:
return anthropicHandler(req, { params });
case ApiPath.Baidu:
return baiduHandler(req, { params });
case ApiPath.ByteDance:
return bytedanceHandler(req, { params });
case ApiPath.Alibaba:
return alibabaHandler(req, { params });
// case ApiPath.Tencent: using "/api/tencent"
case ApiPath.Moonshot:
return moonshotHandler(req, { params });
case ApiPath.Stability:
return stabilityHandler(req, { params });
case ApiPath.Iflytek:
return iflytekHandler(req, { params });
default:
return openaiHandler(req, { params });
}
}
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];

View File

@ -14,7 +14,7 @@ import type { RequestPayload } from "@/app/client/platforms/openai";
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { path: string[] } },
) { ) {
@ -40,30 +40,6 @@ async function handle(
} }
} }
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
async function request(req: NextRequest) { async function request(req: NextRequest) {
const controller = new AbortController(); const controller = new AbortController();

View File

@ -9,13 +9,13 @@ import {
} from "@/app/constant"; } from "@/app/constant";
import { prettyObject } from "@/app/utils/format"; import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth"; import { auth } from "./auth";
import { isModelAvailableInServer } from "@/app/utils/model"; import { isModelAvailableInServer } from "@/app/utils/model";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]); const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { path: string[] } },
) { ) {
@ -56,30 +56,6 @@ async function handle(
} }
} }
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
async function request(req: NextRequest) { async function request(req: NextRequest) {

View File

@ -0,0 +1,73 @@
import md5 from "spark-md5";
import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "@/app/config/server";
async function handle(req: NextRequest, res: NextResponse) {
const serverConfig = getServerSideConfig();
const storeUrl = () =>
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
const storeHeaders = () => ({
Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
});
if (req.method === "POST") {
const clonedBody = await req.text();
const hashedCode = md5.hash(clonedBody).trim();
const body: {
key: string;
value: string;
expiration_ttl?: number;
} = {
key: hashedCode,
value: clonedBody,
};
try {
const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
if (ttl > 60) {
body["expiration_ttl"] = ttl;
}
} catch (e) {
console.error(e);
}
const res = await fetch(`${storeUrl()}/bulk`, {
headers: {
...storeHeaders(),
"Content-Type": "application/json",
},
method: "PUT",
body: JSON.stringify([body]),
});
const result = await res.json();
console.log("save data", result);
if (result?.success) {
return NextResponse.json(
{ code: 0, id: hashedCode, result },
{ status: res.status },
);
}
return NextResponse.json(
{ error: true, msg: "Save data error" },
{ status: 400 },
);
}
if (req.method === "GET") {
const id = req?.nextUrl?.searchParams?.get("id");
const res = await fetch(`${storeUrl()}/values/${id}`, {
headers: storeHeaders(),
method: "GET",
});
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
return NextResponse.json(
{ error: true, msg: "Invalid request" },
{ status: 400 },
);
}
export const POST = handle;
export const GET = handle;
export const runtime = "edge";

View File

@ -67,6 +67,9 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
let systemApiKey: string | undefined; let systemApiKey: string | undefined;
switch (modelProvider) { switch (modelProvider) {
case ModelProvider.Stability:
systemApiKey = serverConfig.stabilityApiKey;
break;
case ModelProvider.GeminiPro: case ModelProvider.GeminiPro:
systemApiKey = serverConfig.googleApiKey; systemApiKey = serverConfig.googleApiKey;
break; break;
@ -82,6 +85,13 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
case ModelProvider.Qwen: case ModelProvider.Qwen:
systemApiKey = serverConfig.alibabaApiKey; systemApiKey = serverConfig.alibabaApiKey;
break; break;
case ModelProvider.Moonshot:
systemApiKey = serverConfig.moonshotApiKey;
break;
case ModelProvider.Iflytek:
systemApiKey =
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
break;
case ModelProvider.GPT: case ModelProvider.GPT:
default: default:
if (req.nextUrl.pathname.includes("azure/deployments")) { if (req.nextUrl.pathname.includes("azure/deployments")) {

View File

@ -2,10 +2,10 @@ import { getServerSideConfig } from "@/app/config/server";
import { ModelProvider } from "@/app/constant"; import { ModelProvider } from "@/app/constant";
import { prettyObject } from "@/app/utils/format"; import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth"; import { auth } from "./auth";
import { requestOpenai } from "../../common"; import { requestOpenai } from "./common";
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { path: string[] } },
) { ) {
@ -31,27 +31,3 @@ async function handle(
return NextResponse.json(prettyObject(e)); return NextResponse.json(prettyObject(e));
} }
} }
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];

View File

@ -14,7 +14,7 @@ import { getAccessToken } from "@/app/utils/baidu";
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { path: string[] } },
) { ) {
@ -52,30 +52,6 @@ async function handle(
} }
} }
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
async function request(req: NextRequest) { async function request(req: NextRequest) {
const controller = new AbortController(); const controller = new AbortController();

View File

@ -12,7 +12,7 @@ import { isModelAvailableInServer } from "@/app/utils/model";
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { path: string[] } },
) { ) {
@ -38,30 +38,6 @@ async function handle(
} }
} }
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
async function request(req: NextRequest) { async function request(req: NextRequest) {
const controller = new AbortController(); const controller = new AbortController();

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth"; import { auth } from "./auth";
import { getServerSideConfig } from "@/app/config/server"; import { getServerSideConfig } from "@/app/config/server";
import { import {
ApiPath, ApiPath,
@ -11,9 +11,9 @@ import { prettyObject } from "@/app/utils/format";
const serverConfig = getServerSideConfig(); const serverConfig = getServerSideConfig();
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { provider: string; path: string[] } },
) { ) {
console.log("[Google Route] params ", params); console.log("[Google Route] params ", params);

131
app/api/iflytek.ts Normal file
View File

@ -0,0 +1,131 @@
import { getServerSideConfig } from "@/app/config/server";
import {
Iflytek,
IFLYTEK_BASE_URL,
ApiPath,
ModelProvider,
ServiceProvider,
} from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/api/auth";
import { isModelAvailableInServer } from "@/app/utils/model";
import type { RequestPayload } from "@/app/client/platforms/openai";
// iflytek
const serverConfig = getServerSideConfig();
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Iflytek Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const authResult = auth(req, ModelProvider.Iflytek);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await request(req);
return response;
} catch (e) {
console.error("[Iflytek] ", e);
return NextResponse.json(prettyObject(e));
}
}
async function request(req: NextRequest) {
const controller = new AbortController();
// iflytek use base url or just remove the path
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Iflytek, "");
let baseUrl = serverConfig.iflytekUrl || IFLYTEK_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchUrl = `${baseUrl}${path}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
Authorization: req.headers.get("Authorization") ?? "",
},
method: req.method,
body: req.body,
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
// try to refuse some request to some models
if (serverConfig.customModels && req.body) {
try {
const clonedBody = await req.text();
fetchOptions.body = clonedBody;
const jsonBody = JSON.parse(clonedBody) as { model?: string };
// not undefined and is false
if (
isModelAvailableInServer(
serverConfig.customModels,
jsonBody?.model as string,
ServiceProvider.Iflytek as string,
)
) {
return NextResponse.json(
{
error: true,
message: `you are not allowed to use ${jsonBody?.model} model`,
},
{
status: 403,
},
);
}
} catch (e) {
console.error(`[Iflytek] filter`, e);
}
}
try {
const res = await fetch(fetchUrl, fetchOptions);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}

130
app/api/moonshot.ts Normal file
View File

@ -0,0 +1,130 @@
import { getServerSideConfig } from "@/app/config/server";
import {
Moonshot,
MOONSHOT_BASE_URL,
ApiPath,
ModelProvider,
ServiceProvider,
} from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/api/auth";
import { isModelAvailableInServer } from "@/app/utils/model";
import type { RequestPayload } from "@/app/client/platforms/openai";
const serverConfig = getServerSideConfig();
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Moonshot Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const authResult = auth(req, ModelProvider.Moonshot);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await request(req);
return response;
} catch (e) {
console.error("[Moonshot] ", e);
return NextResponse.json(prettyObject(e));
}
}
async function request(req: NextRequest) {
const controller = new AbortController();
// alibaba use base url or just remove the path
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Moonshot, "");
let baseUrl = serverConfig.moonshotUrl || MOONSHOT_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchUrl = `${baseUrl}${path}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
Authorization: req.headers.get("Authorization") ?? "",
},
method: req.method,
body: req.body,
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
// #1815 try to refuse some request to some models
if (serverConfig.customModels && req.body) {
try {
const clonedBody = await req.text();
fetchOptions.body = clonedBody;
const jsonBody = JSON.parse(clonedBody) as { model?: string };
// not undefined and is false
if (
isModelAvailableInServer(
serverConfig.customModels,
jsonBody?.model as string,
ServiceProvider.Moonshot as string,
)
) {
return NextResponse.json(
{
error: true,
message: `you are not allowed to use ${jsonBody?.model} model`,
},
{
status: 403,
},
);
}
} catch (e) {
console.error(`[Moonshot] filter`, e);
}
}
try {
const res = await fetch(fetchUrl, fetchOptions);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}

View File

@ -3,8 +3,8 @@ import { getServerSideConfig } from "@/app/config/server";
import { ModelProvider, OpenaiPath } from "@/app/constant"; import { ModelProvider, OpenaiPath } from "@/app/constant";
import { prettyObject } from "@/app/utils/format"; import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "../../auth"; import { auth } from "./auth";
import { requestOpenai } from "../../common"; import { requestOpenai } from "./common";
const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
@ -13,14 +13,14 @@ function getModels(remoteModelRes: OpenAIListModelResponse) {
if (config.disableGPT4) { if (config.disableGPT4) {
remoteModelRes.data = remoteModelRes.data.filter( remoteModelRes.data = remoteModelRes.data.filter(
(m) => !m.id.startsWith("gpt-4"), (m) => !m.id.startsWith("gpt-4") || m.id.startsWith("gpt-4o-mini"),
); );
} }
return remoteModelRes; return remoteModelRes;
} }
async function handle( export async function handle(
req: NextRequest, req: NextRequest,
{ params }: { params: { path: string[] } }, { params }: { params: { path: string[] } },
) { ) {
@ -70,27 +70,3 @@ async function handle(
return NextResponse.json(prettyObject(e)); return NextResponse.json(prettyObject(e));
} }
} }
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];

99
app/api/stability.ts Normal file
View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "@/app/config/server";
import { ModelProvider, STABILITY_BASE_URL } from "@/app/constant";
import { auth } from "@/app/api/auth";
export async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Stability] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const controller = new AbortController();
const serverConfig = getServerSideConfig();
let baseUrl = serverConfig.stabilityUrl || STABILITY_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
let path = `${req.nextUrl.pathname}`.replaceAll("/api/stability/", "");
console.log("[Stability Proxy] ", path);
console.log("[Stability Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const authResult = auth(req, ModelProvider.Stability);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
const bearToken = req.headers.get("Authorization") ?? "";
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
const key = token ? token : serverConfig.stabilityApiKey;
if (!key) {
return NextResponse.json(
{
error: true,
message: `missing STABILITY_API_KEY in server env vars`,
},
{
status: 401,
},
);
}
const fetchUrl = `${baseUrl}/${path}`;
console.log("[Stability Url] ", fetchUrl);
const fetchOptions: RequestInit = {
headers: {
"Content-Type": req.headers.get("Content-Type") || "multipart/form-data",
Accept: req.headers.get("Accept") || "application/json",
Authorization: `Bearer ${key}`,
},
method: req.method,
body: req.body,
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
try {
const res = await fetch(fetchUrl, fetchOptions);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}

124
app/api/tencent/route.ts Normal file
View File

@ -0,0 +1,124 @@
import { getServerSideConfig } from "@/app/config/server";
import {
TENCENT_BASE_URL,
ApiPath,
ModelProvider,
ServiceProvider,
Tencent,
} from "@/app/constant";
import { prettyObject } from "@/app/utils/format";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/api/auth";
import { isModelAvailableInServer } from "@/app/utils/model";
import { getHeader } from "@/app/utils/tencent";
const serverConfig = getServerSideConfig();
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Tencent Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const authResult = auth(req, ModelProvider.Hunyuan);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await request(req);
return response;
} catch (e) {
console.error("[Tencent] ", e);
return NextResponse.json(prettyObject(e));
}
}
export const GET = handle;
export const POST = handle;
export const runtime = "edge";
export const preferredRegion = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
async function request(req: NextRequest) {
const controller = new AbortController();
let baseUrl = serverConfig.tencentUrl || TENCENT_BASE_URL;
if (!baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchUrl = baseUrl;
const body = await req.text();
const headers = await getHeader(
body,
serverConfig.tencentSecretId as string,
serverConfig.tencentSecretKey as string,
);
const fetchOptions: RequestInit = {
headers,
method: req.method,
body,
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
try {
const res = await fetch(fetchUrl, fetchOptions);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}

View File

@ -29,6 +29,7 @@ async function handle(
const requestUrl = new URL(req.url); const requestUrl = new URL(req.url);
let endpoint = requestUrl.searchParams.get("endpoint"); let endpoint = requestUrl.searchParams.get("endpoint");
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
// Validate the endpoint to prevent potential SSRF attacks // Validate the endpoint to prevent potential SSRF attacks
if ( if (
@ -37,9 +38,13 @@ async function handle(
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint); const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
const normalizedEndpoint = normalizeUrl(endpoint as string); const normalizedEndpoint = normalizeUrl(endpoint as string);
return normalizedEndpoint && return (
normalizedEndpoint &&
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname && normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname); normalizedEndpoint.pathname.startsWith(
normalizedAllowedEndpoint.pathname,
)
);
}) })
) { ) {
return NextResponse.json( return NextResponse.json(
@ -61,7 +66,11 @@ async function handle(
const targetPath = `${endpoint}${endpointPath}`; const targetPath = `${endpoint}${endpointPath}`;
// only allow MKCOL, GET, PUT // only allow MKCOL, GET, PUT
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") { if (
proxy_method !== "MKCOL" &&
proxy_method !== "GET" &&
proxy_method !== "PUT"
) {
return NextResponse.json( return NextResponse.json(
{ {
error: true, error: true,
@ -74,7 +83,7 @@ async function handle(
} }
// for MKCOL request, only allow request ${folder} // for MKCOL request, only allow request ${folder}
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) { if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
return NextResponse.json( return NextResponse.json(
{ {
error: true, error: true,
@ -87,7 +96,7 @@ async function handle(
} }
// for GET request, only allow request ending with fileName // for GET request, only allow request ending with fileName
if (req.method === "GET" && !targetPath.endsWith(fileName)) { if (proxy_method === "GET" && !targetPath.endsWith(fileName)) {
return NextResponse.json( return NextResponse.json(
{ {
error: true, error: true,
@ -100,7 +109,7 @@ async function handle(
} }
// for PUT request, only allow request ending with fileName // for PUT request, only allow request ending with fileName
if (req.method === "PUT" && !targetPath.endsWith(fileName)) { if (proxy_method === "PUT" && !targetPath.endsWith(fileName)) {
return NextResponse.json( return NextResponse.json(
{ {
error: true, error: true,
@ -114,7 +123,7 @@ async function handle(
const targetUrl = targetPath; const targetUrl = targetPath;
const method = req.method; const method = proxy_method || req.method;
const shouldNotHaveBody = ["get", "head"].includes( const shouldNotHaveBody = ["get", "head"].includes(
method?.toLowerCase() ?? "", method?.toLowerCase() ?? "",
); );
@ -139,7 +148,7 @@ async function handle(
"[Any Proxy]", "[Any Proxy]",
targetUrl, targetUrl,
{ {
method: req.method, method: method,
}, },
{ {
status: fetchResult?.status, status: fetchResult?.status,

View File

@ -6,12 +6,15 @@ import {
ServiceProvider, ServiceProvider,
} from "../constant"; } from "../constant";
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store"; import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
import { ChatGPTApi } from "./platforms/openai"; import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
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";
import { DoubaoApi } from "./platforms/bytedance"; import { DoubaoApi } from "./platforms/bytedance";
import { QwenApi } from "./platforms/alibaba"; import { QwenApi } from "./platforms/alibaba";
import { HunyuanApi } from "./platforms/tencent";
import { MoonshotApi } from "./platforms/moonshot";
import { SparkApi } from "./platforms/iflytek";
export const ROLES = ["system", "user", "assistant"] as const; export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number]; export type MessageRole = (typeof ROLES)[number];
@ -40,6 +43,9 @@ export interface LLMConfig {
stream?: boolean; stream?: boolean;
presence_penalty?: number; presence_penalty?: number;
frequency_penalty?: number; frequency_penalty?: number;
size?: DalleRequestPayload["size"];
quality?: DalleRequestPayload["quality"];
style?: DalleRequestPayload["style"];
} }
export interface ChatOptions { export interface ChatOptions {
@ -62,12 +68,14 @@ export interface LLMModel {
displayName?: string; displayName?: string;
available: boolean; available: boolean;
provider: LLMModelProvider; provider: LLMModelProvider;
sorted: number;
} }
export interface LLMModelProvider { export interface LLMModelProvider {
id: string; id: string;
providerName: string; providerName: string;
providerType: string; providerType: string;
sorted: number;
} }
export abstract class LLMApi { export abstract class LLMApi {
@ -117,6 +125,15 @@ export class ClientApi {
case ModelProvider.Qwen: case ModelProvider.Qwen:
this.llm = new QwenApi(); this.llm = new QwenApi();
break; break;
case ModelProvider.Hunyuan:
this.llm = new HunyuanApi();
break;
case ModelProvider.Moonshot:
this.llm = new MoonshotApi();
break;
case ModelProvider.Iflytek:
this.llm = new SparkApi();
break;
default: default:
this.llm = new ChatGPTApi(); this.llm = new ChatGPTApi();
} }
@ -168,6 +185,19 @@ export class ClientApi {
} }
} }
export function getBearerToken(
apiKey: string,
noBearer: boolean = false,
): string {
return validString(apiKey)
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
: "";
}
export function validString(x: string): boolean {
return x?.length > 0;
}
export function getHeaders() { export function getHeaders() {
const accessStore = useAccessStore.getState(); const accessStore = useAccessStore.getState();
const chatStore = useChatStore.getState(); const chatStore = useChatStore.getState();
@ -186,6 +216,8 @@ export function getHeaders() {
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu; const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance; const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba; const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
const isEnabledAccessControl = accessStore.enabledAccessControl(); const isEnabledAccessControl = accessStore.enabledAccessControl();
const apiKey = isGoogle const apiKey = isGoogle
? accessStore.googleApiKey ? accessStore.googleApiKey
@ -197,6 +229,12 @@ export function getHeaders() {
? accessStore.bytedanceApiKey ? accessStore.bytedanceApiKey
: isAlibaba : isAlibaba
? accessStore.alibabaApiKey ? accessStore.alibabaApiKey
: isMoonshot
? accessStore.moonshotApiKey
: isIflytek
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
: ""
: accessStore.openaiApiKey; : accessStore.openaiApiKey;
return { return {
isGoogle, isGoogle,
@ -205,6 +243,8 @@ export function getHeaders() {
isBaidu, isBaidu,
isByteDance, isByteDance,
isAlibaba, isAlibaba,
isMoonshot,
isIflytek,
apiKey, apiKey,
isEnabledAccessControl, isEnabledAccessControl,
}; };
@ -214,15 +254,6 @@ export function getHeaders() {
return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization"; return isAzure ? "api-key" : isAnthropic ? "x-api-key" : "Authorization";
} }
function getBearerToken(apiKey: string, noBearer: boolean = false): string {
return validString(apiKey)
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
: "";
}
function validString(x: string): boolean {
return x?.length > 0;
}
const { const {
isGoogle, isGoogle,
isAzure, isAzure,
@ -263,6 +294,12 @@ export function getClientApi(provider: ServiceProvider): ClientApi {
return new ClientApi(ModelProvider.Doubao); return new ClientApi(ModelProvider.Doubao);
case ServiceProvider.Alibaba: case ServiceProvider.Alibaba:
return new ClientApi(ModelProvider.Qwen); return new ClientApi(ModelProvider.Qwen);
case ServiceProvider.Tencent:
return new ClientApi(ModelProvider.Hunyuan);
case ServiceProvider.Moonshot:
return new ClientApi(ModelProvider.Moonshot);
case ServiceProvider.Iflytek:
return new ClientApi(ModelProvider.Iflytek);
default: default:
return new ClientApi(ModelProvider.GPT); return new ClientApi(ModelProvider.GPT);
} }

View File

@ -77,16 +77,24 @@ export class ErnieApi implements LLMApi {
async chat(options: ChatOptions) { async chat(options: ChatOptions) {
const messages = options.messages.map((v) => ({ const messages = options.messages.map((v) => ({
role: v.role, // "error_code": 336006, "error_msg": "the role of message with even index in the messages must be user or function",
role: v.role === "system" ? "user" : v.role,
content: getMessageTextContent(v), content: getMessageTextContent(v),
})); }));
// "error_code": 336006, "error_msg": "the length of messages must be an odd number", // "error_code": 336006, "error_msg": "the length of messages must be an odd number",
if (messages.length % 2 === 0) { if (messages.length % 2 === 0) {
messages.unshift({ if (messages.at(0)?.role === "user") {
role: "user", messages.splice(1, 0, {
content: " ", role: "assistant",
}); content: " ",
});
} else {
messages.unshift({
role: "user",
content: " ",
});
}
} }
const modelConfig = { const modelConfig = {

View File

@ -25,11 +25,9 @@ export class GeminiProApi implements LLMApi {
baseUrl = accessStore.googleUrl; baseUrl = accessStore.googleUrl;
} }
const isApp = !!getClientConfig()?.isApp;
if (baseUrl.length === 0) { if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp; baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google;
baseUrl = isApp
? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}`
: ApiPath.Google;
} }
if (baseUrl.endsWith("/")) { if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1); baseUrl = baseUrl.slice(0, baseUrl.length - 1);
@ -43,6 +41,10 @@ export class GeminiProApi implements LLMApi {
let chatPath = [baseUrl, path].join("/"); let chatPath = [baseUrl, path].join("/");
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse"; chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
// if chatPath.startsWith('http') then add key in query string
if (chatPath.startsWith("http") && accessStore.googleApiKey) {
chatPath += `&key=${accessStore.googleApiKey}`;
}
return chatPath; return chatPath;
} }
extractMessage(res: any) { extractMessage(res: any) {

View File

@ -0,0 +1,240 @@
"use client";
import {
ApiPath,
DEFAULT_API_HOST,
Iflytek,
REQUEST_TIMEOUT_MS,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { ChatOptions, getHeaders, LLMApi, LLMModel } from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent } from "@/app/utils";
import { OpenAIListModelResponse, RequestPayload } from "./openai";
export class SparkApi implements LLMApi {
private disableListModels = true;
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.iflytekUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
const apiPath = ApiPath.Iflytek;
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Iflytek)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
extractMessage(res: any) {
return res.choices?.at(0)?.message?.content ?? "";
}
async chat(options: ChatOptions) {
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = getMessageTextContent(v);
messages.push({ role: v.role, content });
}
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
providerName: options.config.providerName,
},
};
const requestPayload: RequestPayload = {
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,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
console.log("[Request] Spark payload: ", requestPayload);
const shouldStream = !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path(Iflytek.ChatPath);
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: getHeaders(),
};
// Make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// Animate response text to make it look smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// Start animation
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log("[Spark] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
// Handle different error scenarios
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
extraInfo = Locale.Error.Unauthorized;
}
options.onError?.(
new Error(
`Request failed with status ${res.status}: ${extraInfo}`,
),
);
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text);
options.onError?.(new Error(`Failed to parse response: ${text}`));
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
if (!res.ok) {
const errorText = await res.text();
options.onError?.(
new Error(`Request failed with status ${res.status}: ${errorText}`),
);
return;
}
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}

View File

@ -0,0 +1,251 @@
"use client";
// azure and openai, using same models. so using same LLMApi.
import {
ApiPath,
DEFAULT_API_HOST,
DEFAULT_MODELS,
Moonshot,
REQUEST_TIMEOUT_MS,
ServiceProvider,
} from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { collectModelsWithDefaultModel } from "@/app/utils/model";
import { preProcessImageContent } from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
LLMUsage,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent } from "@/app/utils";
import { OpenAIListModelResponse, RequestPayload } from "./openai";
export class MoonshotApi implements LLMApi {
private disableListModels = true;
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.moonshotUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
const apiPath = ApiPath.Moonshot;
baseUrl = isApp ? DEFAULT_API_HOST + "/proxy" + apiPath : apiPath;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Moonshot)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
}
extractMessage(res: any) {
return res.choices?.at(0)?.message?.content ?? "";
}
async chat(options: ChatOptions) {
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = getMessageTextContent(v);
messages.push({ role: v.role, content });
}
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
providerName: options.config.providerName,
},
};
const requestPayload: RequestPayload = {
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,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path(Moonshot.ChatPath);
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: getHeaders(),
};
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
if (responseText?.length === 0) {
options.onError?.(new Error("empty response from server"));
}
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// start animaion
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log(
"[OpenAI] request response content type: ",
contentType,
);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
const textmoderation = json?.prompt_filter_results;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}

View File

@ -11,8 +11,13 @@ import {
} from "@/app/constant"; } from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import { collectModelsWithDefaultModel } from "@/app/utils/model"; import { collectModelsWithDefaultModel } from "@/app/utils/model";
import { preProcessImageContent } from "@/app/utils/chat"; import {
preProcessImageContent,
uploadImage,
base64Image2Blob,
} from "@/app/utils/chat";
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
import { import {
ChatOptions, ChatOptions,
@ -33,6 +38,7 @@ import {
getMessageTextContent, getMessageTextContent,
getMessageImages, getMessageImages,
isVisionModel, isVisionModel,
isDalle3 as _isDalle3,
} from "@/app/utils"; } from "@/app/utils";
export interface OpenAIListModelResponse { export interface OpenAIListModelResponse {
@ -58,6 +64,16 @@ export interface RequestPayload {
max_tokens?: number; max_tokens?: number;
} }
export interface DalleRequestPayload {
model: string;
prompt: string;
response_format: "url" | "b64_json";
n: number;
size: DalleSize;
quality: DalleQuality;
style: DalleStyle;
}
export class ChatGPTApi implements LLMApi { export class ChatGPTApi implements LLMApi {
private disableListModels = true; private disableListModels = true;
@ -100,20 +116,31 @@ export class ChatGPTApi implements LLMApi {
return cloudflareAIGatewayUrl([baseUrl, path].join("/")); return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
} }
extractMessage(res: any) { async extractMessage(res: any) {
return res.choices?.at(0)?.message?.content ?? ""; if (res.error) {
return "```\n" + JSON.stringify(res, null, 4) + "\n```";
}
// dalle3 model return url, using url create image message
if (res.data) {
let url = res.data?.at(0)?.url ?? "";
const b64_json = res.data?.at(0)?.b64_json ?? "";
if (!url && b64_json) {
// uploadImage
url = await uploadImage(base64Image2Blob(b64_json, "image/png"));
}
return [
{
type: "image_url",
image_url: {
url,
},
},
];
}
return res.choices?.at(0)?.message?.content ?? res;
} }
async chat(options: ChatOptions) { async chat(options: ChatOptions) {
const visionModel = isVisionModel(options.config.model);
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = visionModel
? await preProcessImageContent(v.content)
: getMessageTextContent(v);
messages.push({ role: v.role, content });
}
const modelConfig = { const modelConfig = {
...useAppConfig.getState().modelConfig, ...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig, ...useChatStore.getState().currentSession().mask.modelConfig,
@ -123,26 +150,54 @@ export class ChatGPTApi implements LLMApi {
}, },
}; };
const requestPayload: RequestPayload = { let requestPayload: RequestPayload | DalleRequestPayload;
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,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
// add max_tokens to vision model const isDalle3 = _isDalle3(options.config.model);
if (visionModel && modelConfig.model.includes("preview")) { if (isDalle3) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000); const prompt = getMessageTextContent(
options.messages.slice(-1)?.pop() as any,
);
requestPayload = {
model: options.config.model,
prompt,
// URLs are only valid for 60 minutes after the image has been generated.
response_format: "b64_json", // using b64_json, and save image in CacheStorage
n: 1,
size: options.config?.size ?? "1024x1024",
quality: options.config?.quality ?? "standard",
style: options.config?.style ?? "vivid",
};
} else {
const visionModel = isVisionModel(options.config.model);
const messages: ChatOptions["messages"] = [];
for (const v of options.messages) {
const content = visionModel
? await preProcessImageContent(v.content)
: getMessageTextContent(v);
messages.push({ role: v.role, content });
}
requestPayload = {
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,
// max_tokens: Math.max(modelConfig.max_tokens, 1024),
// Please do not ask me why not send max_tokens, no reason, this param is just shit, I dont want to explain anymore.
};
// add max_tokens to vision model
if (visionModel && modelConfig.model.includes("preview")) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
}
} }
console.log("[Request] openai payload: ", requestPayload); console.log("[Request] openai payload: ", requestPayload);
const shouldStream = !!options.config.stream; const shouldStream = !isDalle3 && !!options.config.stream;
const controller = new AbortController(); const controller = new AbortController();
options.onController?.(controller); options.onController?.(controller);
@ -168,13 +223,15 @@ export class ChatGPTApi implements LLMApi {
model?.provider?.providerName === ServiceProvider.Azure, model?.provider?.providerName === ServiceProvider.Azure,
); );
chatPath = this.path( chatPath = this.path(
Azure.ChatPath( (isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
(model?.displayName ?? model?.name) as string, (model?.displayName ?? model?.name) as string,
useCustomConfig ? useAccessStore.getState().azureApiVersion : "", useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
), ),
); );
} else { } else {
chatPath = this.path(OpenaiPath.ChatPath); chatPath = this.path(
isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
);
} }
const chatPayload = { const chatPayload = {
method: "POST", method: "POST",
@ -186,7 +243,7 @@ export class ChatGPTApi implements LLMApi {
// make a fetch request // make a fetch request
const requestTimeoutId = setTimeout( const requestTimeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
REQUEST_TIMEOUT_MS, isDalle3 ? REQUEST_TIMEOUT_MS * 2 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
); );
if (shouldStream) { if (shouldStream) {
@ -317,7 +374,7 @@ export class ChatGPTApi implements LLMApi {
clearTimeout(requestTimeoutId); clearTimeout(requestTimeoutId);
const resJson = await res.json(); const resJson = await res.json();
const message = this.extractMessage(resJson); const message = await this.extractMessage(resJson);
options.onFinish(message); options.onFinish(message);
} }
} catch (e) { } catch (e) {
@ -411,13 +468,17 @@ export class ChatGPTApi implements LLMApi {
return []; return [];
} }
//由于目前 OpenAI 的 disableListModels 默认为 true所以当前实际不会运行到这场
let seq = 1000; //同 Constant.ts 中的排序保持一致
return chatModels.map((m) => ({ return chatModels.map((m) => ({
name: m.id, name: m.id,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "openai", id: "openai",
providerName: "OpenAI", providerName: "OpenAI",
providerType: "openai", providerType: "openai",
sorted: 1,
}, },
})); }));
} }

View File

@ -0,0 +1,268 @@
"use client";
import { ApiPath, DEFAULT_API_HOST, REQUEST_TIMEOUT_MS } from "@/app/constant";
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
import {
ChatOptions,
getHeaders,
LLMApi,
LLMModel,
MultimodalContent,
} from "../api";
import Locale from "../../locales";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import { getClientConfig } from "@/app/config/client";
import { getMessageTextContent, isVisionModel } from "@/app/utils";
import mapKeys from "lodash-es/mapKeys";
import mapValues from "lodash-es/mapValues";
import isArray from "lodash-es/isArray";
import isObject from "lodash-es/isObject";
export interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}
interface RequestPayload {
Messages: {
Role: "system" | "user" | "assistant";
Content: string | MultimodalContent[];
}[];
Stream?: boolean;
Model: string;
Temperature: number;
TopP: number;
}
function capitalizeKeys(obj: any): any {
if (isArray(obj)) {
return obj.map(capitalizeKeys);
} else if (isObject(obj)) {
return mapValues(
mapKeys(obj, (value: any, key: string) =>
key.replace(/(^|_)(\w)/g, (m, $1, $2) => $2.toUpperCase()),
),
capitalizeKeys,
);
} else {
return obj;
}
}
export class HunyuanApi implements LLMApi {
path(): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.tencentUrl;
}
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp
? DEFAULT_API_HOST + "/api/proxy/tencent"
: ApiPath.Tencent;
}
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Tencent)) {
baseUrl = "https://" + baseUrl;
}
console.log("[Proxy Endpoint] ", baseUrl);
return baseUrl;
}
extractMessage(res: any) {
return res.Choices?.at(0)?.Message?.Content ?? "";
}
async chat(options: ChatOptions) {
const visionModel = isVisionModel(options.config.model);
const messages = options.messages.map((v, index) => ({
// "Messages 中 system 角色必须位于列表的最开始"
role: index !== 0 && v.role === "system" ? "user" : v.role,
content: visionModel ? v.content : getMessageTextContent(v),
}));
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const requestPayload: RequestPayload = capitalizeKeys({
model: modelConfig.model,
messages,
temperature: modelConfig.temperature,
top_p: modelConfig.top_p,
stream: options.config.stream,
});
console.log("[Request] Tencent payload: ", requestPayload);
const shouldStream = !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
const chatPath = this.path();
const chatPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: getHeaders(),
};
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";
let finished = false;
// animate response to make it looks smooth
function animateResponseText() {
if (finished || controller.signal.aborted) {
responseText += remainText;
console.log("[Response Animation] finished");
if (responseText?.length === 0) {
options.onError?.(new Error("empty response from server"));
}
return;
}
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
}
// start animaion
animateResponseText();
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
controller.signal.onabort = finish;
fetchEventSource(chatPath, {
...chatPayload,
async onopen(res) {
clearTimeout(requestTimeoutId);
const contentType = res.headers.get("content-type");
console.log(
"[Tencent] request response content type: ",
contentType,
);
if (contentType?.startsWith("text/plain")) {
responseText = await res.clone().text();
return finish();
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join("\n\n");
return finish();
}
},
onmessage(msg) {
if (msg.data === "[DONE]" || finished) {
return finish();
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.Choices as Array<{
Delta: { Content: string };
}>;
const delta = choices[0]?.Delta?.Content;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
finish();
},
onerror(e) {
options.onError?.(e);
throw e;
},
openWhenHidden: true,
});
} else {
const res = await fetch(chatPath, chatPayload);
clearTimeout(requestTimeoutId);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
}
} catch (e) {
console.log("[Request] failed to make a chat request", e);
options.onError?.(e as Error);
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models(): Promise<LLMModel[]> {
return [];
}
}

View File

@ -41,13 +41,16 @@ interface ChatCommands {
del?: Command; del?: Command;
} }
export const ChatCommandPrefix = ":"; // Compatible with Chinese colon character ""
export const ChatCommandPrefix = /^[:]/;
export function useChatCommand(commands: ChatCommands = {}) { export function useChatCommand(commands: ChatCommands = {}) {
function extract(userInput: string) { function extract(userInput: string) {
return ( const match = userInput.match(ChatCommandPrefix);
userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput if (match) {
) as keyof ChatCommands; return userInput.slice(1) as keyof ChatCommands;
}
return userInput as keyof ChatCommands;
} }
function search(userInput: string) { function search(userInput: string) {
@ -57,7 +60,7 @@ export function useChatCommand(commands: ChatCommands = {}) {
.filter((c) => c.startsWith(input)) .filter((c) => c.startsWith(input))
.map((c) => ({ .map((c) => ({
title: desc[c as keyof ChatCommands], title: desc[c as keyof ChatCommands],
content: ChatCommandPrefix + c, content: ":" + c,
})); }));
} }

View File

@ -0,0 +1,31 @@
.artifacts {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
&-header {
display: flex;
align-items: center;
height: 36px;
padding: 20px;
background: var(--second);
}
&-title {
flex: 1;
text-align: center;
font-weight: bold;
font-size: 24px;
}
&-content {
flex-grow: 1;
padding: 0 20px 20px 20px;
background-color: var(--second);
}
}
.artifacts-iframe {
width: 100%;
border: var(--border-in-light);
border-radius: 6px;
background-color: var(--gray);
}

View File

@ -0,0 +1,234 @@
import { useEffect, useState, useRef, useMemo } from "react";
import { useParams } from "react-router";
import { useWindowSize } from "@/app/utils";
import { IconButton } from "./button";
import { nanoid } from "nanoid";
import ExportIcon from "../icons/share.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
import GithubIcon from "../icons/github.svg";
import LoadingButtonIcon from "../icons/loading.svg";
import Locale from "../locales";
import { Modal, showToast } from "./ui-lib";
import { copyToClipboard, downloadAs } from "../utils";
import { Path, ApiPath, REPO_URL } from "@/app/constant";
import { Loading } from "./home";
import styles from "./artifacts.module.scss";
export function HTMLPreview(props: {
code: string;
autoHeight?: boolean;
height?: number | string;
onLoad?: (title?: string) => void;
}) {
const ref = useRef<HTMLIFrameElement>(null);
const frameId = useRef<string>(nanoid());
const [iframeHeight, setIframeHeight] = useState(600);
const [title, setTitle] = useState("");
/*
* https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
* 1. using srcdoc
* 2. using src with dataurl:
* easy to share
* length limit (Data URIs cannot be larger than 32,768 characters.)
*/
useEffect(() => {
const handleMessage = (e: any) => {
const { id, height, title } = e.data;
setTitle(title);
if (id == frameId.current) {
setIframeHeight(height);
}
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
const height = useMemo(() => {
if (!props.autoHeight) return props.height || 600;
if (typeof props.height === "string") {
return props.height;
}
const parentHeight = props.height || 600;
return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40;
}, [props.autoHeight, props.height, iframeHeight]);
const srcDoc = useMemo(() => {
const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
if (props.code.includes("</head>")) {
props.code.replace("</head>", "</head>" + script);
}
return props.code + script;
}, [props.code]);
const handleOnLoad = () => {
if (props?.onLoad) {
props.onLoad(title);
}
};
return (
<iframe
className={styles["artifacts-iframe"]}
id={frameId.current}
ref={ref}
sandbox="allow-forms allow-modals allow-scripts"
style={{ height }}
srcDoc={srcDoc}
onLoad={handleOnLoad}
/>
);
}
export function ArtifactsShareButton({
getCode,
id,
style,
fileName,
}: {
getCode: () => string;
id?: string;
style?: any;
fileName?: string;
}) {
const [loading, setLoading] = useState(false);
const [name, setName] = useState(id);
const [show, setShow] = useState(false);
const shareUrl = useMemo(
() => [location.origin, "#", Path.Artifacts, "/", name].join(""),
[name],
);
const upload = (code: string) =>
id
? Promise.resolve({ id })
: fetch(ApiPath.Artifacts, {
method: "POST",
body: code,
})
.then((res) => res.json())
.then(({ id }) => {
if (id) {
return { id };
}
throw Error();
})
.catch((e) => {
showToast(Locale.Export.Artifacts.Error);
});
return (
<>
<div className="window-action-button" style={style}>
<IconButton
icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
bordered
title={Locale.Export.Artifacts.Title}
onClick={() => {
if (loading) return;
setLoading(true);
upload(getCode())
.then((res) => {
if (res?.id) {
setShow(true);
setName(res?.id);
}
})
.finally(() => setLoading(false));
}}
/>
</div>
{show && (
<div className="modal-mask">
<Modal
title={Locale.Export.Artifacts.Title}
onClose={() => setShow(false)}
actions={[
<IconButton
key="download"
icon={<DownloadIcon />}
bordered
text={Locale.Export.Download}
onClick={() => {
downloadAs(getCode(), `${fileName || name}.html`).then(() =>
setShow(false),
);
}}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Chat.Actions.Copy}
onClick={() => {
copyToClipboard(shareUrl).then(() => setShow(false));
}}
/>,
]}
>
<div>
<a target="_blank" href={shareUrl}>
{shareUrl}
</a>
</div>
</Modal>
</div>
)}
</>
);
}
export function Artifacts() {
const { id } = useParams();
const [code, setCode] = useState("");
const [loading, setLoading] = useState(true);
const [fileName, setFileName] = useState("");
useEffect(() => {
if (id) {
fetch(`${ApiPath.Artifacts}?id=${id}`)
.then((res) => {
if (res.status > 300) {
throw Error("can not get content");
}
return res;
})
.then((res) => res.text())
.then(setCode)
.catch((e) => {
showToast(Locale.Export.Artifacts.Error);
});
}
}, [id]);
return (
<div className={styles["artifacts"]}>
<div className={styles["artifacts-header"]}>
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
<IconButton bordered icon={<GithubIcon />} shadow />
</a>
<div className={styles["artifacts-title"]}>NextChat Artifacts</div>
<ArtifactsShareButton
id={id}
getCode={() => code}
fileName={fileName}
/>
</div>
<div className={styles["artifacts-content"]}>
{loading && <Loading />}
{code && (
<HTMLPreview
code={code}
autoHeight={false}
height={"100%"}
onLoad={(title) => {
setFileName(title as string);
setLoading(false);
}}
/>
)}
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import styles from "./button.module.scss"; import styles from "./button.module.scss";
import { CSSProperties } from "react";
export type ButtonType = "primary" | "danger" | null; export type ButtonType = "primary" | "danger" | null;
@ -16,6 +17,8 @@ export function IconButton(props: {
disabled?: boolean; disabled?: boolean;
tabIndex?: number; tabIndex?: number;
autoFocus?: boolean; autoFocus?: boolean;
style?: CSSProperties;
aria?: string;
}) { }) {
return ( return (
<button <button
@ -31,9 +34,12 @@ export function IconButton(props: {
role="button" role="button"
tabIndex={props.tabIndex} tabIndex={props.tabIndex}
autoFocus={props.autoFocus} autoFocus={props.autoFocus}
style={props.style}
aria-label={props.aria}
> >
{props.icon && ( {props.icon && (
<div <div
aria-label={props.text || props.title}
className={ className={
styles["icon-button-icon"] + styles["icon-button-icon"] +
` ${props.type === "primary" && "no-dark"}` ` ${props.type === "primary" && "no-dark"}`
@ -44,7 +50,12 @@ export function IconButton(props: {
)} )}
{props.text && ( {props.text && (
<div className={styles["icon-button-text"]}>{props.text}</div> <div
aria-label={props.text || props.title}
className={styles["icon-button-text"]}
>
{props.text}
</div>
)} )}
</button> </button>
); );

View File

@ -346,6 +346,12 @@
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
.chat-model-name {
font-size: 12px;
color: var(--black);
margin-left: 6px;
}
} }
.chat-message-container { .chat-message-container {

View File

@ -37,6 +37,10 @@ import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg"; import BottomIcon from "../icons/bottom.svg";
import StopIcon from "../icons/pause.svg"; import StopIcon from "../icons/pause.svg";
import RobotIcon from "../icons/robot.svg"; import RobotIcon from "../icons/robot.svg";
import SizeIcon from "../icons/size.svg";
import QualityIcon from "../icons/hd.svg";
import StyleIcon from "../icons/palette.svg";
import PluginIcon from "../icons/plugin.svg";
import { import {
ChatMessage, ChatMessage,
@ -59,6 +63,7 @@ import {
getMessageTextContent, getMessageTextContent,
getMessageImages, getMessageImages,
isVisionModel, isVisionModel,
isDalle3,
} from "../utils"; } from "../utils";
import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
@ -66,6 +71,7 @@ import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller"; import { ChatControllerPool } from "../client/controller";
import { DalleSize, DalleQuality, DalleStyle } from "../typing";
import { Prompt, usePromptStore } from "../store/prompt"; import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales"; import Locale from "../locales";
@ -89,6 +95,7 @@ import {
REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT, UNFINISHED_INPUT,
ServiceProvider, ServiceProvider,
Plugin,
} from "../constant"; } from "../constant";
import { Avatar } from "./emoji"; import { Avatar } from "./emoji";
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask"; import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
@ -338,7 +345,7 @@ function ClearContextDivider() {
); );
} }
function ChatAction(props: { export function ChatAction(props: {
text: string; text: string;
icon: JSX.Element; icon: JSX.Element;
onClick: () => void; onClick: () => void;
@ -476,8 +483,22 @@ export function ChatActions(props: {
return model?.displayName ?? ""; return model?.displayName ?? "";
}, [models, currentModel, currentProviderName]); }, [models, currentModel, currentProviderName]);
const [showModelSelector, setShowModelSelector] = useState(false); const [showModelSelector, setShowModelSelector] = useState(false);
const [showPluginSelector, setShowPluginSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false); const [showUploadImage, setShowUploadImage] = useState(false);
const [showSizeSelector, setShowSizeSelector] = useState(false);
const [showQualitySelector, setShowQualitySelector] = useState(false);
const [showStyleSelector, setShowStyleSelector] = useState(false);
const dalle3Sizes: DalleSize[] = ["1024x1024", "1792x1024", "1024x1792"];
const dalle3Qualitys: DalleQuality[] = ["standard", "hd"];
const dalle3Styles: DalleStyle[] = ["vivid", "natural"];
const currentSize =
chatStore.currentSession().mask.modelConfig?.size ?? "1024x1024";
const currentQuality =
chatStore.currentSession().mask.modelConfig?.quality ?? "standard";
const currentStyle =
chatStore.currentSession().mask.modelConfig?.style ?? "vivid";
useEffect(() => { useEffect(() => {
const show = isVisionModel(currentModel); const show = isVisionModel(currentModel);
setShowUploadImage(show); setShowUploadImage(show);
@ -620,6 +641,115 @@ export function ChatActions(props: {
}} }}
/> />
)} )}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowSizeSelector(true)}
text={currentSize}
icon={<SizeIcon />}
/>
)}
{showSizeSelector && (
<Selector
defaultSelectedValue={currentSize}
items={dalle3Sizes.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowSizeSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const size = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.size = size;
});
showToast(size);
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowQualitySelector(true)}
text={currentQuality}
icon={<QualityIcon />}
/>
)}
{showQualitySelector && (
<Selector
defaultSelectedValue={currentQuality}
items={dalle3Qualitys.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowQualitySelector(false)}
onSelection={(q) => {
if (q.length === 0) return;
const quality = q[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.quality = quality;
});
showToast(quality);
}}
/>
)}
{isDalle3(currentModel) && (
<ChatAction
onClick={() => setShowStyleSelector(true)}
text={currentStyle}
icon={<StyleIcon />}
/>
)}
{showStyleSelector && (
<Selector
defaultSelectedValue={currentStyle}
items={dalle3Styles.map((m) => ({
title: m,
value: m,
}))}
onClose={() => setShowStyleSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
const style = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.style = style;
});
showToast(style);
}}
/>
)}
<ChatAction
onClick={() => setShowPluginSelector(true)}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>
{showPluginSelector && (
<Selector
multiple
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
items={[
{
title: Locale.Plugin.Artifacts,
value: Plugin.Artifacts,
},
]}
onClose={() => setShowPluginSelector(false)}
onSelection={(s) => {
const plugin = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.plugin = s;
});
if (plugin) {
showToast(plugin);
}
}}
/>
)}
</div> </div>
); );
} }
@ -701,6 +831,7 @@ function _Chat() {
const session = chatStore.currentSession(); const session = chatStore.currentSession();
const config = useAppConfig(); const config = useAppConfig();
const fontSize = config.fontSize; const fontSize = config.fontSize;
const fontFamily = config.fontFamily;
const [showExport, setShowExport] = useState(false); const [showExport, setShowExport] = useState(false);
@ -780,7 +911,7 @@ function _Chat() {
// clear search results // clear search results
if (n === 0) { if (n === 0) {
setPromptHints([]); setPromptHints([]);
} else if (text.startsWith(ChatCommandPrefix)) { } else if (text.match(ChatCommandPrefix)) {
setPromptHints(chatCommands.search(text)); setPromptHints(chatCommands.search(text));
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { } else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion // check if need to trigger auto completion
@ -1270,6 +1401,8 @@ function _Chat() {
<IconButton <IconButton
icon={<RenameIcon />} icon={<RenameIcon />}
bordered bordered
title={Locale.Chat.EditMessage.Title}
aria={Locale.Chat.EditMessage.Title}
onClick={() => setIsEditingMessage(true)} onClick={() => setIsEditingMessage(true)}
/> />
</div> </div>
@ -1289,6 +1422,8 @@ function _Chat() {
<IconButton <IconButton
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />} icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered bordered
title={Locale.Chat.Actions.FullScreen}
aria={Locale.Chat.Actions.FullScreen}
onClick={() => { onClick={() => {
config.update( config.update(
(config) => (config.tightBorder = !config.tightBorder), (config) => (config.tightBorder = !config.tightBorder),
@ -1340,6 +1475,7 @@ function _Chat() {
<div className={styles["chat-message-edit"]}> <div className={styles["chat-message-edit"]}>
<IconButton <IconButton
icon={<EditIcon />} icon={<EditIcon />}
aria={Locale.Chat.Actions.Edit}
onClick={async () => { onClick={async () => {
const newMessage = await showPrompt( const newMessage = await showPrompt(
Locale.Chat.Actions.Edit, Locale.Chat.Actions.Edit,
@ -1388,6 +1524,11 @@ function _Chat() {
</> </>
)} )}
</div> </div>
{!isUser && (
<div className={styles["chat-model-name"]}>
{message.model}
</div>
)}
{showActions && ( {showActions && (
<div className={styles["chat-message-actions"]}> <div className={styles["chat-message-actions"]}>
@ -1439,6 +1580,7 @@ function _Chat() {
)} )}
<div className={styles["chat-message-item"]}> <div className={styles["chat-message-item"]}>
<Markdown <Markdown
key={message.streaming ? "loading" : "done"}
content={getMessageTextContent(message)} content={getMessageTextContent(message)}
loading={ loading={
(message.preview || message.streaming) && (message.preview || message.streaming) &&
@ -1451,6 +1593,7 @@ function _Chat() {
setUserInput(getMessageTextContent(message)); setUserInput(getMessageTextContent(message));
}} }}
fontSize={fontSize} fontSize={fontSize}
fontFamily={fontFamily}
parentRef={scrollRef} parentRef={scrollRef}
defaultShow={i >= messages.length - 6} defaultShow={i >= messages.length - 6}
/> />
@ -1545,6 +1688,7 @@ function _Chat() {
autoFocus={autoFocus} autoFocus={autoFocus}
style={{ style={{
fontSize: config.fontSize, fontSize: config.fontSize,
fontFamily: config.fontFamily,
}} }}
/> />
{attachImages.length != 0 && ( {attachImages.length != 0 && (

View File

@ -1,3 +1,5 @@
"use client";
import React from "react"; import React from "react";
import { IconButton } from "./button"; import { IconButton } from "./button";
import GithubIcon from "../icons/github.svg"; import GithubIcon from "../icons/github.svg";

View File

@ -541,7 +541,7 @@ export function ImagePreviewer(props: {
<div> <div>
<div className={styles["main-title"]}>NextChat</div> <div className={styles["main-title"]}>NextChat</div>
<div className={styles["sub-title"]}> <div className={styles["sub-title"]}>
github.com/Yidadaa/ChatGPT-Next-Web github.com/ChatGPTNextWeb/ChatGPT-Next-Web
</div> </div>
<div className={styles["icons"]}> <div className={styles["icons"]}>
<ExportAvatar avatar={config.avatar} /> <ExportAvatar avatar={config.avatar} />
@ -583,6 +583,7 @@ export function ImagePreviewer(props: {
<Markdown <Markdown
content={getMessageTextContent(m)} content={getMessageTextContent(m)}
fontSize={config.fontSize} fontSize={config.fontSize}
fontFamily={config.fontFamily}
defaultShow defaultShow
/> />
{getMessageImages(m).length == 1 && ( {getMessageImages(m).length == 1 && (

View File

@ -137,12 +137,18 @@
position: relative; position: relative;
padding-top: 20px; padding-top: 20px;
padding-bottom: 20px; padding-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
} }
.sidebar-logo { .sidebar-logo {
position: absolute; display: inline-flex;
right: 0; }
bottom: 18px;
.sidebar-title-container {
display: inline-flex;
flex-direction: column;
} }
.sidebar-title { .sidebar-title {

View File

@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
); );
} }
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
loading: () => <Loading noLogo />,
});
const Settings = dynamic(async () => (await import("./settings")).Settings, { const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />, loading: () => <Loading noLogo />,
}); });
@ -55,6 +59,10 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
loading: () => <Loading noLogo />, loading: () => <Loading noLogo />,
}); });
const Sd = dynamic(async () => (await import("./sd")).Sd, {
loading: () => <Loading noLogo />,
});
export function useSwitchTheme() { export function useSwitchTheme() {
const config = useAppConfig(); const config = useAppConfig();
@ -122,11 +130,23 @@ const loadAsyncGoogleFont = () => {
document.head.appendChild(linkEl); document.head.appendChild(linkEl);
}; };
export function WindowContent(props: { children: React.ReactNode }) {
return (
<div className={styles["window-content"]} id={SlotID.AppBody}>
{props?.children}
</div>
);
}
function Screen() { function Screen() {
const config = useAppConfig(); const config = useAppConfig();
const location = useLocation(); const location = useLocation();
const isArtifact = location.pathname.includes(Path.Artifacts);
const isHome = location.pathname === Path.Home; const isHome = location.pathname === Path.Home;
const isAuth = location.pathname === Path.Auth; const isAuth = location.pathname === Path.Auth;
const isSd = location.pathname === Path.Sd;
const isSdNew = location.pathname === Path.SdNew;
const isMobileScreen = useMobileScreen(); const isMobileScreen = useMobileScreen();
const shouldTightBorder = const shouldTightBorder =
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen); getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
@ -135,34 +155,40 @@ function Screen() {
loadAsyncGoogleFont(); loadAsyncGoogleFont();
}, []); }, []);
if (isArtifact) {
return (
<Routes>
<Route path="/artifacts/:id" element={<Artifacts />} />
</Routes>
);
}
const renderContent = () => {
if (isAuth) return <AuthPage />;
if (isSd) return <Sd />;
if (isSdNew) return <Sd />;
return (
<>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<WindowContent>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</WindowContent>
</>
);
};
return ( return (
<div <div
className={ className={`${styles.container} ${
styles.container + shouldTightBorder ? styles["tight-container"] : styles.container
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${ } ${getLang() === "ar" ? styles["rtl-screen"] : ""}`}
getLang() === "ar" ? styles["rtl-screen"] : ""
}`
}
> >
{isAuth ? ( {renderContent()}
<>
<AuthPage />
</>
) : (
<>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div className={styles["window-content"]} id={SlotID.AppBody}>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route path={Path.NewChat} element={<NewChat />} />
<Route path={Path.Masks} element={<MaskPage />} />
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</div>
</>
)}
</div> </div>
); );
} }

View File

@ -9,6 +9,7 @@ interface InputRangeProps {
min: string; min: string;
max: string; max: string;
step: string; step: string;
aria: string;
} }
export function InputRange({ export function InputRange({
@ -19,11 +20,13 @@ export function InputRange({
min, min,
max, max,
step, step,
aria,
}: InputRangeProps) { }: InputRangeProps) {
return ( return (
<div className={styles["input-range"] + ` ${className ?? ""}`}> <div className={styles["input-range"] + ` ${className ?? ""}`}>
{title || value} {title || value}
<input <input
aria-label={aria}
type="range" type="range"
title={title} title={title}
value={value} value={value}

View File

@ -6,14 +6,16 @@ import RehypeKatex from "rehype-katex";
import RemarkGfm from "remark-gfm"; import RemarkGfm from "remark-gfm";
import RehypeHighlight from "rehype-highlight"; import RehypeHighlight from "rehype-highlight";
import { useRef, useState, RefObject, useEffect, useMemo } from "react"; import { useRef, useState, RefObject, useEffect, useMemo } from "react";
import { copyToClipboard } from "../utils"; import { copyToClipboard, useWindowSize } from "../utils";
import mermaid from "mermaid"; import mermaid from "mermaid";
import LoadingIcon from "../icons/three-dots.svg"; import LoadingIcon from "../icons/three-dots.svg";
import React from "react"; import React from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { showImageModal } from "./ui-lib"; import { showImageModal, FullScreen } from "./ui-lib";
import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
import { Plugin } from "../constant";
import { useChatStore } from "../store";
export function Mermaid(props: { code: string }) { export function Mermaid(props: { code: string }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
@ -64,25 +66,64 @@ export function PreCode(props: { children: any }) {
const ref = useRef<HTMLPreElement>(null); const ref = useRef<HTMLPreElement>(null);
const refText = ref.current?.innerText; const refText = ref.current?.innerText;
const [mermaidCode, setMermaidCode] = useState(""); const [mermaidCode, setMermaidCode] = useState("");
const [htmlCode, setHtmlCode] = useState("");
const { height } = useWindowSize();
const chatStore = useChatStore();
const session = chatStore.currentSession();
const plugins = session.mask?.plugin;
const renderMermaid = useDebouncedCallback(() => { const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return; if (!ref.current) return;
const mermaidDom = ref.current.querySelector("code.language-mermaid"); const mermaidDom = ref.current.querySelector("code.language-mermaid");
if (mermaidDom) { if (mermaidDom) {
setMermaidCode((mermaidDom as HTMLElement).innerText); setMermaidCode((mermaidDom as HTMLElement).innerText);
} }
const htmlDom = ref.current.querySelector("code.language-html");
if (htmlDom) {
setHtmlCode((htmlDom as HTMLElement).innerText);
} else if (refText?.startsWith("<!DOCTYPE")) {
setHtmlCode(refText);
}
}, 600); }, 600);
useEffect(() => { useEffect(() => {
setTimeout(renderMermaid, 1); setTimeout(renderArtifacts, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refText]); }, [refText]);
const enableArtifacts = useMemo(
() => plugins?.includes(Plugin.Artifacts),
[plugins],
);
//Wrap the paragraph for plain-text
useEffect(() => {
if (ref.current) {
const codeElements = ref.current.querySelectorAll(
"code",
) as NodeListOf<HTMLElement>;
const wrapLanguages = [
"",
"md",
"markdown",
"text",
"txt",
"plaintext",
"tex",
"latex",
];
codeElements.forEach((codeElement) => {
let languageClass = codeElement.className.match(/language-(\w+)/);
let name = languageClass ? languageClass[1] : "";
if (wrapLanguages.includes(name)) {
codeElement.style.whiteSpace = "pre-wrap";
}
});
}
}, []);
return ( return (
<> <>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
<pre ref={ref}> <pre ref={ref}>
<span <span
className="copy-code-button" className="copy-code-button"
@ -95,6 +136,22 @@ export function PreCode(props: { children: any }) {
></span> ></span>
{props.children} {props.children}
</pre> </pre>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
{htmlCode.length > 0 && enableArtifacts && (
<FullScreen className="no-dark html" right={70}>
<ArtifactsShareButton
style={{ position: "absolute", right: 20, top: 10 }}
getCode={() => htmlCode}
/>
<HTMLPreview
code={htmlCode}
autoHeight={!document.fullscreenElement}
height={!document.fullscreenElement ? 600 : height}
/>
</FullScreen>
)}
</> </>
); );
} }
@ -175,6 +232,7 @@ export function Markdown(
content: string; content: string;
loading?: boolean; loading?: boolean;
fontSize?: number; fontSize?: number;
fontFamily?: string;
parentRef?: RefObject<HTMLDivElement>; parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean; defaultShow?: boolean;
} & React.DOMAttributes<HTMLDivElement>, } & React.DOMAttributes<HTMLDivElement>,
@ -186,6 +244,7 @@ export function Markdown(
className="markdown-body" className="markdown-body"
style={{ style={{
fontSize: `${props.fontSize ?? 14}px`, fontSize: `${props.fontSize ?? 14}px`,
fontFamily: props.fontFamily || "inherit",
}} }}
ref={mdRef} ref={mdRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}

View File

@ -127,6 +127,8 @@ export function MaskConfig(props: {
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
> >
<div <div
tabIndex={0}
aria-label={Locale.Mask.Config.Avatar}
onClick={() => setShowPicker(true)} onClick={() => setShowPicker(true)}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
@ -139,6 +141,7 @@ export function MaskConfig(props: {
</ListItem> </ListItem>
<ListItem title={Locale.Mask.Config.Name}> <ListItem title={Locale.Mask.Config.Name}>
<input <input
aria-label={Locale.Mask.Config.Name}
type="text" type="text"
value={props.mask.name} value={props.mask.name}
onInput={(e) => onInput={(e) =>
@ -153,6 +156,7 @@ export function MaskConfig(props: {
subTitle={Locale.Mask.Config.HideContext.SubTitle} subTitle={Locale.Mask.Config.HideContext.SubTitle}
> >
<input <input
aria-label={Locale.Mask.Config.HideContext.Title}
type="checkbox" type="checkbox"
checked={props.mask.hideContext} checked={props.mask.hideContext}
onChange={(e) => { onChange={(e) => {
@ -169,6 +173,7 @@ export function MaskConfig(props: {
subTitle={Locale.Mask.Config.Share.SubTitle} subTitle={Locale.Mask.Config.Share.SubTitle}
> >
<IconButton <IconButton
aria={Locale.Mask.Config.Share.Title}
icon={<CopyIcon />} icon={<CopyIcon />}
text={Locale.Mask.Config.Share.Action} text={Locale.Mask.Config.Share.Action}
onClick={copyMaskLink} onClick={copyMaskLink}
@ -182,6 +187,7 @@ export function MaskConfig(props: {
subTitle={Locale.Mask.Config.Sync.SubTitle} subTitle={Locale.Mask.Config.Sync.SubTitle}
> >
<input <input
aria-label={Locale.Mask.Config.Sync.Title}
type="checkbox" type="checkbox"
checked={props.mask.syncGlobalConfig} checked={props.mask.syncGlobalConfig}
onChange={async (e) => { onChange={async (e) => {

View File

@ -17,6 +17,7 @@ export function ModelConfigList(props: {
<> <>
<ListItem title={Locale.Settings.Model}> <ListItem title={Locale.Settings.Model}>
<Select <Select
aria-label={Locale.Settings.Model}
value={value} value={value}
onChange={(e) => { onChange={(e) => {
const [model, providerName] = e.currentTarget.value.split("@"); const [model, providerName] = e.currentTarget.value.split("@");
@ -40,6 +41,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.Temperature.SubTitle} subTitle={Locale.Settings.Temperature.SubTitle}
> >
<InputRange <InputRange
aria={Locale.Settings.Temperature.Title}
value={props.modelConfig.temperature?.toFixed(1)} value={props.modelConfig.temperature?.toFixed(1)}
min="0" min="0"
max="1" // lets limit it to 0-1 max="1" // lets limit it to 0-1
@ -59,6 +61,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.TopP.SubTitle} subTitle={Locale.Settings.TopP.SubTitle}
> >
<InputRange <InputRange
aria={Locale.Settings.TopP.Title}
value={(props.modelConfig.top_p ?? 1).toFixed(1)} value={(props.modelConfig.top_p ?? 1).toFixed(1)}
min="0" min="0"
max="1" max="1"
@ -78,6 +81,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.MaxTokens.SubTitle} subTitle={Locale.Settings.MaxTokens.SubTitle}
> >
<input <input
aria-label={Locale.Settings.MaxTokens.Title}
type="number" type="number"
min={1024} min={1024}
max={512000} max={512000}
@ -100,6 +104,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.PresencePenalty.SubTitle} subTitle={Locale.Settings.PresencePenalty.SubTitle}
> >
<InputRange <InputRange
aria={Locale.Settings.PresencePenalty.Title}
value={props.modelConfig.presence_penalty?.toFixed(1)} value={props.modelConfig.presence_penalty?.toFixed(1)}
min="-2" min="-2"
max="2" max="2"
@ -121,6 +126,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.FrequencyPenalty.SubTitle} subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
> >
<InputRange <InputRange
aria={Locale.Settings.FrequencyPenalty.Title}
value={props.modelConfig.frequency_penalty?.toFixed(1)} value={props.modelConfig.frequency_penalty?.toFixed(1)}
min="-2" min="-2"
max="2" max="2"
@ -142,6 +148,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle} subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
> >
<input <input
aria-label={Locale.Settings.InjectSystemPrompts.Title}
type="checkbox" type="checkbox"
checked={props.modelConfig.enableInjectSystemPrompts} checked={props.modelConfig.enableInjectSystemPrompts}
onChange={(e) => onChange={(e) =>
@ -159,6 +166,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.InputTemplate.SubTitle} subTitle={Locale.Settings.InputTemplate.SubTitle}
> >
<input <input
aria-label={Locale.Settings.InputTemplate.Title}
type="text" type="text"
value={props.modelConfig.template} value={props.modelConfig.template}
onChange={(e) => onChange={(e) =>
@ -175,6 +183,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.HistoryCount.SubTitle} subTitle={Locale.Settings.HistoryCount.SubTitle}
> >
<InputRange <InputRange
aria={Locale.Settings.HistoryCount.Title}
title={props.modelConfig.historyMessageCount.toString()} title={props.modelConfig.historyMessageCount.toString()}
value={props.modelConfig.historyMessageCount} value={props.modelConfig.historyMessageCount}
min="0" min="0"
@ -193,6 +202,7 @@ export function ModelConfigList(props: {
subTitle={Locale.Settings.CompressThreshold.SubTitle} subTitle={Locale.Settings.CompressThreshold.SubTitle}
> >
<input <input
aria-label={Locale.Settings.CompressThreshold.Title}
type="number" type="number"
min={500} min={500}
max={4000} max={4000}
@ -208,6 +218,7 @@ export function ModelConfigList(props: {
</ListItem> </ListItem>
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}> <ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
<input <input
aria-label={Locale.Memory.Title}
type="checkbox" type="checkbox"
checked={props.modelConfig.sendMemory} checked={props.modelConfig.sendMemory}
onChange={(e) => onChange={(e) =>

View File

@ -0,0 +1,2 @@
export * from "./sd";
export * from "./sd-panel";

View File

@ -0,0 +1,45 @@
.ctrl-param-item {
display: flex;
justify-content: space-between;
min-height: 40px;
padding: 10px 0;
animation: slide-in ease 0.6s;
flex-direction: column;
.ctrl-param-item-header {
display: flex;
align-items: center;
.ctrl-param-item-title {
font-size: 14px;
font-weight: bolder;
margin-bottom: 5px;
}
}
.ctrl-param-item-sub-title {
font-size: 12px;
font-weight: normal;
margin-top: 3px;
}
textarea {
appearance: none;
border-radius: 10px;
border: var(--border-in-light);
min-height: 36px;
box-sizing: border-box;
background: var(--white);
color: var(--black);
padding: 0 10px;
max-width: 50%;
font-family: inherit;
}
}
.ai-models {
button {
margin-bottom: 10px;
padding: 10px;
width: 100%;
}
}

View File

@ -0,0 +1,320 @@
import styles from "./sd-panel.module.scss";
import React from "react";
import { Select } from "@/app/components/ui-lib";
import { IconButton } from "@/app/components/button";
import Locale from "@/app/locales";
import { useSdStore } from "@/app/store/sd";
export const params = [
{
name: Locale.SdPanel.Prompt,
value: "prompt",
type: "textarea",
placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.Prompt),
required: true,
},
{
name: Locale.SdPanel.ModelVersion,
value: "model",
type: "select",
default: "sd3-medium",
support: ["sd3"],
options: [
{ name: "SD3 Medium", value: "sd3-medium" },
{ name: "SD3 Large", value: "sd3-large" },
{ name: "SD3 Large Turbo", value: "sd3-large-turbo" },
],
},
{
name: Locale.SdPanel.NegativePrompt,
value: "negative_prompt",
type: "textarea",
placeholder: Locale.SdPanel.PleaseInput(Locale.SdPanel.NegativePrompt),
},
{
name: Locale.SdPanel.AspectRatio,
value: "aspect_ratio",
type: "select",
default: "1:1",
options: [
{ name: "1:1", value: "1:1" },
{ name: "16:9", value: "16:9" },
{ name: "21:9", value: "21:9" },
{ name: "2:3", value: "2:3" },
{ name: "3:2", value: "3:2" },
{ name: "4:5", value: "4:5" },
{ name: "5:4", value: "5:4" },
{ name: "9:16", value: "9:16" },
{ name: "9:21", value: "9:21" },
],
},
{
name: Locale.SdPanel.ImageStyle,
value: "style",
type: "select",
default: "3d-model",
support: ["core"],
options: [
{ name: Locale.SdPanel.Styles.D3Model, value: "3d-model" },
{ name: Locale.SdPanel.Styles.AnalogFilm, value: "analog-film" },
{ name: Locale.SdPanel.Styles.Anime, value: "anime" },
{ name: Locale.SdPanel.Styles.Cinematic, value: "cinematic" },
{ name: Locale.SdPanel.Styles.ComicBook, value: "comic-book" },
{ name: Locale.SdPanel.Styles.DigitalArt, value: "digital-art" },
{ name: Locale.SdPanel.Styles.Enhance, value: "enhance" },
{ name: Locale.SdPanel.Styles.FantasyArt, value: "fantasy-art" },
{ name: Locale.SdPanel.Styles.Isometric, value: "isometric" },
{ name: Locale.SdPanel.Styles.LineArt, value: "line-art" },
{ name: Locale.SdPanel.Styles.LowPoly, value: "low-poly" },
{
name: Locale.SdPanel.Styles.ModelingCompound,
value: "modeling-compound",
},
{ name: Locale.SdPanel.Styles.NeonPunk, value: "neon-punk" },
{ name: Locale.SdPanel.Styles.Origami, value: "origami" },
{ name: Locale.SdPanel.Styles.Photographic, value: "photographic" },
{ name: Locale.SdPanel.Styles.PixelArt, value: "pixel-art" },
{ name: Locale.SdPanel.Styles.TileTexture, value: "tile-texture" },
],
},
{
name: "Seed",
value: "seed",
type: "number",
default: 0,
min: 0,
max: 4294967294,
},
{
name: Locale.SdPanel.OutFormat,
value: "output_format",
type: "select",
default: "png",
options: [
{ name: "PNG", value: "png" },
{ name: "JPEG", value: "jpeg" },
{ name: "WebP", value: "webp" },
],
},
];
const sdCommonParams = (model: string, data: any) => {
return params.filter((item) => {
return !(item.support && !item.support.includes(model));
});
};
export const models = [
{
name: "Stable Image Ultra",
value: "ultra",
params: (data: any) => sdCommonParams("ultra", data),
},
{
name: "Stable Image Core",
value: "core",
params: (data: any) => sdCommonParams("core", data),
},
{
name: "Stable Diffusion 3",
value: "sd3",
params: (data: any) => {
return sdCommonParams("sd3", data).filter((item) => {
return !(
data.model === "sd3-large-turbo" && item.value == "negative_prompt"
);
});
},
},
];
export function ControlParamItem(props: {
title: string;
subTitle?: string;
required?: boolean;
children?: JSX.Element | JSX.Element[];
className?: string;
}) {
return (
<div className={styles["ctrl-param-item"] + ` ${props.className || ""}`}>
<div className={styles["ctrl-param-item-header"]}>
<div className={styles["ctrl-param-item-title"]}>
<div>
{props.title}
{props.required && <span style={{ color: "red" }}>*</span>}
</div>
</div>
</div>
{props.children}
{props.subTitle && (
<div className={styles["ctrl-param-item-sub-title"]}>
{props.subTitle}
</div>
)}
</div>
);
}
export function ControlParam(props: {
columns: any[];
data: any;
onChange: (field: string, val: any) => void;
}) {
return (
<>
{props.columns?.map((item) => {
let element: null | JSX.Element;
switch (item.type) {
case "textarea":
element = (
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<textarea
rows={item.rows || 3}
style={{ maxWidth: "100%", width: "100%", padding: "10px" }}
placeholder={item.placeholder}
onChange={(e) => {
props.onChange(item.value, e.currentTarget.value);
}}
value={props.data[item.value]}
></textarea>
</ControlParamItem>
);
break;
case "select":
element = (
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<Select
aria-label={item.name}
value={props.data[item.value]}
onChange={(e) => {
props.onChange(item.value, e.currentTarget.value);
}}
>
{item.options.map((opt: any) => {
return (
<option value={opt.value} key={opt.value}>
{opt.name}
</option>
);
})}
</Select>
</ControlParamItem>
);
break;
case "number":
element = (
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<input
aria-label={item.name}
type="number"
min={item.min}
max={item.max}
value={props.data[item.value] || 0}
onChange={(e) => {
props.onChange(item.value, parseInt(e.currentTarget.value));
}}
/>
</ControlParamItem>
);
break;
default:
element = (
<ControlParamItem
title={item.name}
subTitle={item.sub}
required={item.required}
>
<input
aria-label={item.name}
type="text"
value={props.data[item.value]}
style={{ maxWidth: "100%", width: "100%" }}
onChange={(e) => {
props.onChange(item.value, e.currentTarget.value);
}}
/>
</ControlParamItem>
);
}
return <div key={item.value}>{element}</div>;
})}
</>
);
}
export const getModelParamBasicData = (
columns: any[],
data: any,
clearText?: boolean,
) => {
const newParams: any = {};
columns.forEach((item: any) => {
if (clearText && ["text", "textarea", "number"].includes(item.type)) {
newParams[item.value] = item.default || "";
} else {
// @ts-ignore
newParams[item.value] = data[item.value] || item.default || "";
}
});
return newParams;
};
export const getParams = (model: any, params: any) => {
return models.find((m) => m.value === model.value)?.params(params) || [];
};
export function SdPanel() {
const sdStore = useSdStore();
const currentModel = sdStore.currentModel;
const setCurrentModel = sdStore.setCurrentModel;
const params = sdStore.currentParams;
const setParams = sdStore.setCurrentParams;
const handleValueChange = (field: string, val: any) => {
setParams({
...params,
[field]: val,
});
};
const handleModelChange = (model: any) => {
setCurrentModel(model);
setParams(getModelParamBasicData(model.params({}), params));
};
return (
<>
<ControlParamItem title={Locale.SdPanel.AIModel}>
<div className={styles["ai-models"]}>
{models.map((item) => {
return (
<IconButton
text={item.name}
key={item.value}
type={currentModel.value == item.value ? "primary" : null}
shadow
onClick={() => handleModelChange(item)}
/>
);
})}
</div>
</ControlParamItem>
<ControlParam
columns={getParams?.(currentModel, params) as any[]}
data={params}
onChange={handleValueChange}
></ControlParam>
</>
);
}

View File

@ -0,0 +1,140 @@
import { IconButton } from "@/app/components/button";
import GithubIcon from "@/app/icons/github.svg";
import SDIcon from "@/app/icons/sd.svg";
import ReturnIcon from "@/app/icons/return.svg";
import HistoryIcon from "@/app/icons/history.svg";
import Locale from "@/app/locales";
import { Path, REPO_URL } from "@/app/constant";
import { useNavigate } from "react-router-dom";
import dynamic from "next/dynamic";
import {
SideBarContainer,
SideBarBody,
SideBarHeader,
SideBarTail,
useDragSideBar,
useHotKey,
} from "@/app/components/sidebar";
import { getParams, getModelParamBasicData } from "./sd-panel";
import { useSdStore } from "@/app/store/sd";
import { showToast } from "@/app/components/ui-lib";
import { useMobileScreen } from "@/app/utils";
const SdPanel = dynamic(
async () => (await import("@/app/components/sd")).SdPanel,
{
loading: () => null,
},
);
export function SideBar(props: { className?: string }) {
useHotKey();
const isMobileScreen = useMobileScreen();
const { onDragStart, shouldNarrow } = useDragSideBar();
const navigate = useNavigate();
const sdStore = useSdStore();
const currentModel = sdStore.currentModel;
const params = sdStore.currentParams;
const setParams = sdStore.setCurrentParams;
const handleSubmit = () => {
const columns = getParams?.(currentModel, params);
const reqParams: any = {};
for (let i = 0; i < columns.length; i++) {
const item = columns[i];
reqParams[item.value] = params[item.value] ?? null;
if (item.required) {
if (!reqParams[item.value]) {
showToast(Locale.SdPanel.ParamIsRequired(item.name));
return;
}
}
}
let data: any = {
model: currentModel.value,
model_name: currentModel.name,
status: "wait",
params: reqParams,
created_at: new Date().toLocaleString(),
img_data: "",
};
sdStore.sendTask(data, () => {
setParams(getModelParamBasicData(columns, params, true));
navigate(Path.SdNew);
});
};
return (
<SideBarContainer
onDragStart={onDragStart}
shouldNarrow={shouldNarrow}
{...props}
>
{isMobileScreen ? (
<div
className="window-header"
data-tauri-drag-region
style={{
paddingLeft: 0,
paddingRight: 0,
}}
>
<div className="window-actions">
<div className="window-action-button">
<IconButton
icon={<ReturnIcon />}
bordered
title={Locale.Sd.Actions.ReturnHome}
onClick={() => navigate(Path.Home)}
/>
</div>
</div>
<SDIcon width={50} height={50} />
<div className="window-actions">
<div className="window-action-button">
<IconButton
icon={<HistoryIcon />}
bordered
title={Locale.Sd.Actions.History}
onClick={() => navigate(Path.SdNew)}
/>
</div>
</div>
</div>
) : (
<SideBarHeader
title={
<IconButton
icon={<ReturnIcon />}
bordered
title={Locale.Sd.Actions.ReturnHome}
onClick={() => navigate(Path.Home)}
/>
}
logo={<SDIcon width={38} height={"100%"} />}
></SideBarHeader>
)}
<SideBarBody>
<SdPanel />
</SideBarBody>
<SideBarTail
primaryAction={
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
<IconButton icon={<GithubIcon />} shadow />
</a>
}
secondaryAction={
<IconButton
text={Locale.SdPanel.Submit}
type="primary"
shadow
onClick={handleSubmit}
></IconButton>
}
/>
</SideBarContainer>
);
}

View File

@ -0,0 +1,53 @@
.sd-img-list{
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.sd-img-item{
width: 48%;
.sd-img-item-info{
flex:1;
width: 100%;
overflow: hidden;
user-select: text;
p{
margin: 6px;
font-size: 12px;
}
.line-1{
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.pre-img{
display: flex;
width: 130px;
justify-content: center;
align-items: center;
background-color: var(--second);
border-radius: 10px;
}
.img{
width: 130px;
height: 130px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: all .3s;
&:hover{
opacity: .7;
}
}
&:not(:last-child){
margin-bottom: 20px;
}
}
}
@media only screen and (max-width: 600px) {
.sd-img-list{
.sd-img-item{
width: 100%;
}
}
}

336
app/components/sd/sd.tsx Normal file
View File

@ -0,0 +1,336 @@
import chatStyles from "@/app/components/chat.module.scss";
import styles from "@/app/components/sd/sd.module.scss";
import homeStyles from "@/app/components/home.module.scss";
import { IconButton } from "@/app/components/button";
import ReturnIcon from "@/app/icons/return.svg";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
copyToClipboard,
getMessageTextContent,
useMobileScreen,
} from "@/app/utils";
import { useNavigate, useLocation } from "react-router-dom";
import { useAppConfig } from "@/app/store";
import MinIcon from "@/app/icons/min.svg";
import MaxIcon from "@/app/icons/max.svg";
import { getClientConfig } from "@/app/config/client";
import { ChatAction } from "@/app/components/chat";
import DeleteIcon from "@/app/icons/clear.svg";
import CopyIcon from "@/app/icons/copy.svg";
import PromptIcon from "@/app/icons/prompt.svg";
import ResetIcon from "@/app/icons/reload.svg";
import { useSdStore } from "@/app/store/sd";
import LoadingIcon from "@/app/icons/three-dots.svg";
import ErrorIcon from "@/app/icons/delete.svg";
import SDIcon from "@/app/icons/sd.svg";
import { Property } from "csstype";
import {
showConfirm,
showImageModal,
showModal,
} from "@/app/components/ui-lib";
import { removeImage } from "@/app/utils/chat";
import { SideBar } from "./sd-sidebar";
import { WindowContent } from "@/app/components/home";
import { params } from "./sd-panel";
function getSdTaskStatus(item: any) {
let s: string;
let color: Property.Color | undefined = undefined;
switch (item.status) {
case "success":
s = Locale.Sd.Status.Success;
color = "green";
break;
case "error":
s = Locale.Sd.Status.Error;
color = "red";
break;
case "wait":
s = Locale.Sd.Status.Wait;
color = "yellow";
break;
case "running":
s = Locale.Sd.Status.Running;
color = "blue";
break;
default:
s = item.status.toUpperCase();
}
return (
<p className={styles["line-1"]} title={item.error} style={{ color: color }}>
<span>
{Locale.Sd.Status.Name}: {s}
</span>
{item.status === "error" && (
<span
className="clickable"
onClick={() => {
showModal({
title: Locale.Sd.Detail,
children: (
<div style={{ color: color, userSelect: "text" }}>
{item.error}
</div>
),
});
}}
>
- {item.error}
</span>
)}
</p>
);
}
export function Sd() {
const isMobileScreen = useMobileScreen();
const navigate = useNavigate();
const location = useLocation();
const clientConfig = useMemo(() => getClientConfig(), []);
const showMaxIcon = !isMobileScreen && !clientConfig?.isApp;
const config = useAppConfig();
const scrollRef = useRef<HTMLDivElement>(null);
const sdStore = useSdStore();
const [sdImages, setSdImages] = useState(sdStore.draw);
const isSd = location.pathname === Path.Sd;
useEffect(() => {
setSdImages(sdStore.draw);
}, [sdStore.currentId]);
return (
<>
<SideBar className={isSd ? homeStyles["sidebar-show"] : ""} />
<WindowContent>
<div className={chatStyles.chat} key={"1"}>
<div className="window-header" data-tauri-drag-region>
{isMobileScreen && (
<div className="window-actions">
<div className={"window-action-button"}>
<IconButton
icon={<ReturnIcon />}
bordered
title={Locale.Chat.Actions.ChatList}
onClick={() => navigate(Path.Sd)}
/>
</div>
</div>
)}
<div
className={`window-header-title ${chatStyles["chat-body-title"]}`}
>
<div className={`window-header-main-title`}>Stability AI</div>
<div className="window-header-sub-title">
{Locale.Sd.SubTitle(sdImages.length || 0)}
</div>
</div>
<div className="window-actions">
{showMaxIcon && (
<div className="window-action-button">
<IconButton
aria={Locale.Chat.Actions.FullScreen}
icon={config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
config.update(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
</div>
)}
{isMobileScreen && <SDIcon width={50} height={50} />}
</div>
</div>
<div className={chatStyles["chat-body"]} ref={scrollRef}>
<div className={styles["sd-img-list"]}>
{sdImages.length > 0 ? (
sdImages.map((item: any) => {
return (
<div
key={item.id}
style={{ display: "flex" }}
className={styles["sd-img-item"]}
>
{item.status === "success" ? (
<img
className={styles["img"]}
src={item.img_data}
alt={item.id}
onClick={(e) =>
showImageModal(
item.img_data,
true,
isMobileScreen
? { width: "100%", height: "fit-content" }
: { maxWidth: "100%", maxHeight: "100%" },
isMobileScreen
? { width: "100%", height: "fit-content" }
: { width: "100%", height: "100%" },
)
}
/>
) : item.status === "error" ? (
<div className={styles["pre-img"]}>
<ErrorIcon />
</div>
) : (
<div className={styles["pre-img"]}>
<LoadingIcon />
</div>
)}
<div
style={{ marginLeft: "10px" }}
className={styles["sd-img-item-info"]}
>
<p className={styles["line-1"]}>
{Locale.SdPanel.Prompt}:{" "}
<span
className="clickable"
title={item.params.prompt}
onClick={() => {
showModal({
title: Locale.Sd.Detail,
children: (
<div style={{ userSelect: "text" }}>
{item.params.prompt}
</div>
),
});
}}
>
{item.params.prompt}
</span>
</p>
<p>
{Locale.SdPanel.AIModel}: {item.model_name}
</p>
{getSdTaskStatus(item)}
<p>{item.created_at}</p>
<div className={chatStyles["chat-message-actions"]}>
<div className={chatStyles["chat-input-actions"]}>
<ChatAction
text={Locale.Sd.Actions.Params}
icon={<PromptIcon />}
onClick={() => {
showModal({
title: Locale.Sd.GenerateParams,
children: (
<div style={{ userSelect: "text" }}>
{Object.keys(item.params).map((key) => {
let label = key;
let value = item.params[key];
switch (label) {
case "prompt":
label = Locale.SdPanel.Prompt;
break;
case "negative_prompt":
label =
Locale.SdPanel.NegativePrompt;
break;
case "aspect_ratio":
label = Locale.SdPanel.AspectRatio;
break;
case "seed":
label = "Seed";
value = value || 0;
break;
case "output_format":
label = Locale.SdPanel.OutFormat;
value = value?.toUpperCase();
break;
case "style":
label = Locale.SdPanel.ImageStyle;
value = params
.find(
(item) =>
item.value === "style",
)
?.options?.find(
(item) => item.value === value,
)?.name;
break;
default:
break;
}
return (
<div
key={key}
style={{ margin: "10px" }}
>
<strong>{label}: </strong>
{value}
</div>
);
})}
</div>
),
});
}}
/>
<ChatAction
text={Locale.Sd.Actions.Copy}
icon={<CopyIcon />}
onClick={() =>
copyToClipboard(
getMessageTextContent({
role: "user",
content: item.params.prompt,
}),
)
}
/>
<ChatAction
text={Locale.Sd.Actions.Retry}
icon={<ResetIcon />}
onClick={() => {
const reqData = {
model: item.model,
model_name: item.model_name,
status: "wait",
params: { ...item.params },
created_at: new Date().toLocaleString(),
img_data: "",
};
sdStore.sendTask(reqData);
}}
/>
<ChatAction
text={Locale.Sd.Actions.Delete}
icon={<DeleteIcon />}
onClick={async () => {
if (
await showConfirm(Locale.Sd.Danger.Delete)
) {
// remove img_data + remove item in list
removeImage(item.img_data).finally(() => {
sdStore.draw = sdImages.filter(
(i: any) => i.id !== item.id,
);
sdStore.getNextId();
});
}
}}
/>
</div>
</div>
</div>
</div>
);
})
) : (
<div>{Locale.Sd.EmptyRecord}</div>
)}
</div>
</div>
</div>
</WindowContent>
</>
);
}

View File

@ -54,8 +54,10 @@ import {
Anthropic, Anthropic,
Azure, Azure,
Baidu, Baidu,
Tencent,
ByteDance, ByteDance,
Alibaba, Alibaba,
Moonshot,
Google, Google,
GoogleSafetySettingsThreshold, GoogleSafetySettingsThreshold,
OPENAI_BASE_URL, OPENAI_BASE_URL,
@ -65,6 +67,8 @@ import {
ServiceProvider, ServiceProvider,
SlotID, SlotID,
UPDATE_URL, UPDATE_URL,
Stability,
Iflytek,
} from "../constant"; } from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt"; import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error"; import { ErrorBoundary } from "./error";
@ -242,6 +246,7 @@ function DangerItems() {
subTitle={Locale.Settings.Danger.Reset.SubTitle} subTitle={Locale.Settings.Danger.Reset.SubTitle}
> >
<IconButton <IconButton
aria={Locale.Settings.Danger.Reset.Title}
text={Locale.Settings.Danger.Reset.Action} text={Locale.Settings.Danger.Reset.Action}
onClick={async () => { onClick={async () => {
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) { if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
@ -256,6 +261,7 @@ function DangerItems() {
subTitle={Locale.Settings.Danger.Clear.SubTitle} subTitle={Locale.Settings.Danger.Clear.SubTitle}
> >
<IconButton <IconButton
aria={Locale.Settings.Danger.Clear.Title}
text={Locale.Settings.Danger.Clear.Action} text={Locale.Settings.Danger.Clear.Action}
onClick={async () => { onClick={async () => {
if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) { if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
@ -509,6 +515,7 @@ function SyncItems() {
> >
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<IconButton <IconButton
aria={Locale.Settings.Sync.CloudState + Locale.UI.Config}
icon={<ConfigIcon />} icon={<ConfigIcon />}
text={Locale.UI.Config} text={Locale.UI.Config}
onClick={() => { onClick={() => {
@ -539,6 +546,7 @@ function SyncItems() {
> >
<div style={{ display: "flex" }}> <div style={{ display: "flex" }}>
<IconButton <IconButton
aria={Locale.Settings.Sync.LocalState + Locale.UI.Export}
icon={<UploadIcon />} icon={<UploadIcon />}
text={Locale.UI.Export} text={Locale.UI.Export}
onClick={() => { onClick={() => {
@ -546,6 +554,7 @@ function SyncItems() {
}} }}
/> />
<IconButton <IconButton
aria={Locale.Settings.Sync.LocalState + Locale.UI.Import}
icon={<DownloadIcon />} icon={<DownloadIcon />}
text={Locale.UI.Import} text={Locale.UI.Import}
onClick={() => { onClick={() => {
@ -683,6 +692,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle} subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.CustomEndpoint.Title}
type="checkbox" type="checkbox"
checked={accessStore.useCustomConfig} checked={accessStore.useCustomConfig}
onChange={(e) => onChange={(e) =>
@ -702,6 +712,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle} subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.OpenAI.Endpoint.Title}
type="text" type="text"
value={accessStore.openaiUrl} value={accessStore.openaiUrl}
placeholder={OPENAI_BASE_URL} placeholder={OPENAI_BASE_URL}
@ -717,6 +728,8 @@ export function Settings() {
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle} subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria={Locale.Settings.ShowPassword}
aria-label={Locale.Settings.Access.OpenAI.ApiKey.Title}
value={accessStore.openaiApiKey} value={accessStore.openaiApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder} placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
@ -740,6 +753,7 @@ export function Settings() {
} }
> >
<input <input
aria-label={Locale.Settings.Access.Azure.Endpoint.Title}
type="text" type="text"
value={accessStore.azureUrl} value={accessStore.azureUrl}
placeholder={Azure.ExampleEndpoint} placeholder={Azure.ExampleEndpoint}
@ -755,6 +769,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle} subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.Azure.ApiKey.Title}
value={accessStore.azureApiKey} value={accessStore.azureApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.Azure.ApiKey.Placeholder} placeholder={Locale.Settings.Access.Azure.ApiKey.Placeholder}
@ -770,6 +785,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle} subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.Azure.ApiVerion.Title}
type="text" type="text"
value={accessStore.azureApiVersion} value={accessStore.azureApiVersion}
placeholder="2023-08-01-preview" placeholder="2023-08-01-preview"
@ -794,6 +810,7 @@ export function Settings() {
} }
> >
<input <input
aria-label={Locale.Settings.Access.Google.Endpoint.Title}
type="text" type="text"
value={accessStore.googleUrl} value={accessStore.googleUrl}
placeholder={Google.ExampleEndpoint} placeholder={Google.ExampleEndpoint}
@ -809,6 +826,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle} subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.Google.ApiKey.Title}
value={accessStore.googleApiKey} value={accessStore.googleApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder} placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
@ -824,6 +842,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle} subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.Google.ApiVersion.Title}
type="text" type="text"
value={accessStore.googleApiVersion} value={accessStore.googleApiVersion}
placeholder="2023-08-01-preview" placeholder="2023-08-01-preview"
@ -839,6 +858,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Google.GoogleSafetySettings.SubTitle} subTitle={Locale.Settings.Access.Google.GoogleSafetySettings.SubTitle}
> >
<Select <Select
aria-label={Locale.Settings.Access.Google.GoogleSafetySettings.Title}
value={accessStore.googleSafetySettings} value={accessStore.googleSafetySettings}
onChange={(e) => { onChange={(e) => {
accessStore.update( accessStore.update(
@ -869,6 +889,7 @@ export function Settings() {
} }
> >
<input <input
aria-label={Locale.Settings.Access.Anthropic.Endpoint.Title}
type="text" type="text"
value={accessStore.anthropicUrl} value={accessStore.anthropicUrl}
placeholder={Anthropic.ExampleEndpoint} placeholder={Anthropic.ExampleEndpoint}
@ -884,6 +905,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle} subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.Anthropic.ApiKey.Title}
value={accessStore.anthropicApiKey} value={accessStore.anthropicApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.Anthropic.ApiKey.Placeholder} placeholder={Locale.Settings.Access.Anthropic.ApiKey.Placeholder}
@ -899,6 +921,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Anthropic.ApiVerion.SubTitle} subTitle={Locale.Settings.Access.Anthropic.ApiVerion.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.Anthropic.ApiVerion.Title}
type="text" type="text"
value={accessStore.anthropicApiVersion} value={accessStore.anthropicApiVersion}
placeholder={Anthropic.Vision} placeholder={Anthropic.Vision}
@ -920,6 +943,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Baidu.Endpoint.SubTitle} subTitle={Locale.Settings.Access.Baidu.Endpoint.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.Baidu.Endpoint.Title}
type="text" type="text"
value={accessStore.baiduUrl} value={accessStore.baiduUrl}
placeholder={Baidu.ExampleEndpoint} placeholder={Baidu.ExampleEndpoint}
@ -935,6 +959,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Baidu.ApiKey.SubTitle} subTitle={Locale.Settings.Access.Baidu.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.Baidu.ApiKey.Title}
value={accessStore.baiduApiKey} value={accessStore.baiduApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.Baidu.ApiKey.Placeholder} placeholder={Locale.Settings.Access.Baidu.ApiKey.Placeholder}
@ -950,6 +975,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Baidu.SecretKey.SubTitle} subTitle={Locale.Settings.Access.Baidu.SecretKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.Baidu.SecretKey.Title}
value={accessStore.baiduSecretKey} value={accessStore.baiduSecretKey}
type="text" type="text"
placeholder={Locale.Settings.Access.Baidu.SecretKey.Placeholder} placeholder={Locale.Settings.Access.Baidu.SecretKey.Placeholder}
@ -963,6 +989,60 @@ export function Settings() {
</> </>
); );
const tencentConfigComponent = accessStore.provider ===
ServiceProvider.Tencent && (
<>
<ListItem
title={Locale.Settings.Access.Tencent.Endpoint.Title}
subTitle={Locale.Settings.Access.Tencent.Endpoint.SubTitle}
>
<input
aria-label={Locale.Settings.Access.Tencent.Endpoint.Title}
type="text"
value={accessStore.tencentUrl}
placeholder={Tencent.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) => (access.tencentUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Tencent.ApiKey.Title}
subTitle={Locale.Settings.Access.Tencent.ApiKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Tencent.ApiKey.Title}
value={accessStore.tencentSecretId}
type="text"
placeholder={Locale.Settings.Access.Tencent.ApiKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.tencentSecretId = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Tencent.SecretKey.Title}
subTitle={Locale.Settings.Access.Tencent.SecretKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Tencent.SecretKey.Title}
value={accessStore.tencentSecretKey}
type="text"
placeholder={Locale.Settings.Access.Tencent.SecretKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.tencentSecretKey = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
const byteDanceConfigComponent = accessStore.provider === const byteDanceConfigComponent = accessStore.provider ===
ServiceProvider.ByteDance && ( ServiceProvider.ByteDance && (
<> <>
@ -974,6 +1054,7 @@ export function Settings() {
} }
> >
<input <input
aria-label={Locale.Settings.Access.ByteDance.Endpoint.Title}
type="text" type="text"
value={accessStore.bytedanceUrl} value={accessStore.bytedanceUrl}
placeholder={ByteDance.ExampleEndpoint} placeholder={ByteDance.ExampleEndpoint}
@ -989,6 +1070,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.ByteDance.ApiKey.SubTitle} subTitle={Locale.Settings.Access.ByteDance.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.ByteDance.ApiKey.Title}
value={accessStore.bytedanceApiKey} value={accessStore.bytedanceApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.ByteDance.ApiKey.Placeholder} placeholder={Locale.Settings.Access.ByteDance.ApiKey.Placeholder}
@ -1013,6 +1095,7 @@ export function Settings() {
} }
> >
<input <input
aria-label={Locale.Settings.Access.Alibaba.Endpoint.Title}
type="text" type="text"
value={accessStore.alibabaUrl} value={accessStore.alibabaUrl}
placeholder={Alibaba.ExampleEndpoint} placeholder={Alibaba.ExampleEndpoint}
@ -1028,6 +1111,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Alibaba.ApiKey.SubTitle} subTitle={Locale.Settings.Access.Alibaba.ApiKey.SubTitle}
> >
<PasswordInput <PasswordInput
aria-label={Locale.Settings.Access.Alibaba.ApiKey.Title}
value={accessStore.alibabaApiKey} value={accessStore.alibabaApiKey}
type="text" type="text"
placeholder={Locale.Settings.Access.Alibaba.ApiKey.Placeholder} placeholder={Locale.Settings.Access.Alibaba.ApiKey.Placeholder}
@ -1041,6 +1125,145 @@ export function Settings() {
</> </>
); );
const moonshotConfigComponent = accessStore.provider ===
ServiceProvider.Moonshot && (
<>
<ListItem
title={Locale.Settings.Access.Moonshot.Endpoint.Title}
subTitle={
Locale.Settings.Access.Moonshot.Endpoint.SubTitle +
Moonshot.ExampleEndpoint
}
>
<input
aria-label={Locale.Settings.Access.Moonshot.Endpoint.Title}
type="text"
value={accessStore.moonshotUrl}
placeholder={Moonshot.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) => (access.moonshotUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Moonshot.ApiKey.Title}
subTitle={Locale.Settings.Access.Moonshot.ApiKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Moonshot.ApiKey.Title}
value={accessStore.moonshotApiKey}
type="text"
placeholder={Locale.Settings.Access.Moonshot.ApiKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.moonshotApiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
const stabilityConfigComponent = accessStore.provider ===
ServiceProvider.Stability && (
<>
<ListItem
title={Locale.Settings.Access.Stability.Endpoint.Title}
subTitle={
Locale.Settings.Access.Stability.Endpoint.SubTitle +
Stability.ExampleEndpoint
}
>
<input
aria-label={Locale.Settings.Access.Stability.Endpoint.Title}
type="text"
value={accessStore.stabilityUrl}
placeholder={Stability.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) => (access.stabilityUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Stability.ApiKey.Title}
subTitle={Locale.Settings.Access.Stability.ApiKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Stability.ApiKey.Title}
value={accessStore.stabilityApiKey}
type="text"
placeholder={Locale.Settings.Access.Stability.ApiKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.stabilityApiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
const lflytekConfigComponent = accessStore.provider ===
ServiceProvider.Iflytek && (
<>
<ListItem
title={Locale.Settings.Access.Iflytek.Endpoint.Title}
subTitle={
Locale.Settings.Access.Iflytek.Endpoint.SubTitle +
Iflytek.ExampleEndpoint
}
>
<input
aria-label={Locale.Settings.Access.Iflytek.Endpoint.Title}
type="text"
value={accessStore.iflytekUrl}
placeholder={Iflytek.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) => (access.iflytekUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Iflytek.ApiKey.Title}
subTitle={Locale.Settings.Access.Iflytek.ApiKey.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Iflytek.ApiKey.Title}
value={accessStore.iflytekApiKey}
type="text"
placeholder={Locale.Settings.Access.Iflytek.ApiKey.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.iflytekApiKey = e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Iflytek.ApiSecret.Title}
subTitle={Locale.Settings.Access.Iflytek.ApiSecret.SubTitle}
>
<PasswordInput
aria-label={Locale.Settings.Access.Iflytek.ApiSecret.Title}
value={accessStore.iflytekApiSecret}
type="text"
placeholder={Locale.Settings.Access.Iflytek.ApiSecret.Placeholder}
onChange={(e) => {
accessStore.update(
(access) => (access.iflytekApiSecret = e.currentTarget.value),
);
}}
/>
</ListItem>
</>
);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<div className="window-header" data-tauri-drag-region> <div className="window-header" data-tauri-drag-region>
@ -1057,6 +1280,7 @@ export function Settings() {
<div className="window-action-button"></div> <div className="window-action-button"></div>
<div className="window-action-button"> <div className="window-action-button">
<IconButton <IconButton
aria={Locale.UI.Close}
icon={<CloseIcon />} icon={<CloseIcon />}
onClick={() => navigate(Path.Home)} onClick={() => navigate(Path.Home)}
bordered bordered
@ -1080,6 +1304,8 @@ export function Settings() {
open={showEmojiPicker} open={showEmojiPicker}
> >
<div <div
aria-label={Locale.Settings.Avatar}
tabIndex={0}
className={styles.avatar} className={styles.avatar}
onClick={() => { onClick={() => {
setShowEmojiPicker(!showEmojiPicker); setShowEmojiPicker(!showEmojiPicker);
@ -1117,6 +1343,7 @@ export function Settings() {
<ListItem title={Locale.Settings.SendKey}> <ListItem title={Locale.Settings.SendKey}>
<Select <Select
aria-label={Locale.Settings.SendKey}
value={config.submitKey} value={config.submitKey}
onChange={(e) => { onChange={(e) => {
updateConfig( updateConfig(
@ -1135,6 +1362,7 @@ export function Settings() {
<ListItem title={Locale.Settings.Theme}> <ListItem title={Locale.Settings.Theme}>
<Select <Select
aria-label={Locale.Settings.Theme}
value={config.theme} value={config.theme}
onChange={(e) => { onChange={(e) => {
updateConfig( updateConfig(
@ -1152,6 +1380,7 @@ export function Settings() {
<ListItem title={Locale.Settings.Lang.Name}> <ListItem title={Locale.Settings.Lang.Name}>
<Select <Select
aria-label={Locale.Settings.Lang.Name}
value={getLang()} value={getLang()}
onChange={(e) => { onChange={(e) => {
changeLang(e.target.value as any); changeLang(e.target.value as any);
@ -1170,6 +1399,7 @@ export function Settings() {
subTitle={Locale.Settings.FontSize.SubTitle} subTitle={Locale.Settings.FontSize.SubTitle}
> >
<InputRange <InputRange
aria={Locale.Settings.FontSize.Title}
title={`${config.fontSize ?? 14}px`} title={`${config.fontSize ?? 14}px`}
value={config.fontSize} value={config.fontSize}
min="12" min="12"
@ -1184,11 +1414,29 @@ export function Settings() {
></InputRange> ></InputRange>
</ListItem> </ListItem>
<ListItem
title={Locale.Settings.FontFamily.Title}
subTitle={Locale.Settings.FontFamily.SubTitle}
>
<input
aria-label={Locale.Settings.FontFamily.Title}
type="text"
value={config.fontFamily}
placeholder={Locale.Settings.FontFamily.Placeholder}
onChange={(e) =>
updateConfig(
(config) => (config.fontFamily = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem <ListItem
title={Locale.Settings.AutoGenerateTitle.Title} title={Locale.Settings.AutoGenerateTitle.Title}
subTitle={Locale.Settings.AutoGenerateTitle.SubTitle} subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
> >
<input <input
aria-label={Locale.Settings.AutoGenerateTitle.Title}
type="checkbox" type="checkbox"
checked={config.enableAutoGenerateTitle} checked={config.enableAutoGenerateTitle}
onChange={(e) => onChange={(e) =>
@ -1205,6 +1453,7 @@ export function Settings() {
subTitle={Locale.Settings.SendPreviewBubble.SubTitle} subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
> >
<input <input
aria-label={Locale.Settings.SendPreviewBubble.Title}
type="checkbox" type="checkbox"
checked={config.sendPreviewBubble} checked={config.sendPreviewBubble}
onChange={(e) => onChange={(e) =>
@ -1225,6 +1474,7 @@ export function Settings() {
subTitle={Locale.Settings.Mask.Splash.SubTitle} subTitle={Locale.Settings.Mask.Splash.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Mask.Splash.Title}
type="checkbox" type="checkbox"
checked={!config.dontShowMaskSplashScreen} checked={!config.dontShowMaskSplashScreen}
onChange={(e) => onChange={(e) =>
@ -1242,6 +1492,7 @@ export function Settings() {
subTitle={Locale.Settings.Mask.Builtin.SubTitle} subTitle={Locale.Settings.Mask.Builtin.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Mask.Builtin.Title}
type="checkbox" type="checkbox"
checked={config.hideBuiltinMasks} checked={config.hideBuiltinMasks}
onChange={(e) => onChange={(e) =>
@ -1260,6 +1511,7 @@ export function Settings() {
subTitle={Locale.Settings.Prompt.Disable.SubTitle} subTitle={Locale.Settings.Prompt.Disable.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Prompt.Disable.Title}
type="checkbox" type="checkbox"
checked={config.disablePromptHint} checked={config.disablePromptHint}
onChange={(e) => onChange={(e) =>
@ -1279,6 +1531,7 @@ export function Settings() {
)} )}
> >
<IconButton <IconButton
aria={Locale.Settings.Prompt.List + Locale.Settings.Prompt.Edit}
icon={<EditIcon />} icon={<EditIcon />}
text={Locale.Settings.Prompt.Edit} text={Locale.Settings.Prompt.Edit}
onClick={() => setShowPromptModal(true)} onClick={() => setShowPromptModal(true)}
@ -1300,6 +1553,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.Provider.SubTitle} subTitle={Locale.Settings.Access.Provider.SubTitle}
> >
<Select <Select
aria-label={Locale.Settings.Access.Provider.Title}
value={accessStore.provider} value={accessStore.provider}
onChange={(e) => { onChange={(e) => {
accessStore.update( accessStore.update(
@ -1324,6 +1578,10 @@ export function Settings() {
{baiduConfigComponent} {baiduConfigComponent}
{byteDanceConfigComponent} {byteDanceConfigComponent}
{alibabaConfigComponent} {alibabaConfigComponent}
{tencentConfigComponent}
{moonshotConfigComponent}
{stabilityConfigComponent}
{lflytekConfigComponent}
</> </>
)} )}
</> </>
@ -1360,6 +1618,7 @@ export function Settings() {
subTitle={Locale.Settings.Access.CustomModel.SubTitle} subTitle={Locale.Settings.Access.CustomModel.SubTitle}
> >
<input <input
aria-label={Locale.Settings.Access.CustomModel.Title}
type="text" type="text"
value={config.customModels} value={config.customModels}
placeholder="model1,model2,model3" placeholder="model1,model2,model3"

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from "react"; import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
@ -10,8 +10,8 @@ import AddIcon from "../icons/add.svg";
import CloseIcon from "../icons/close.svg"; import CloseIcon from "../icons/close.svg";
import DeleteIcon from "../icons/delete.svg"; import DeleteIcon from "../icons/delete.svg";
import MaskIcon from "../icons/mask.svg"; import MaskIcon from "../icons/mask.svg";
import PluginIcon from "../icons/plugin.svg";
import DragIcon from "../icons/drag.svg"; import DragIcon from "../icons/drag.svg";
import DiscoveryIcon from "../icons/discovery.svg";
import Locale from "../locales"; import Locale from "../locales";
@ -23,19 +23,20 @@ import {
MIN_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH,
NARROW_SIDEBAR_WIDTH, NARROW_SIDEBAR_WIDTH,
Path, Path,
PLUGINS,
REPO_URL, REPO_URL,
} from "../constant"; } from "../constant";
import { Link, useNavigate } from "react-router-dom"; 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, showToast } from "./ui-lib"; import { showConfirm, Selector } from "./ui-lib";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null, loading: () => null,
}); });
function useHotKey() { export function useHotKey() {
const chatStore = useChatStore(); const chatStore = useChatStore();
useEffect(() => { useEffect(() => {
@ -54,7 +55,7 @@ function useHotKey() {
}); });
} }
function useDragSideBar() { export function useDragSideBar() {
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x); const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
const config = useAppConfig(); const config = useAppConfig();
@ -127,25 +128,21 @@ function useDragSideBar() {
shouldNarrow, shouldNarrow,
}; };
} }
export function SideBarContainer(props: {
export function SideBar(props: { className?: string }) { children: React.ReactNode;
const chatStore = useChatStore(); onDragStart: (e: MouseEvent) => void;
shouldNarrow: boolean;
// drag side bar className?: string;
const { onDragStart, shouldNarrow } = useDragSideBar(); }) {
const navigate = useNavigate();
const config = useAppConfig();
const isMobileScreen = useMobileScreen(); const isMobileScreen = useMobileScreen();
const isIOSMobile = useMemo( const isIOSMobile = useMemo(
() => isIOS() && isMobileScreen, () => isIOS() && isMobileScreen,
[isMobileScreen], [isMobileScreen],
); );
const { children, className, onDragStart, shouldNarrow } = props;
useHotKey();
return ( return (
<div <div
className={`${styles.sidebar} ${props.className} ${ className={`${styles.sidebar} ${className} ${
shouldNarrow && styles["narrow-sidebar"] shouldNarrow && styles["narrow-sidebar"]
}`} }`}
style={{ style={{
@ -153,43 +150,130 @@ export function SideBar(props: { className?: string }) {
transition: isMobileScreen && isIOSMobile ? "none" : undefined, transition: isMobileScreen && isIOSMobile ? "none" : undefined,
}} }}
> >
<div className={styles["sidebar-header"]} data-tauri-drag-region> {children}
<div className={styles["sidebar-title"]} data-tauri-drag-region>
NextChat
</div>
<div className={styles["sidebar-sub-title"]}>
Build your own AI assistant.
</div>
<div className={styles["sidebar-logo"] + " no-dark"}>
<ChatGptIcon />
</div>
</div>
<div className={styles["sidebar-header-bar"]}>
<IconButton
icon={<MaskIcon />}
text={shouldNarrow ? undefined : Locale.Mask.Name}
className={styles["sidebar-bar-button"]}
onClick={() => {
if (config.dontShowMaskSplashScreen !== true) {
navigate(Path.NewChat, { state: { fromHome: true } });
} else {
navigate(Path.Masks, { state: { fromHome: true } });
}
}}
shadow
/>
<IconButton
icon={<PluginIcon />}
text={shouldNarrow ? undefined : Locale.Plugin.Name}
className={styles["sidebar-bar-button"]}
onClick={() => showToast(Locale.WIP)}
shadow
/>
</div>
<div <div
className={styles["sidebar-body"]} className={styles["sidebar-drag"]}
onPointerDown={(e) => onDragStart(e as any)}
>
<DragIcon />
</div>
</div>
);
}
export function SideBarHeader(props: {
title?: string | React.ReactNode;
subTitle?: string | React.ReactNode;
logo?: React.ReactNode;
children?: React.ReactNode;
}) {
const { title, subTitle, logo, children } = props;
return (
<Fragment>
<div className={styles["sidebar-header"]} data-tauri-drag-region>
<div className={styles["sidebar-title-container"]}>
<div className={styles["sidebar-title"]} data-tauri-drag-region>
{title}
</div>
<div className={styles["sidebar-sub-title"]}>{subTitle}</div>
</div>
<div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
</div>
{children}
</Fragment>
);
}
export function SideBarBody(props: {
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}) {
const { onClick, children } = props;
return (
<div className={styles["sidebar-body"]} onClick={onClick}>
{children}
</div>
);
}
export function SideBarTail(props: {
primaryAction?: React.ReactNode;
secondaryAction?: React.ReactNode;
}) {
const { primaryAction, secondaryAction } = props;
return (
<div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}>{primaryAction}</div>
<div className={styles["sidebar-actions"]}>{secondaryAction}</div>
</div>
);
}
export function SideBar(props: { className?: string }) {
useHotKey();
const { onDragStart, shouldNarrow } = useDragSideBar();
const [showPluginSelector, setShowPluginSelector] = useState(false);
const navigate = useNavigate();
const config = useAppConfig();
const chatStore = useChatStore();
return (
<SideBarContainer
onDragStart={onDragStart}
shouldNarrow={shouldNarrow}
{...props}
>
<SideBarHeader
title="NextChat"
subTitle="Build your own AI assistant."
logo={<ChatGptIcon />}
>
<div className={styles["sidebar-header-bar"]}>
<IconButton
icon={<MaskIcon />}
text={shouldNarrow ? undefined : Locale.Mask.Name}
className={styles["sidebar-bar-button"]}
onClick={() => {
if (config.dontShowMaskSplashScreen !== true) {
navigate(Path.NewChat, { state: { fromHome: true } });
} else {
navigate(Path.Masks, { state: { fromHome: true } });
}
}}
shadow
/>
<IconButton
icon={<DiscoveryIcon />}
text={shouldNarrow ? undefined : Locale.Discovery.Name}
className={styles["sidebar-bar-button"]}
onClick={() => setShowPluginSelector(true)}
shadow
/>
</div>
{showPluginSelector && (
<Selector
items={[
{
title: "👇 Please select the plugin you need to use",
value: "-",
disable: true,
},
...PLUGINS.map((item) => {
return {
title: item.name,
value: item.path,
};
}),
]}
onClose={() => setShowPluginSelector(false)}
onSelection={(s) => {
navigate(s[0], { state: { fromHome: true } });
}}
/>
)}
</SideBarHeader>
<SideBarBody
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
navigate(Path.Home); navigate(Path.Home);
@ -197,32 +281,41 @@ export function SideBar(props: { className?: string }) {
}} }}
> >
<ChatList narrow={shouldNarrow} /> <ChatList narrow={shouldNarrow} />
</div> </SideBarBody>
<SideBarTail
<div className={styles["sidebar-tail"]}> primaryAction={
<div className={styles["sidebar-actions"]}> <>
<div className={styles["sidebar-action"] + " " + styles.mobile}> <div className={styles["sidebar-action"] + " " + styles.mobile}>
<IconButton <IconButton
icon={<DeleteIcon />} icon={<DeleteIcon />}
onClick={async () => { onClick={async () => {
if (await showConfirm(Locale.Home.DeleteChat)) { if (await showConfirm(Locale.Home.DeleteChat)) {
chatStore.deleteSession(chatStore.currentSessionIndex); chatStore.deleteSession(chatStore.currentSessionIndex);
} }
}} }}
/> />
</div> </div>
<div className={styles["sidebar-action"]}> <div className={styles["sidebar-action"]}>
<Link to={Path.Settings}> <Link to={Path.Settings}>
<IconButton icon={<SettingsIcon />} shadow /> <IconButton
</Link> aria={Locale.Settings.Title}
</div> icon={<SettingsIcon />}
<div className={styles["sidebar-action"]}> shadow
<a href={REPO_URL} target="_blank" rel="noopener noreferrer"> />
<IconButton icon={<GithubIcon />} shadow /> </Link>
</a> </div>
</div> <div className={styles["sidebar-action"]}>
</div> <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
<div> <IconButton
aria={Locale.Export.MessageFromChatGPT}
icon={<GithubIcon />}
shadow
/>
</a>
</div>
</>
}
secondaryAction={
<IconButton <IconButton
icon={<AddIcon />} icon={<AddIcon />}
text={shouldNarrow ? undefined : Locale.Home.NewChat} text={shouldNarrow ? undefined : Locale.Home.NewChat}
@ -236,15 +329,8 @@ export function SideBar(props: { className?: string }) {
}} }}
shadow shadow
/> />
</div> }
</div> />
</SideBarContainer>
<div
className={styles["sidebar-drag"]}
onPointerDown={(e) => onDragStart(e as any)}
>
<DragIcon />
</div>
</div>
); );
} }

View File

@ -61,6 +61,19 @@
font-weight: normal; font-weight: normal;
} }
} }
&.vertical{
flex-direction: column;
align-items: start;
.list-header{
.list-item-title{
margin-bottom: 5px;
}
.list-item-sub-title{
margin-bottom: 2px;
}
}
}
} }
.list { .list {
@ -291,7 +304,12 @@
justify-content: center; justify-content: center;
z-index: 999; z-index: 999;
.selector-item-disabled{
opacity: 0.6;
}
&-content { &-content {
min-width: 300px;
.list { .list {
max-height: 90vh; max-height: 90vh;
overflow-x: hidden; overflow-x: hidden;

View File

@ -13,7 +13,15 @@ import MinIcon from "../icons/min.svg";
import Locale from "../locales"; import Locale from "../locales";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import React, { HTMLProps, useEffect, useState } from "react"; import React, {
CSSProperties,
HTMLProps,
MouseEvent,
useEffect,
useState,
useCallback,
useRef,
} from "react";
import { IconButton } from "./button"; import { IconButton } from "./button";
export function Popover(props: { export function Popover(props: {
@ -47,11 +55,16 @@ export function ListItem(props: {
children?: JSX.Element | JSX.Element[]; children?: JSX.Element | JSX.Element[];
icon?: JSX.Element; icon?: JSX.Element;
className?: string; className?: string;
onClick?: () => void; onClick?: (e: MouseEvent) => void;
vertical?: boolean;
}) { }) {
return ( return (
<div <div
className={styles["list-item"] + ` ${props.className || ""}`} className={
styles["list-item"] +
` ${props.vertical ? styles["vertical"] : ""} ` +
` ${props.className || ""}`
}
onClick={props.onClick} onClick={props.onClick}
> >
<div className={styles["list-header"]}> <div className={styles["list-header"]}>
@ -252,9 +265,10 @@ export function Input(props: InputProps) {
); );
} }
export function PasswordInput(props: HTMLProps<HTMLInputElement>) { export function PasswordInput(
props: HTMLProps<HTMLInputElement> & { aria?: string },
) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
function changeVisibility() { function changeVisibility() {
setVisible(!visible); setVisible(!visible);
} }
@ -262,6 +276,7 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
return ( return (
<div className={"password-input-container"}> <div className={"password-input-container"}>
<IconButton <IconButton
aria={props.aria}
icon={visible ? <EyeIcon /> : <EyeOffIcon />} icon={visible ? <EyeIcon /> : <EyeOffIcon />}
onClick={changeVisibility} onClick={changeVisibility}
className={"password-eye"} className={"password-eye"}
@ -420,17 +435,25 @@ export function showPrompt(content: any, value = "", rows = 3) {
}); });
} }
export function showImageModal(img: string) { export function showImageModal(
img: string,
defaultMax?: boolean,
style?: CSSProperties,
boxStyle?: CSSProperties,
) {
showModal({ showModal({
title: Locale.Export.Image.Modal, title: Locale.Export.Image.Modal,
defaultMax: defaultMax,
children: ( children: (
<div> <div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
<img <img
src={img} src={img}
alt="preview" alt="preview"
style={{ style={
maxWidth: "100%", style ?? {
}} maxWidth: "100%",
}
}
></img> ></img>
</div> </div>
), ),
@ -442,27 +465,56 @@ export function Selector<T>(props: {
title: string; title: string;
subTitle?: string; subTitle?: string;
value: T; value: T;
disable?: boolean;
}>; }>;
defaultSelectedValue?: T; defaultSelectedValue?: T[] | T;
onSelection?: (selection: T[]) => void; onSelection?: (selection: T[]) => void;
onClose?: () => void; onClose?: () => void;
multiple?: boolean; multiple?: boolean;
}) { }) {
const [selectedValues, setSelectedValues] = useState<T[]>(
Array.isArray(props.defaultSelectedValue)
? props.defaultSelectedValue
: props.defaultSelectedValue !== undefined
? [props.defaultSelectedValue]
: [],
);
const handleSelection = (e: MouseEvent, value: T) => {
if (props.multiple) {
e.stopPropagation();
const newSelectedValues = selectedValues.includes(value)
? selectedValues.filter((v) => v !== value)
: [...selectedValues, value];
setSelectedValues(newSelectedValues);
props.onSelection?.(newSelectedValues);
} else {
setSelectedValues([value]);
props.onSelection?.([value]);
props.onClose?.();
}
};
return ( return (
<div className={styles["selector"]} onClick={() => props.onClose?.()}> <div className={styles["selector"]} onClick={() => props.onClose?.()}>
<div className={styles["selector-content"]}> <div className={styles["selector-content"]}>
<List> <List>
{props.items.map((item, i) => { {props.items.map((item, i) => {
const selected = props.defaultSelectedValue === item.value; const selected = selectedValues.includes(item.value);
return ( return (
<ListItem <ListItem
className={styles["selector-item"]} className={`${styles["selector-item"]} ${
item.disable && styles["selector-item-disabled"]
}`}
key={i} key={i}
title={item.title} title={item.title}
subTitle={item.subTitle} subTitle={item.subTitle}
onClick={() => { onClick={(e) => {
props.onSelection?.([item.value]); if (item.disable) {
props.onClose?.(); e.stopPropagation();
} else {
handleSelection(e, item.value);
}
}} }}
> >
{selected ? ( {selected ? (
@ -485,3 +537,38 @@ export function Selector<T>(props: {
</div> </div>
); );
} }
export function FullScreen(props: any) {
const { children, right = 10, top = 10, ...rest } = props;
const ref = useRef<HTMLDivElement>();
const [fullScreen, setFullScreen] = useState(false);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
ref.current?.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
useEffect(() => {
const handleScreenChange = (e: any) => {
if (e.target === ref.current) {
setFullScreen(!!document.fullscreenElement);
}
};
document.addEventListener("fullscreenchange", handleScreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleScreenChange);
};
}, []);
return (
<div ref={ref} style={{ position: "relative" }} {...rest}>
<div style={{ position: "absolute", right, top }}>
<IconButton
icon={fullScreen ? <MinIcon /> : <MaxIcon />}
onClick={toggleFullscreen}
bordered
/>
</div>
{children}
</div>
);
}

View File

@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
export function getClientConfig() { export function getClientConfig() {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
// client side // client side
return JSON.parse(queryMeta("config")) as BuildConfig; return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
} }
if (typeof process !== "undefined") { if (typeof process !== "undefined") {

View File

@ -23,6 +23,10 @@ declare global {
CUSTOM_MODELS?: string; // to control custom models CUSTOM_MODELS?: string; // to control custom models
DEFAULT_MODEL?: string; // to control default model in every new chat window DEFAULT_MODEL?: string; // to control default model in every new chat window
// stability only
STABILITY_URL?: string;
STABILITY_API_KEY?: string;
// azure only // azure only
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name} AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
AZURE_API_KEY?: string; AZURE_API_KEY?: string;
@ -53,6 +57,20 @@ declare global {
ALIBABA_URL?: string; ALIBABA_URL?: string;
ALIBABA_API_KEY?: string; ALIBABA_API_KEY?: string;
// tencent only
TENCENT_URL?: string;
TENCENT_SECRET_KEY?: string;
TENCENT_SECRET_ID?: string;
// moonshot only
MOONSHOT_URL?: string;
MOONSHOT_API_KEY?: string;
// iflytek only
IFLYTEK_URL?: string;
IFLYTEK_API_KEY?: string;
IFLYTEK_API_SECRET?: string;
// custom template for preprocessing user input // custom template for preprocessing user input
DEFAULT_INPUT_TEMPLATE?: string; DEFAULT_INPUT_TEMPLATE?: string;
} }
@ -101,19 +119,30 @@ export const getServerSideConfig = () => {
if (disableGPT4) { if (disableGPT4) {
if (customModels) customModels += ","; if (customModels) customModels += ",";
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4")) customModels += DEFAULT_MODELS.filter(
(m) => m.name.startsWith("gpt-4") && !m.name.startsWith("gpt-4o-mini"),
)
.map((m) => "-" + m.name) .map((m) => "-" + m.name)
.join(","); .join(",");
if (defaultModel.startsWith("gpt-4")) defaultModel = ""; if (
defaultModel.startsWith("gpt-4") &&
!defaultModel.startsWith("gpt-4o-mini")
)
defaultModel = "";
} }
const isStability = !!process.env.STABILITY_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;
const isTencent = !!process.env.TENCENT_API_KEY;
const isBaidu = !!process.env.BAIDU_API_KEY; const isBaidu = !!process.env.BAIDU_API_KEY;
const isBytedance = !!process.env.BYTEDANCE_API_KEY; const isBytedance = !!process.env.BYTEDANCE_API_KEY;
const isAlibaba = !!process.env.ALIBABA_API_KEY; const isAlibaba = !!process.env.ALIBABA_API_KEY;
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
const isIflytek = !!process.env.IFLYTEK_API_KEY;
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
// const randomIndex = Math.floor(Math.random() * apiKeys.length); // const randomIndex = Math.floor(Math.random() * apiKeys.length);
@ -131,6 +160,10 @@ 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,
isStability,
stabilityUrl: process.env.STABILITY_URL,
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
isAzure, isAzure,
azureUrl: process.env.AZURE_URL, azureUrl: process.env.AZURE_URL,
azureApiKey: getApiKey(process.env.AZURE_API_KEY), azureApiKey: getApiKey(process.env.AZURE_API_KEY),
@ -158,6 +191,25 @@ export const getServerSideConfig = () => {
alibabaUrl: process.env.ALIBABA_URL, alibabaUrl: process.env.ALIBABA_URL,
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY), alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
isTencent,
tencentUrl: process.env.TENCENT_URL,
tencentSecretKey: getApiKey(process.env.TENCENT_SECRET_KEY),
tencentSecretId: process.env.TENCENT_SECRET_ID,
isMoonshot,
moonshotUrl: process.env.MOONSHOT_URL,
moonshotApiKey: getApiKey(process.env.MOONSHOT_API_KEY),
isIflytek,
iflytekUrl: process.env.IFLYTEK_URL,
iflytekApiKey: process.env.IFLYTEK_API_KEY,
iflytekApiSecret: process.env.IFLYTEK_API_SECRET,
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
gtmId: process.env.GTM_ID, gtmId: process.env.GTM_ID,
gaId: process.env.GA_ID || DEFAULT_GA_ID, gaId: process.env.GA_ID || DEFAULT_GA_ID,

View File

@ -1,4 +1,4 @@
export const OWNER = "Yidadaa"; export const OWNER = "ChatGPTNextWeb";
export const REPO = "ChatGPT-Next-Web"; export const REPO = "ChatGPT-Next-Web";
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`; export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
@ -8,6 +8,8 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`; export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
export const RUNTIME_CONFIG_DOM = "danger-runtime-config"; export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export const STABILITY_BASE_URL = "https://api.stability.ai";
export const DEFAULT_API_HOST = "https://api.nextchat.dev"; export const DEFAULT_API_HOST = "https://api.nextchat.dev";
export const OPENAI_BASE_URL = "https://api.openai.com"; export const OPENAI_BASE_URL = "https://api.openai.com";
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
@ -21,6 +23,11 @@ export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com";
export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/"; export const ALIBABA_BASE_URL = "https://dashscope.aliyuncs.com/api/";
export const TENCENT_BASE_URL = "https://hunyuan.tencentcloudapi.com";
export const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
export const IFLYTEK_BASE_URL = "https://spark-api-open.xf-yun.com";
export const CACHE_URL_PREFIX = "/api/cache"; export const CACHE_URL_PREFIX = "/api/cache";
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`; export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
@ -31,6 +38,9 @@ export enum Path {
NewChat = "/new-chat", NewChat = "/new-chat",
Masks = "/masks", Masks = "/masks",
Auth = "/auth", Auth = "/auth",
Sd = "/sd",
SdNew = "/sd-new",
Artifacts = "/artifacts",
} }
export enum ApiPath { export enum ApiPath {
@ -42,6 +52,11 @@ export enum ApiPath {
Baidu = "/api/baidu", Baidu = "/api/baidu",
ByteDance = "/api/bytedance", ByteDance = "/api/bytedance",
Alibaba = "/api/alibaba", Alibaba = "/api/alibaba",
Tencent = "/api/tencent",
Moonshot = "/api/moonshot",
Iflytek = "/api/iflytek",
Stability = "/api/stability",
Artifacts = "/api/artifacts",
} }
export enum SlotID { export enum SlotID {
@ -54,6 +69,10 @@ export enum FileName {
Prompts = "prompts.json", Prompts = "prompts.json",
} }
export enum Plugin {
Artifacts = "artifacts",
}
export enum StoreKey { export enum StoreKey {
Chat = "chat-next-web-store", Chat = "chat-next-web-store",
Access = "access-control", Access = "access-control",
@ -62,6 +81,7 @@ export enum StoreKey {
Prompt = "prompt-store", Prompt = "prompt-store",
Update = "chat-update", Update = "chat-update",
Sync = "sync", Sync = "sync",
SdList = "sd-list",
} }
export const DEFAULT_SIDEBAR_WIDTH = 300; export const DEFAULT_SIDEBAR_WIDTH = 300;
@ -88,6 +108,10 @@ export enum ServiceProvider {
Baidu = "Baidu", Baidu = "Baidu",
ByteDance = "ByteDance", ByteDance = "ByteDance",
Alibaba = "Alibaba", Alibaba = "Alibaba",
Tencent = "Tencent",
Moonshot = "Moonshot",
Stability = "Stability",
Iflytek = "Iflytek",
} }
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings // Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
@ -100,14 +124,23 @@ export enum GoogleSafetySettingsThreshold {
} }
export enum ModelProvider { export enum ModelProvider {
Stability = "Stability",
GPT = "GPT", GPT = "GPT",
GeminiPro = "GeminiPro", GeminiPro = "GeminiPro",
Claude = "Claude", Claude = "Claude",
Ernie = "Ernie", Ernie = "Ernie",
Doubao = "Doubao", Doubao = "Doubao",
Qwen = "Qwen", Qwen = "Qwen",
Hunyuan = "Hunyuan",
Moonshot = "Moonshot",
Iflytek = "Iflytek",
} }
export const Stability = {
GeneratePath: "v2beta/stable-image/generate",
ExampleEndpoint: "https://api.stability.ai",
};
export const Anthropic = { export const Anthropic = {
ChatPath: "v1/messages", ChatPath: "v1/messages",
ChatPath1: "v1/complete", ChatPath1: "v1/complete",
@ -117,6 +150,7 @@ export const Anthropic = {
export const OpenaiPath = { export const OpenaiPath = {
ChatPath: "v1/chat/completions", ChatPath: "v1/chat/completions",
ImagePath: "v1/images/generations",
UsagePath: "dashboard/billing/usage", UsagePath: "dashboard/billing/usage",
SubsPath: "dashboard/billing/subscription", SubsPath: "dashboard/billing/subscription",
ListModelPath: "v1/models", ListModelPath: "v1/models",
@ -125,7 +159,10 @@ export const OpenaiPath = {
export const Azure = { export const Azure = {
ChatPath: (deployName: string, apiVersion: string) => ChatPath: (deployName: string, apiVersion: string) =>
`deployments/${deployName}/chat/completions?api-version=${apiVersion}`, `deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}", // https://<your_resource_name>.openai.azure.com/openai/deployments/<your_deployment_name>/images/generations?api-version=<api_version>
ImagePath: (deployName: string, apiVersion: string) =>
`deployments/${deployName}/images/generations?api-version=${apiVersion}`,
ExampleEndpoint: "https://{resource-url}/openai",
}; };
export const Google = { export const Google = {
@ -164,6 +201,20 @@ export const Alibaba = {
ChatPath: "v1/services/aigc/text-generation/generation", ChatPath: "v1/services/aigc/text-generation/generation",
}; };
export const Tencent = {
ExampleEndpoint: TENCENT_BASE_URL,
};
export const Moonshot = {
ExampleEndpoint: MOONSHOT_BASE_URL,
ChatPath: "v1/chat/completions",
};
export const Iflytek = {
ExampleEndpoint: IFLYTEK_BASE_URL,
ChatPath: "v1/chat/completions",
};
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
// export const DEFAULT_SYSTEM_TEMPLATE = ` // export const DEFAULT_SYSTEM_TEMPLATE = `
// You are ChatGPT, a large language model trained by {{ServiceProvider}}. // You are ChatGPT, a large language model trained by {{ServiceProvider}}.
@ -192,6 +243,7 @@ export const KnowledgeCutOffDate: Record<string, string> = {
"gpt-4-turbo-preview": "2023-12", "gpt-4-turbo-preview": "2023-12",
"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-mini": "2023-10", "gpt-4o-mini": "2023-10",
"gpt-4o-mini-2024-07-18": "2023-10", "gpt-4o-mini-2024-07-18": "2023-10",
"gpt-4-vision-preview": "2023-04", "gpt-4-vision-preview": "2023-04",
@ -213,11 +265,13 @@ const openaiModels = [
"gpt-4-turbo-preview", "gpt-4-turbo-preview",
"gpt-4o", "gpt-4o",
"gpt-4o-2024-05-13", "gpt-4o-2024-05-13",
"gpt-4o-2024-08-06",
"gpt-4o-mini", "gpt-4o-mini",
"gpt-4o-mini-2024-07-18", "gpt-4o-mini-2024-07-18",
"gpt-4-vision-preview", "gpt-4-vision-preview",
"gpt-4-turbo-2024-04-09", "gpt-4-turbo-2024-04-09",
"gpt-4-1106-preview", "gpt-4-1106-preview",
"dall-e-3",
]; ];
const googleModels = [ const googleModels = [
@ -270,68 +324,136 @@ const alibabaModes = [
"qwen-max-longcontext", "qwen-max-longcontext",
]; ];
const tencentModels = [
"hunyuan-pro",
"hunyuan-standard",
"hunyuan-lite",
"hunyuan-role",
"hunyuan-functioncall",
"hunyuan-code",
"hunyuan-vision",
];
const moonshotModes = ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"];
const iflytekModels = [
"general",
"generalv3",
"pro-128k",
"generalv3.5",
"4.0Ultra",
];
let seq = 1000; // 内置的模型序号生成器从1000开始
export const DEFAULT_MODELS = [ export const DEFAULT_MODELS = [
...openaiModels.map((name) => ({ ...openaiModels.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++, // Global sequence sort(index)
provider: { provider: {
id: "openai", id: "openai",
providerName: "OpenAI", providerName: "OpenAI",
providerType: "openai", providerType: "openai",
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
}, },
})), })),
...openaiModels.map((name) => ({ ...openaiModels.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "azure", id: "azure",
providerName: "Azure", providerName: "Azure",
providerType: "azure", providerType: "azure",
sorted: 2,
}, },
})), })),
...googleModels.map((name) => ({ ...googleModels.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "google", id: "google",
providerName: "Google", providerName: "Google",
providerType: "google", providerType: "google",
sorted: 3,
}, },
})), })),
...anthropicModels.map((name) => ({ ...anthropicModels.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "anthropic", id: "anthropic",
providerName: "Anthropic", providerName: "Anthropic",
providerType: "anthropic", providerType: "anthropic",
sorted: 4,
}, },
})), })),
...baiduModels.map((name) => ({ ...baiduModels.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "baidu", id: "baidu",
providerName: "Baidu", providerName: "Baidu",
providerType: "baidu", providerType: "baidu",
sorted: 5,
}, },
})), })),
...bytedanceModels.map((name) => ({ ...bytedanceModels.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "bytedance", id: "bytedance",
providerName: "ByteDance", providerName: "ByteDance",
providerType: "bytedance", providerType: "bytedance",
sorted: 6,
}, },
})), })),
...alibabaModes.map((name) => ({ ...alibabaModes.map((name) => ({
name, name,
available: true, available: true,
sorted: seq++,
provider: { provider: {
id: "alibaba", id: "alibaba",
providerName: "Alibaba", providerName: "Alibaba",
providerType: "alibaba", providerType: "alibaba",
sorted: 7,
},
})),
...tencentModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "tencent",
providerName: "Tencent",
providerType: "tencent",
sorted: 8,
},
})),
...moonshotModes.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "moonshot",
providerName: "Moonshot",
providerType: "moonshot",
sorted: 9,
},
})),
...iflytekModels.map((name) => ({
name,
available: true,
sorted: seq++,
provider: {
id: "iflytek",
providerName: "Iflytek",
providerType: "iflytek",
sorted: 10,
}, },
})), })),
] as const; ] as const;

7
app/icons/discovery.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" height="1.2rem" viewBox="0 0 24 24">
<g fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<circle cx="12" cy="12" r="9" />
<path
d="M11.307 9.739L15 9l-.739 3.693a2 2 0 0 1-1.568 1.569L9 15l.739-3.693a2 2 0 0 1 1.568-1.568" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

4
app/icons/hd.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-badge-hd" viewBox="0 0 16 16">
<path d="M7.396 11V5.001H6.209v2.44H3.687V5H2.5v6h1.187V8.43h2.522V11zM8.5 5.001V11h2.188c1.811 0 2.685-1.107 2.685-3.015 0-1.894-.86-2.984-2.684-2.984zm1.187.967h.843c1.112 0 1.622.686 1.622 2.04 0 1.353-.505 2.02-1.622 2.02h-.843z"/>
<path d="M14 3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

10
app/icons/history.svg Normal file
View File

@ -0,0 +1,10 @@
<?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="M5.81836 6.72729V14H13.0911" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M4 24C4 35.0457 12.9543 44 24 44V44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C16.598 4 10.1351 8.02111 6.67677 13.9981"
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M24.005 12L24.0038 24.0088L32.4832 32.4882" stroke="#333" stroke-width="4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 660 B

4
app/icons/palette.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#333" class="bi bi-palette" viewBox="0 0 16 16">
<path d="M8 5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m4 3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3M5.5 7a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m.5 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/>
<path d="M16 8c0 3.15-1.866 2.585-3.567 2.07C11.42 9.763 10.465 9.473 10 10c-.603.683-.475 1.819-.351 2.92C9.826 14.495 9.996 16 8 16a8 8 0 1 1 8-8m-8 7c.611 0 .654-.171.655-.176.078-.146.124-.464.07-1.119-.014-.168-.037-.37-.061-.591-.052-.464-.112-1.005-.118-1.462-.01-.707.083-1.61.704-2.314.369-.417.845-.578 1.272-.618.404-.038.812.026 1.16.104.343.077.702.186 1.025.284l.028.008c.346.105.658.199.953.266.653.148.904.083.991.024C14.717 9.38 15 9.161 15 8a7 7 0 1 0-7 7"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

12
app/icons/sd.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1.21em" height="1em" viewBox="0 0 256 213">
<defs>
<linearGradient id="logosStabilityAiIcon0" x1="50%" x2="50%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#9d39ff" />
<stop offset="100%" stop-color="#a380ff" />
</linearGradient>
</defs>
<path fill="url(#logosStabilityAiIcon0)"
d="M72.418 212.45c49.478 0 81.658-26.205 81.658-65.626c0-30.572-19.572-49.998-54.569-58.043l-22.469-6.74c-19.71-4.424-31.215-9.738-28.505-23.312c2.255-11.292 9.002-17.667 24.69-17.667c49.872 0 68.35 17.667 68.35 17.667V16.237S123.583 0 73.223 0C25.757 0 0 24.424 0 62.236c0 30.571 17.85 48.35 54.052 56.798q3.802.95 3.885.976q8.26 2.556 22.293 6.755c18.504 4.425 23.262 9.121 23.262 23.2c0 12.872-13.374 20.19-31.074 20.19C21.432 170.154 0 144.36 0 144.36v47.078s13.402 21.01 72.418 21.01" />
<path fill="#e80000"
d="M225.442 209.266c17.515 0 30.558-12.67 30.558-29.812c0-17.515-12.67-29.813-30.558-29.813c-17.515 0-30.185 12.298-30.185 29.813s12.67 29.812 30.185 29.812" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

@ -0,0 +1 @@
<?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="M42 7H6C4.89543 7 4 7.89543 4 9V39C4 40.1046 4.89543 41 6 41H42C43.1046 41 44 40.1046 44 39V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#333" stroke-width="4"/><path d="M30 30V18L38 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 30V18L18 30V18" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/><path d="M24 20V21" stroke="#333" stroke-width="4" stroke-linecap="round"/><path d="M24 27V28" stroke="#333" stroke-width="4" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 681 B

View File

@ -153,6 +153,11 @@ const ar: PartialLocaleType = {
Title: "حجم الخط", Title: "حجم الخط",
SubTitle: "حجم الخط في محتوى الدردشة", SubTitle: "حجم الخط في محتوى الدردشة",
}, },
FontFamily: {
Title: "خط الدردشة",
SubTitle: "خط محتوى الدردشة، اتركه فارغًا لتطبيق الخط الافتراضي العالمي",
Placeholder: "اسم الخط",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "حقن الرسائل النصية النظامية", Title: "حقن الرسائل النصية النظامية",
SubTitle: SubTitle:

View File

@ -153,6 +153,12 @@ const bn: PartialLocaleType = {
Title: "ফন্ট সাইজ", Title: "ফন্ট সাইজ",
SubTitle: "চ্যাট কনটেন্টের ফন্ট সাইজ", SubTitle: "চ্যাট কনটেন্টের ফন্ট সাইজ",
}, },
FontFamily: {
Title: "চ্যাট ফন্ট",
SubTitle:
"চ্যাট সামগ্রীর ফন্ট, বিশ্বব্যাপী ডিফল্ট ফন্ট প্রয়োগ করতে খালি রাখুন",
Placeholder: "ফন্টের নাম",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "সিস্টেম-লেভেল প্রম্পট যোগ করুন", Title: "সিস্টেম-লেভেল প্রম্পট যোগ করুন",
SubTitle: SubTitle:

View File

@ -42,6 +42,7 @@ const cn = {
PinToastAction: "查看", PinToastAction: "查看",
Delete: "删除", Delete: "删除",
Edit: "编辑", Edit: "编辑",
FullScreen: "全屏",
}, },
Commands: { Commands: {
new: "新建聊天", new: "新建聊天",
@ -104,6 +105,10 @@ const cn = {
Toast: "正在生成截图", Toast: "正在生成截图",
Modal: "长按或右键保存图片", Modal: "长按或右键保存图片",
}, },
Artifacts: {
Title: "分享页面",
Error: "分享失败",
},
}, },
Select: { Select: {
Search: "搜索消息", Search: "搜索消息",
@ -128,6 +133,7 @@ const cn = {
Settings: { Settings: {
Title: "设置", Title: "设置",
SubTitle: "所有设置选项", SubTitle: "所有设置选项",
ShowPassword: "显示密码",
Danger: { Danger: {
Reset: { Reset: {
@ -152,6 +158,11 @@ const cn = {
Title: "字体大小", Title: "字体大小",
SubTitle: "聊天内容的字体大小", SubTitle: "聊天内容的字体大小",
}, },
FontFamily: {
Title: "聊天字体",
SubTitle: "聊天内容的字体,若置空则应用全局默认字体",
Placeholder: "字体名称",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "注入系统级提示信息", Title: "注入系统级提示信息",
SubTitle: "强制给每次请求的消息列表开头添加一个模拟 ChatGPT 的系统提示", SubTitle: "强制给每次请求的消息列表开头添加一个模拟 ChatGPT 的系统提示",
@ -367,6 +378,22 @@ const cn = {
SubTitle: "不支持自定义前往.env配置", SubTitle: "不支持自定义前往.env配置",
}, },
}, },
Tencent: {
ApiKey: {
Title: "API Key",
SubTitle: "使用自定义腾讯云API Key",
Placeholder: "Tencent API Key",
},
SecretKey: {
Title: "Secret Key",
SubTitle: "使用自定义腾讯云Secret Key",
Placeholder: "Tencent Secret Key",
},
Endpoint: {
Title: "接口地址",
SubTitle: "不支持自定义前往.env配置",
},
},
ByteDance: { ByteDance: {
ApiKey: { ApiKey: {
Title: "接口密钥", Title: "接口密钥",
@ -389,6 +416,44 @@ const cn = {
SubTitle: "样例:", SubTitle: "样例:",
}, },
}, },
Moonshot: {
ApiKey: {
Title: "接口密钥",
SubTitle: "使用自定义月之暗面API Key",
Placeholder: "Moonshot API Key",
},
Endpoint: {
Title: "接口地址",
SubTitle: "样例:",
},
},
Stability: {
ApiKey: {
Title: "接口密钥",
SubTitle: "使用自定义 Stability API Key",
Placeholder: "Stability API Key",
},
Endpoint: {
Title: "接口地址",
SubTitle: "样例:",
},
},
Iflytek: {
ApiKey: {
Title: "ApiKey",
SubTitle: "从讯飞星火控制台获取的 APIKey",
Placeholder: "APIKey",
},
ApiSecret: {
Title: "ApiSecret",
SubTitle: "从讯飞星火控制台获取的 APISecret",
Placeholder: "APISecret",
},
Endpoint: {
Title: "接口地址",
SubTitle: "样例:",
},
},
CustomModel: { CustomModel: {
Title: "自定义模型名", Title: "自定义模型名",
SubTitle: "增加自定义模型可选项,使用英文逗号隔开", SubTitle: "增加自定义模型可选项,使用英文逗号隔开",
@ -446,6 +511,10 @@ const cn = {
}, },
Plugin: { Plugin: {
Name: "插件", Name: "插件",
Artifacts: "Artifacts",
},
Discovery: {
Name: "发现",
}, },
FineTuned: { FineTuned: {
Sysmessage: "你是一个助手", Sysmessage: "你是一个助手",
@ -526,6 +595,61 @@ const cn = {
Topic: "主题", Topic: "主题",
Time: "时间", Time: "时间",
}, },
SdPanel: {
Prompt: "画面提示",
NegativePrompt: "否定提示",
PleaseInput: (name: string) => `请输入${name}`,
AspectRatio: "横纵比",
ImageStyle: "图像风格",
OutFormat: "输出格式",
AIModel: "AI模型",
ModelVersion: "模型版本",
Submit: "提交生成",
ParamIsRequired: (name: string) => `${name}不能为空`,
Styles: {
D3Model: "3D模型",
AnalogFilm: "模拟电影",
Anime: "动漫",
Cinematic: "电影风格",
ComicBook: "漫画书",
DigitalArt: "数字艺术",
Enhance: "增强",
FantasyArt: "幻想艺术",
Isometric: "等角",
LineArt: "线描",
LowPoly: "低多边形",
ModelingCompound: "建模材料",
NeonPunk: "霓虹朋克",
Origami: "折纸",
Photographic: "摄影",
PixelArt: "像素艺术",
TileTexture: "贴图",
},
},
Sd: {
SubTitle: (count: number) => `${count} 条绘画`,
Actions: {
Params: "查看参数",
Copy: "复制提示词",
Delete: "删除",
Retry: "重试",
ReturnHome: "返回首页",
History: "查看历史",
},
EmptyRecord: "暂无绘画记录",
Status: {
Name: "状态",
Success: "成功",
Error: "失败",
Wait: "等待中",
Running: "运行中",
},
Danger: {
Delete: "确认删除?",
},
GenerateParams: "生成参数",
Detail: "详情",
},
}; };
type DeepPartial<T> = T extends object type DeepPartial<T> = T extends object

View File

@ -153,6 +153,12 @@ const cs: PartialLocaleType = {
Title: "Velikost písma", Title: "Velikost písma",
SubTitle: "Velikost písma pro obsah chatu", SubTitle: "Velikost písma pro obsah chatu",
}, },
FontFamily: {
Title: "Chatové Písmo",
SubTitle:
"Písmo obsahu chatu, ponechejte prázdné pro použití globálního výchozího písma",
Placeholder: "Název Písma",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Vložit systémové výzvy", Title: "Vložit systémové výzvy",
SubTitle: SubTitle:

View File

@ -155,6 +155,12 @@ const de: PartialLocaleType = {
Title: "Schriftgröße", Title: "Schriftgröße",
SubTitle: "Schriftgröße des Chat-Inhalts", SubTitle: "Schriftgröße des Chat-Inhalts",
}, },
FontFamily: {
Title: "Chat-Schriftart",
SubTitle:
"Schriftart des Chat-Inhalts, leer lassen, um die globale Standardschriftart anzuwenden",
Placeholder: "Schriftartname",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Systemweite Eingabeaufforderungen einfügen", Title: "Systemweite Eingabeaufforderungen einfügen",
SubTitle: SubTitle:

View File

@ -44,6 +44,7 @@ const en: LocaleType = {
PinToastAction: "View", PinToastAction: "View",
Delete: "Delete", Delete: "Delete",
Edit: "Edit", Edit: "Edit",
FullScreen: "FullScreen",
}, },
Commands: { Commands: {
new: "Start a new chat", new: "Start a new chat",
@ -106,6 +107,10 @@ const en: LocaleType = {
Toast: "Capturing Image...", Toast: "Capturing Image...",
Modal: "Long press or right click to save image", Modal: "Long press or right click to save image",
}, },
Artifacts: {
Title: "Share Artifacts",
Error: "Share Error",
},
}, },
Select: { Select: {
Search: "Search", Search: "Search",
@ -131,6 +136,7 @@ const en: LocaleType = {
Settings: { Settings: {
Title: "Settings", Title: "Settings",
SubTitle: "All Settings", SubTitle: "All Settings",
ShowPassword: "ShowPassword",
Danger: { Danger: {
Reset: { Reset: {
Title: "Reset All Settings", Title: "Reset All Settings",
@ -154,6 +160,12 @@ const en: LocaleType = {
Title: "Font Size", Title: "Font Size",
SubTitle: "Adjust font size of chat content", SubTitle: "Adjust font size of chat content",
}, },
FontFamily: {
Title: "Chat Font Family",
SubTitle:
"Font Family of the chat content, leave empty to apply global default font",
Placeholder: "Font Family Name",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Inject System Prompts", Title: "Inject System Prompts",
SubTitle: "Inject a global system prompt for every request", SubTitle: "Inject a global system prompt for every request",
@ -350,6 +362,22 @@ const en: LocaleType = {
SubTitle: "not supported, configure in .env", SubTitle: "not supported, configure in .env",
}, },
}, },
Tencent: {
ApiKey: {
Title: "Tencent API Key",
SubTitle: "Use a custom Tencent API Key",
Placeholder: "Tencent API Key",
},
SecretKey: {
Title: "Tencent Secret Key",
SubTitle: "Use a custom Tencent Secret Key",
Placeholder: "Tencent Secret Key",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "not supported, configure in .env",
},
},
ByteDance: { ByteDance: {
ApiKey: { ApiKey: {
Title: "ByteDance API Key", Title: "ByteDance API Key",
@ -372,6 +400,44 @@ const en: LocaleType = {
SubTitle: "Example: ", SubTitle: "Example: ",
}, },
}, },
Moonshot: {
ApiKey: {
Title: "Moonshot API Key",
SubTitle: "Use a custom Moonshot API Key",
Placeholder: "Moonshot API Key",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "Example: ",
},
},
Stability: {
ApiKey: {
Title: "Stability API Key",
SubTitle: "Use a custom Stability API Key",
Placeholder: "Stability API Key",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "Example: ",
},
},
Iflytek: {
ApiKey: {
Title: "Iflytek API Key",
SubTitle: "Use a Iflytek API Key",
Placeholder: "Iflytek API Key",
},
ApiSecret: {
Title: "Iflytek API Secret",
SubTitle: "Use a Iflytek API Secret",
Placeholder: "Iflytek API Secret",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "Example: ",
},
},
CustomModel: { CustomModel: {
Title: "Custom Models", Title: "Custom Models",
SubTitle: "Custom model options, seperated by comma", SubTitle: "Custom model options, seperated by comma",
@ -453,6 +519,10 @@ const en: LocaleType = {
}, },
Plugin: { Plugin: {
Name: "Plugin", Name: "Plugin",
Artifacts: "Artifacts",
},
Discovery: {
Name: "Discovery",
}, },
FineTuned: { FineTuned: {
Sysmessage: "You are an assistant that", Sysmessage: "You are an assistant that",
@ -528,11 +598,65 @@ const en: LocaleType = {
Topic: "Topic", Topic: "Topic",
Time: "Time", Time: "Time",
}, },
URLCommand: { URLCommand: {
Code: "Detected access code from url, confirm to apply? ", Code: "Detected access code from url, confirm to apply? ",
Settings: "Detected settings from url, confirm to apply?", Settings: "Detected settings from url, confirm to apply?",
}, },
SdPanel: {
Prompt: "Prompt",
NegativePrompt: "Negative Prompt",
PleaseInput: (name: string) => `Please input ${name}`,
AspectRatio: "Aspect Ratio",
ImageStyle: "Image Style",
OutFormat: "Output Format",
AIModel: "AI Model",
ModelVersion: "Model Version",
Submit: "Submit",
ParamIsRequired: (name: string) => `${name} is required`,
Styles: {
D3Model: "3d-model",
AnalogFilm: "analog-film",
Anime: "anime",
Cinematic: "cinematic",
ComicBook: "comic-book",
DigitalArt: "digital-art",
Enhance: "enhance",
FantasyArt: "fantasy-art",
Isometric: "isometric",
LineArt: "line-art",
LowPoly: "low-poly",
ModelingCompound: "modeling-compound",
NeonPunk: "neon-punk",
Origami: "origami",
Photographic: "photographic",
PixelArt: "pixel-art",
TileTexture: "tile-texture",
},
},
Sd: {
SubTitle: (count: number) => `${count} images`,
Actions: {
Params: "See Params",
Copy: "Copy Prompt",
Delete: "Delete",
Retry: "Retry",
ReturnHome: "Return Home",
History: "History",
},
EmptyRecord: "No images yet",
Status: {
Name: "Status",
Success: "Success",
Error: "Error",
Wait: "Waiting",
Running: "Running",
},
Danger: {
Delete: "Confirm to delete?",
},
GenerateParams: "Generate Params",
Detail: "Detail",
},
}; };
export default en; export default en;

View File

@ -159,6 +159,12 @@ const es: PartialLocaleType = {
Title: "Tamaño de fuente", Title: "Tamaño de fuente",
SubTitle: "Tamaño de la fuente del contenido del chat", SubTitle: "Tamaño de la fuente del contenido del chat",
}, },
FontFamily: {
Title: "Fuente del Chat",
SubTitle:
"Fuente del contenido del chat, dejar vacío para aplicar la fuente predeterminada global",
Placeholder: "Nombre de la Fuente",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Inyectar mensajes del sistema", Title: "Inyectar mensajes del sistema",
SubTitle: SubTitle:

View File

@ -157,6 +157,12 @@ const fr: PartialLocaleType = {
Title: "Taille de la police", Title: "Taille de la police",
SubTitle: "Taille de la police pour le contenu des discussions", SubTitle: "Taille de la police pour le contenu des discussions",
}, },
FontFamily: {
Title: "Police de Chat",
SubTitle:
"Police du contenu du chat, laissez vide pour appliquer la police par défaut globale",
Placeholder: "Nom de la Police",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Injecter des invites système", Title: "Injecter des invites système",
SubTitle: SubTitle:

View File

@ -154,6 +154,12 @@ const id: PartialLocaleType = {
Title: "Ukuran Font", Title: "Ukuran Font",
SubTitle: "Ukuran font untuk konten obrolan", SubTitle: "Ukuran font untuk konten obrolan",
}, },
FontFamily: {
Title: "Font Obrolan",
SubTitle:
"Font dari konten obrolan, biarkan kosong untuk menerapkan font default global",
Placeholder: "Nama Font",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Suntikkan Pesan Sistem", Title: "Suntikkan Pesan Sistem",
SubTitle: SubTitle:

View File

@ -159,6 +159,12 @@ const it: PartialLocaleType = {
Title: "Dimensione del carattere", Title: "Dimensione del carattere",
SubTitle: "Dimensione del carattere per il contenuto della chat", SubTitle: "Dimensione del carattere per il contenuto della chat",
}, },
FontFamily: {
Title: "Font della Chat",
SubTitle:
"Carattere del contenuto della chat, lascia vuoto per applicare il carattere predefinito globale",
Placeholder: "Nome del Font",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Inserisci suggerimenti di sistema", Title: "Inserisci suggerimenti di sistema",
SubTitle: SubTitle:

View File

@ -153,6 +153,12 @@ const jp: PartialLocaleType = {
Title: "フォントサイズ", Title: "フォントサイズ",
SubTitle: "チャット内容のフォントサイズ", SubTitle: "チャット内容のフォントサイズ",
}, },
FontFamily: {
Title: "チャットフォント",
SubTitle:
"チャットコンテンツのフォント、空白の場合はグローバルデフォルトフォントを適用します",
Placeholder: "フォント名",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "システムプロンプトの注入", Title: "システムプロンプトの注入",
SubTitle: SubTitle:

View File

@ -153,6 +153,11 @@ const ko: PartialLocaleType = {
Title: "글꼴 크기", Title: "글꼴 크기",
SubTitle: "채팅 내용의 글꼴 크기", SubTitle: "채팅 내용의 글꼴 크기",
}, },
FontFamily: {
Title: "채팅 폰트",
SubTitle: "채팅 내용의 폰트, 비워 두면 글로벌 기본 폰트를 적용",
Placeholder: "폰트 이름",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "시스템 수준 프롬프트 삽입", Title: "시스템 수준 프롬프트 삽입",
SubTitle: SubTitle:

View File

@ -157,6 +157,12 @@ const no: PartialLocaleType = {
Title: "Skriftstørrelse", Title: "Skriftstørrelse",
SubTitle: "Skriftstørrelse for samtaleinnhold", SubTitle: "Skriftstørrelse for samtaleinnhold",
}, },
FontFamily: {
Title: "Chat-skrifttype",
SubTitle:
"Skrifttypen for chatinnhold, la stå tom for å bruke global standardskrifttype",
Placeholder: "Skriftnavn",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Injiser systemprompter", Title: "Injiser systemprompter",
SubTitle: SubTitle:

View File

@ -153,6 +153,12 @@ const pt: PartialLocaleType = {
Title: "Tamanho da Fonte", Title: "Tamanho da Fonte",
SubTitle: "Ajustar o tamanho da fonte do conteúdo do chat", SubTitle: "Ajustar o tamanho da fonte do conteúdo do chat",
}, },
FontFamily: {
Title: "Fonte do Chat",
SubTitle:
"Fonte do conteúdo do chat, deixe vazio para aplicar a fonte padrão global",
Placeholder: "Nome da Fonte",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Inserir Prompts de Sistema", Title: "Inserir Prompts de Sistema",
SubTitle: "Inserir um prompt de sistema global para cada requisição", SubTitle: "Inserir um prompt de sistema global para cada requisição",

View File

@ -153,6 +153,12 @@ const ru: PartialLocaleType = {
Title: "Размер шрифта", Title: "Размер шрифта",
SubTitle: "Размер шрифта в чате", SubTitle: "Размер шрифта в чате",
}, },
FontFamily: {
Title: "Шрифт чата",
SubTitle:
"Шрифт содержимого чата, оставьте пустым для применения глобального шрифта по умолчанию",
Placeholder: "Название шрифта",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Вставить системные подсказки", Title: "Вставить системные подсказки",
SubTitle: SubTitle:

View File

@ -155,6 +155,12 @@ const sk: PartialLocaleType = {
Title: "Veľkosť písma", Title: "Veľkosť písma",
SubTitle: "Nastaviť veľkosť písma obsahu chatu", SubTitle: "Nastaviť veľkosť písma obsahu chatu",
}, },
FontFamily: {
Title: "Chatové Písmo",
SubTitle:
"Písmo obsahu chatu, ponechajte prázdne pre použitie globálneho predvoleného písma",
Placeholder: "Názov Písma",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Vložiť systémové výzvy", Title: "Vložiť systémové výzvy",
SubTitle: "Vložiť globálnu systémovú výzvu pre každú požiadavku", SubTitle: "Vložiť globálnu systémovú výzvu pre každú požiadavku",

View File

@ -154,6 +154,12 @@ const tr: PartialLocaleType = {
Title: "Yazı Boyutu", Title: "Yazı Boyutu",
SubTitle: "Sohbet içeriğinin yazı boyutu", SubTitle: "Sohbet içeriğinin yazı boyutu",
}, },
FontFamily: {
Title: "Sohbet Yazı Tipi",
SubTitle:
"Sohbet içeriğinin yazı tipi, boş bırakıldığında küresel varsayılan yazı tipi uygulanır",
Placeholder: "Yazı Tipi Adı",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Sistem Seviyesi İpucu Enjeksiyonu", Title: "Sistem Seviyesi İpucu Enjeksiyonu",
SubTitle: "Her isteğin başına ChatGPT benzeri bir sistem ipucu ekle", SubTitle: "Her isteğin başına ChatGPT benzeri bir sistem ipucu ekle",

View File

@ -153,6 +153,11 @@ const tw = {
Title: "字型大小", Title: "字型大小",
SubTitle: "聊天內容的字型大小", SubTitle: "聊天內容的字型大小",
}, },
FontFamily: {
Title: "聊天字體",
SubTitle: "聊天內容的字體,若置空則應用全局默認字體",
Placeholder: "字體名稱",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "匯入系統提示", Title: "匯入系統提示",
SubTitle: "強制在每個請求的訊息列表開頭新增一個模擬 ChatGPT 的系統提示", SubTitle: "強制在每個請求的訊息列表開頭新增一個模擬 ChatGPT 的系統提示",

View File

@ -153,6 +153,12 @@ const vi: PartialLocaleType = {
Title: "Kích thước chữ", Title: "Kích thước chữ",
SubTitle: "Kích thước chữ của nội dung trò chuyện", SubTitle: "Kích thước chữ của nội dung trò chuyện",
}, },
FontFamily: {
Title: "Phông Chữ Trò Chuyện",
SubTitle:
"Phông chữ của nội dung trò chuyện, để trống để áp dụng phông chữ mặc định toàn cầu",
Placeholder: "Tên Phông Chữ",
},
InjectSystemPrompts: { InjectSystemPrompts: {
Title: "Tiêm thông báo hệ thống", Title: "Tiêm thông báo hệ thống",
SubTitle: SubTitle:

View File

@ -39,7 +39,21 @@ const DEFAULT_ALIBABA_URL = isApp
? DEFAULT_API_HOST + "/api/proxy/alibaba" ? DEFAULT_API_HOST + "/api/proxy/alibaba"
: ApiPath.Alibaba; : ApiPath.Alibaba;
console.log("DEFAULT_ANTHROPIC_URL", DEFAULT_ANTHROPIC_URL); const DEFAULT_TENCENT_URL = isApp
? DEFAULT_API_HOST + "/api/proxy/tencent"
: ApiPath.Tencent;
const DEFAULT_MOONSHOT_URL = isApp
? DEFAULT_API_HOST + "/api/proxy/moonshot"
: ApiPath.Moonshot;
const DEFAULT_STABILITY_URL = isApp
? DEFAULT_API_HOST + "/api/proxy/stability"
: ApiPath.Stability;
const DEFAULT_IFLYTEK_URL = isApp
? DEFAULT_API_HOST + "/api/proxy/iflytek"
: ApiPath.Iflytek;
const DEFAULT_ACCESS_STATE = { const DEFAULT_ACCESS_STATE = {
accessCode: "", accessCode: "",
@ -80,6 +94,24 @@ const DEFAULT_ACCESS_STATE = {
alibabaUrl: DEFAULT_ALIBABA_URL, alibabaUrl: DEFAULT_ALIBABA_URL,
alibabaApiKey: "", alibabaApiKey: "",
// moonshot
moonshotUrl: DEFAULT_MOONSHOT_URL,
moonshotApiKey: "",
//stability
stabilityUrl: DEFAULT_STABILITY_URL,
stabilityApiKey: "",
// tencent
tencentUrl: DEFAULT_TENCENT_URL,
tencentSecretKey: "",
tencentSecretId: "",
// iflytek
iflytekUrl: DEFAULT_IFLYTEK_URL,
iflytekApiKey: "",
iflytekApiSecret: "",
// server config // server config
needCode: true, needCode: true,
hideUserApiKey: false, hideUserApiKey: false,
@ -128,6 +160,17 @@ export const useAccessStore = createPersistStore(
return ensure(get(), ["alibabaApiKey"]); return ensure(get(), ["alibabaApiKey"]);
}, },
isValidTencent() {
return ensure(get(), ["tencentSecretKey", "tencentSecretId"]);
},
isValidMoonshot() {
return ensure(get(), ["moonshotApiKey"]);
},
isValidIflytek() {
return ensure(get(), ["iflytekApiKey"]);
},
isAuthorized() { isAuthorized() {
this.fetch(); this.fetch();
@ -140,6 +183,9 @@ export const useAccessStore = createPersistStore(
this.isValidBaidu() || this.isValidBaidu() ||
this.isValidByteDance() || this.isValidByteDance() ||
this.isValidAlibaba() || this.isValidAlibaba() ||
this.isValidTencent ||
this.isValidMoonshot() ||
this.isValidIflytek() ||
!this.enabledAccessControl() || !this.enabledAccessControl() ||
(this.enabledAccessControl() && ensure(get(), ["accessCode"])) (this.enabledAccessControl() && ensure(get(), ["accessCode"]))
); );

View File

@ -26,6 +26,7 @@ import { nanoid } from "nanoid";
import { createPersistStore } from "../utils/store"; import { createPersistStore } from "../utils/store";
import { collectModelsWithDefaultModel } from "../utils/model"; import { collectModelsWithDefaultModel } from "../utils/model";
import { useAccessStore } from "./access"; import { useAccessStore } from "./access";
import { isDalle3 } from "../utils";
export type ChatMessage = RequestMessage & { export type ChatMessage = RequestMessage & {
date: string; date: string;
@ -541,8 +542,13 @@ export const useChatStore = createPersistStore(
const config = useAppConfig.getState(); const config = useAppConfig.getState();
const session = get().currentSession(); const session = get().currentSession();
const modelConfig = session.mask.modelConfig; const modelConfig = session.mask.modelConfig;
// skip summarize when using dalle3?
if (isDalle3(modelConfig.model)) {
return;
}
const api: ClientApi = getClientApi(modelConfig.providerName); const providerName = modelConfig.providerName;
const api: ClientApi = getClientApi(providerName);
// remove error messages if any // remove error messages if any
const messages = session.messages; const messages = session.messages;
@ -565,6 +571,7 @@ export const useChatStore = createPersistStore(
config: { config: {
model: getSummarizeModel(session.mask.modelConfig.model), model: getSummarizeModel(session.mask.modelConfig.model),
stream: false, stream: false,
providerName,
}, },
onFinish(message) { onFinish(message) {
get().updateCurrentSession( get().updateCurrentSession(

View File

@ -1,4 +1,5 @@
import { LLMModel } from "../client/api"; import { LLMModel } from "../client/api";
import { DalleSize, DalleQuality, DalleStyle } from "../typing";
import { getClientConfig } from "../config/client"; import { getClientConfig } from "../config/client";
import { import {
DEFAULT_INPUT_TEMPLATE, DEFAULT_INPUT_TEMPLATE,
@ -33,6 +34,7 @@ export const DEFAULT_CONFIG = {
submitKey: SubmitKey.Enter, submitKey: SubmitKey.Enter,
avatar: "1f603", avatar: "1f603",
fontSize: 14, fontSize: 14,
fontFamily: "",
theme: Theme.Auto as Theme, theme: Theme.Auto as Theme,
tightBorder: !!config?.isApp, tightBorder: !!config?.isApp,
sendPreviewBubble: true, sendPreviewBubble: true,
@ -60,6 +62,9 @@ export const DEFAULT_CONFIG = {
compressMessageLengthThreshold: 1000, compressMessageLengthThreshold: 1000,
enableInjectSystemPrompts: true, enableInjectSystemPrompts: true,
template: config?.template ?? DEFAULT_INPUT_TEMPLATE, template: config?.template ?? DEFAULT_INPUT_TEMPLATE,
size: "1024x1024" as DalleSize,
quality: "standard" as DalleQuality,
style: "vivid" as DalleStyle,
}, },
}; };

View File

@ -2,7 +2,7 @@ import { BUILTIN_MASKS } from "../masks";
import { getLang, Lang } from "../locales"; import { getLang, Lang } from "../locales";
import { DEFAULT_TOPIC, ChatMessage } from "./chat"; import { DEFAULT_TOPIC, ChatMessage } from "./chat";
import { ModelConfig, useAppConfig } from "./config"; import { ModelConfig, useAppConfig } from "./config";
import { StoreKey } from "../constant"; import { StoreKey, Plugin } from "../constant";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { createPersistStore } from "../utils/store"; import { createPersistStore } from "../utils/store";
@ -17,6 +17,7 @@ export type Mask = {
modelConfig: ModelConfig; modelConfig: ModelConfig;
lang: Lang; lang: Lang;
builtin: boolean; builtin: boolean;
plugin?: Plugin[];
}; };
export const DEFAULT_MASK_STATE = { export const DEFAULT_MASK_STATE = {
@ -37,6 +38,7 @@ export const createEmptyMask = () =>
lang: getLang(), lang: getLang(),
builtin: false, builtin: false,
createdAt: Date.now(), createdAt: Date.now(),
plugin: [Plugin.Artifacts],
}) as Mask; }) as Mask;
export const useMaskStore = createPersistStore( export const useMaskStore = createPersistStore(

163
app/store/sd.ts Normal file
View File

@ -0,0 +1,163 @@
import {
Stability,
StoreKey,
ACCESS_CODE_PREFIX,
ApiPath,
} from "@/app/constant";
import { getBearerToken } from "@/app/client/api";
import { createPersistStore } from "@/app/utils/store";
import { nanoid } from "nanoid";
import { uploadImage, base64Image2Blob } from "@/app/utils/chat";
import { models, getModelParamBasicData } from "@/app/components/sd/sd-panel";
import { useAccessStore } from "./access";
const defaultModel = {
name: models[0].name,
value: models[0].value,
};
const defaultParams = getModelParamBasicData(models[0].params({}), {});
const DEFAULT_SD_STATE = {
currentId: 0,
draw: [],
currentModel: defaultModel,
currentParams: defaultParams,
};
export const useSdStore = createPersistStore<
{
currentId: number;
draw: any[];
currentModel: typeof defaultModel;
currentParams: any;
},
{
getNextId: () => number;
sendTask: (data: any, okCall?: Function) => void;
updateDraw: (draw: any) => void;
setCurrentModel: (model: any) => void;
setCurrentParams: (data: any) => void;
}
>(
DEFAULT_SD_STATE,
(set, _get) => {
function get() {
return {
..._get(),
...methods,
};
}
const methods = {
getNextId() {
const id = ++_get().currentId;
set({ currentId: id });
return id;
},
sendTask(data: any, okCall?: Function) {
data = { ...data, id: nanoid(), status: "running" };
set({ draw: [data, ..._get().draw] });
this.getNextId();
this.stabilityRequestCall(data);
okCall?.();
},
stabilityRequestCall(data: any) {
const accessStore = useAccessStore.getState();
let prefix: string = ApiPath.Stability as string;
let bearerToken = "";
if (accessStore.useCustomConfig) {
prefix = accessStore.stabilityUrl || (ApiPath.Stability as string);
bearerToken = getBearerToken(accessStore.stabilityApiKey);
}
if (!bearerToken && accessStore.enabledAccessControl()) {
bearerToken = getBearerToken(
ACCESS_CODE_PREFIX + accessStore.accessCode,
);
}
const headers = {
Accept: "application/json",
Authorization: bearerToken,
};
const path = `${prefix}/${Stability.GeneratePath}/${data.model}`;
const formData = new FormData();
for (let paramsKey in data.params) {
formData.append(paramsKey, data.params[paramsKey]);
}
fetch(path, {
method: "POST",
headers,
body: formData,
})
.then((response) => response.json())
.then((resData) => {
if (resData.errors && resData.errors.length > 0) {
this.updateDraw({
...data,
status: "error",
error: resData.errors[0],
});
this.getNextId();
return;
}
const self = this;
if (resData.finish_reason === "SUCCESS") {
uploadImage(base64Image2Blob(resData.image, "image/png"))
.then((img_data) => {
console.debug("uploadImage success", img_data, self);
self.updateDraw({
...data,
status: "success",
img_data,
});
})
.catch((e) => {
console.error("uploadImage error", e);
self.updateDraw({
...data,
status: "error",
error: JSON.stringify(e),
});
});
} else {
self.updateDraw({
...data,
status: "error",
error: JSON.stringify(resData),
});
}
this.getNextId();
})
.catch((error) => {
this.updateDraw({ ...data, status: "error", error: error.message });
console.error("Error:", error);
this.getNextId();
});
},
updateDraw(_draw: any) {
const draw = _get().draw || [];
draw.some((item, index) => {
if (item.id === _draw.id) {
draw[index] = _draw;
set(() => ({ draw }));
return true;
}
});
},
setCurrentModel(model: any) {
set({ currentModel: model });
},
setCurrentParams(data: any) {
set({
currentParams: data,
});
},
};
return methods;
},
{
name: StoreKey.SdList,
version: 1.0,
},
);

View File

@ -7,3 +7,7 @@ export interface RequestMessage {
role: MessageRole; role: MessageRole;
content: string; content: string;
} }
export type DalleSize = "1024x1024" | "1792x1024" | "1024x1792";
export type DalleQuality = "standard" | "hd";
export type DalleStyle = "vivid" | "natural";

View File

@ -194,6 +194,7 @@ export function autoGrowTextArea(dom: HTMLTextAreaElement) {
measureDom.style.width = width + "px"; measureDom.style.width = width + "px";
measureDom.innerText = dom.value !== "" ? dom.value : "1"; measureDom.innerText = dom.value !== "" ? dom.value : "1";
measureDom.style.fontSize = dom.style.fontSize; measureDom.style.fontSize = dom.style.fontSize;
measureDom.style.fontFamily = dom.style.fontFamily;
const endWithEmptyLine = dom.value.endsWith("\n"); const endWithEmptyLine = dom.value.endsWith("\n");
const height = parseFloat(window.getComputedStyle(measureDom).height); const height = parseFloat(window.getComputedStyle(measureDom).height);
const singleLineHeight = parseFloat( const singleLineHeight = parseFloat(
@ -265,3 +266,7 @@ export function isVisionModel(model: string) {
visionKeywords.some((keyword) => model.includes(keyword)) || isGpt4Turbo visionKeywords.some((keyword) => model.includes(keyword)) || isGpt4Turbo
); );
} }
export function isDalle3(model: string) {
return "dall-e-3" === model;
}

View File

@ -112,7 +112,7 @@ export function base64Image2Blob(base64Data: string, contentType: string) {
return new Blob([byteArray], { type: contentType }); return new Blob([byteArray], { type: contentType });
} }
export function uploadImage(file: File): Promise<string> { export function uploadImage(file: Blob): Promise<string> {
if (!window._SW_ENABLED) { if (!window._SW_ENABLED) {
// if serviceWorker register error, using compressImage // if serviceWorker register error, using compressImage
return compressImage(file, 256 * 1024); return compressImage(file, 256 * 1024);

View File

@ -14,8 +14,8 @@ export function createWebDavClient(store: SyncStore) {
return { return {
async check() { async check() {
try { try {
const res = await fetch(this.path(folder, proxyUrl), { const res = await fetch(this.path(folder, proxyUrl, "MKCOL"), {
method: "MKCOL", method: "GET",
headers: this.headers(), headers: this.headers(),
}); });
const success = [201, 200, 404, 405, 301, 302, 307, 308].includes( const success = [201, 200, 404, 405, 301, 302, 307, 308].includes(
@ -42,6 +42,10 @@ export function createWebDavClient(store: SyncStore) {
console.log("[WebDav] get key = ", key, res.status, res.statusText); console.log("[WebDav] get key = ", key, res.status, res.statusText);
if (404 == res.status) {
return "";
}
return await res.text(); return await res.text();
}, },
@ -62,7 +66,7 @@ export function createWebDavClient(store: SyncStore) {
authorization: `Basic ${auth}`, authorization: `Basic ${auth}`,
}; };
}, },
path(path: string, proxyUrl: string = "") { path(path: string, proxyUrl: string = "", proxyMethod: string = "") {
if (path.startsWith("/")) { if (path.startsWith("/")) {
path = path.slice(1); path = path.slice(1);
} }
@ -78,9 +82,13 @@ export function createWebDavClient(store: SyncStore) {
let u = new URL(proxyUrl + pathPrefix + path); let u = new URL(proxyUrl + pathPrefix + path);
// add query params // add query params
u.searchParams.append("endpoint", config.endpoint); u.searchParams.append("endpoint", config.endpoint);
proxyMethod && u.searchParams.append("proxy_method", proxyMethod);
url = u.toString(); url = u.toString();
} catch (e) { } catch (e) {
url = pathPrefix + path + "?endpoint=" + config.endpoint; url = pathPrefix + path + "?endpoint=" + config.endpoint;
if (proxyMethod) {
url += "&proxy_method=" + proxyMethod;
}
} }
return url; return url;

246
app/utils/hmac.ts Normal file
View File

@ -0,0 +1,246 @@
// From https://gist.github.com/guillermodlpa/f6d955f838e9b10d1ef95b8e259b2c58
// From https://gist.github.com/stevendesu/2d52f7b5e1f1184af3b667c0b5e054b8
// To ensure cross-browser support even without a proper SubtleCrypto
// impelmentation (or without access to the impelmentation, as is the case with
// Chrome loaded over HTTP instead of HTTPS), this library can create SHA-256
// HMAC signatures using nothing but raw JavaScript
/* eslint-disable no-magic-numbers, id-length, no-param-reassign, new-cap */
// By giving internal functions names that we can mangle, future calls to
// them are reduced to a single byte (minor space savings in minified file)
const uint8Array = Uint8Array;
const uint32Array = Uint32Array;
const pow = Math.pow;
// Will be initialized below
// Using a Uint32Array instead of a simple array makes the minified code
// a bit bigger (we lose our `unshift()` hack), but comes with huge
// performance gains
const DEFAULT_STATE = new uint32Array(8);
const ROUND_CONSTANTS: number[] = [];
// Reusable object for expanded message
// Using a Uint32Array instead of a simple array makes the minified code
// 7 bytes larger, but comes with huge performance gains
const M = new uint32Array(64);
// After minification the code to compute the default state and round
// constants is smaller than the output. More importantly, this serves as a
// good educational aide for anyone wondering where the magic numbers come
// from. No magic numbers FTW!
function getFractionalBits(n: number) {
return ((n - (n | 0)) * pow(2, 32)) | 0;
}
let n = 2;
let nPrime = 0;
while (nPrime < 64) {
// isPrime() was in-lined from its original function form to save
// a few bytes
let isPrime = true;
// Math.sqrt() was replaced with pow(n, 1/2) to save a few bytes
// var sqrtN = pow(n, 1 / 2);
// So technically to determine if a number is prime you only need to
// check numbers up to the square root. However this function only runs
// once and we're only computing the first 64 primes (up to 311), so on
// any modern CPU this whole function runs in a couple milliseconds.
// By going to n / 2 instead of sqrt(n) we net 8 byte savings and no
// scaling performance cost
for (let factor = 2; factor <= n / 2; factor++) {
if (n % factor === 0) {
isPrime = false;
}
}
if (isPrime) {
if (nPrime < 8) {
DEFAULT_STATE[nPrime] = getFractionalBits(pow(n, 1 / 2));
}
ROUND_CONSTANTS[nPrime] = getFractionalBits(pow(n, 1 / 3));
nPrime++;
}
n++;
}
// For cross-platform support we need to ensure that all 32-bit words are
// in the same endianness. A UTF-8 TextEncoder will return BigEndian data,
// so upon reading or writing to our ArrayBuffer we'll only swap the bytes
// if our system is LittleEndian (which is about 99% of CPUs)
const LittleEndian = !!new uint8Array(new uint32Array([1]).buffer)[0];
function convertEndian(word: number) {
if (LittleEndian) {
return (
// byte 1 -> byte 4
(word >>> 24) |
// byte 2 -> byte 3
(((word >>> 16) & 0xff) << 8) |
// byte 3 -> byte 2
((word & 0xff00) << 8) |
// byte 4 -> byte 1
(word << 24)
);
} else {
return word;
}
}
function rightRotate(word: number, bits: number) {
return (word >>> bits) | (word << (32 - bits));
}
function sha256(data: Uint8Array) {
// Copy default state
const STATE = DEFAULT_STATE.slice();
// Caching this reduces occurrences of ".length" in minified JavaScript
// 3 more byte savings! :D
const legth = data.length;
// Pad data
const bitLength = legth * 8;
const newBitLength = 512 - ((bitLength + 64) % 512) - 1 + bitLength + 65;
// "bytes" and "words" are stored BigEndian
const bytes = new uint8Array(newBitLength / 8);
const words = new uint32Array(bytes.buffer);
bytes.set(data, 0);
// Append a 1
bytes[legth] = 0b10000000;
// Store length in BigEndian
words[words.length - 1] = convertEndian(bitLength);
// Loop iterator (avoid two instances of "var") -- saves 2 bytes
let round;
// Process blocks (512 bits / 64 bytes / 16 words at a time)
for (let block = 0; block < newBitLength / 32; block += 16) {
const workingState = STATE.slice();
// Rounds
for (round = 0; round < 64; round++) {
let MRound;
// Expand message
if (round < 16) {
// Convert to platform Endianness for later math
MRound = convertEndian(words[block + round]);
} else {
const gamma0x = M[round - 15];
const gamma1x = M[round - 2];
MRound =
M[round - 7] +
M[round - 16] +
(rightRotate(gamma0x, 7) ^
rightRotate(gamma0x, 18) ^
(gamma0x >>> 3)) +
(rightRotate(gamma1x, 17) ^
rightRotate(gamma1x, 19) ^
(gamma1x >>> 10));
}
// M array matches platform endianness
M[round] = MRound |= 0;
// Computation
const t1 =
(rightRotate(workingState[4], 6) ^
rightRotate(workingState[4], 11) ^
rightRotate(workingState[4], 25)) +
((workingState[4] & workingState[5]) ^
(~workingState[4] & workingState[6])) +
workingState[7] +
MRound +
ROUND_CONSTANTS[round];
const t2 =
(rightRotate(workingState[0], 2) ^
rightRotate(workingState[0], 13) ^
rightRotate(workingState[0], 22)) +
((workingState[0] & workingState[1]) ^
(workingState[2] & (workingState[0] ^ workingState[1])));
for (let i = 7; i > 0; i--) {
workingState[i] = workingState[i - 1];
}
workingState[0] = (t1 + t2) | 0;
workingState[4] = (workingState[4] + t1) | 0;
}
// Update state
for (round = 0; round < 8; round++) {
STATE[round] = (STATE[round] + workingState[round]) | 0;
}
}
// Finally the state needs to be converted to BigEndian for output
// And we want to return a Uint8Array, not a Uint32Array
return new uint8Array(
new uint32Array(
STATE.map(function (val) {
return convertEndian(val);
}),
).buffer,
);
}
function hmac(key: Uint8Array, data: ArrayLike<number>) {
if (key.length > 64) key = sha256(key);
if (key.length < 64) {
const tmp = new Uint8Array(64);
tmp.set(key, 0);
key = tmp;
}
// Generate inner and outer keys
const innerKey = new Uint8Array(64);
const outerKey = new Uint8Array(64);
for (let i = 0; i < 64; i++) {
innerKey[i] = 0x36 ^ key[i];
outerKey[i] = 0x5c ^ key[i];
}
// Append the innerKey
const msg = new Uint8Array(data.length + 64);
msg.set(innerKey, 0);
msg.set(data, 64);
// Has the previous message and append the outerKey
const result = new Uint8Array(64 + 32);
result.set(outerKey, 0);
result.set(sha256(msg), 64);
// Hash the previous message
return sha256(result);
}
// Convert a string to a Uint8Array, SHA-256 it, and convert back to string
const encoder = new TextEncoder();
export function sign(
inputKey: string | Uint8Array,
inputData: string | Uint8Array,
) {
const key =
typeof inputKey === "string" ? encoder.encode(inputKey) : inputKey;
const data =
typeof inputData === "string" ? encoder.encode(inputData) : inputData;
return hmac(key, data);
}
export function hex(bin: Uint8Array) {
return bin.reduce((acc, val) => {
const hexVal = "00" + val.toString(16);
return acc + hexVal.substring(hexVal.length - 2);
}, "");
}
export function hash(str: string) {
return hex(sha256(encoder.encode(str)));
}
export function hashWithSecret(str: string, secret: string) {
return hex(sign(secret, str)).toString();
}

View File

@ -1,12 +1,42 @@
import { DEFAULT_MODELS } from "../constant"; import { DEFAULT_MODELS } from "../constant";
import { LLMModel } from "../client/api"; import { LLMModel } from "../client/api";
const CustomSeq = {
val: -1000, //To ensure the custom model located at front, start from -1000, refer to constant.ts
cache: new Map<string, number>(),
next: (id: string) => {
if (CustomSeq.cache.has(id)) {
return CustomSeq.cache.get(id) as number;
} else {
let seq = CustomSeq.val++;
CustomSeq.cache.set(id, seq);
return seq;
}
},
};
const customProvider = (providerName: string) => ({ const customProvider = (providerName: string) => ({
id: providerName.toLowerCase(), id: providerName.toLowerCase(),
providerName: providerName, providerName: providerName,
providerType: "custom", providerType: "custom",
sorted: CustomSeq.next(providerName),
}); });
/**
* Sorts an array of models based on specified rules.
*
* First, sorted by provider; if the same, sorted by model
*/
const sortModelTable = (models: ReturnType<typeof collectModels>) =>
models.sort((a, b) => {
if (a.provider && b.provider) {
let cmp = a.provider.sorted - b.provider.sorted;
return cmp === 0 ? a.sorted - b.sorted : cmp;
} else {
return a.sorted - b.sorted;
}
});
export function collectModelTable( export function collectModelTable(
models: readonly LLMModel[], models: readonly LLMModel[],
customModels: string, customModels: string,
@ -17,6 +47,7 @@ export function collectModelTable(
available: boolean; available: boolean;
name: string; name: string;
displayName: string; displayName: string;
sorted: number;
provider?: LLMModel["provider"]; // Marked as optional provider?: LLMModel["provider"]; // Marked as optional
isDefault?: boolean; isDefault?: boolean;
} }
@ -84,6 +115,7 @@ export function collectModelTable(
displayName: displayName || customModelName, displayName: displayName || customModelName,
available, available,
provider, // Use optional chaining provider, // Use optional chaining
sorted: CustomSeq.next(`${customModelName}@${provider?.id}`),
}; };
} }
} }
@ -99,12 +131,21 @@ export function collectModelTableWithDefaultModel(
) { ) {
let modelTable = collectModelTable(models, customModels); let modelTable = collectModelTable(models, customModels);
if (defaultModel && defaultModel !== "") { if (defaultModel && defaultModel !== "") {
modelTable[defaultModel] = { if (defaultModel.includes("@")) {
...modelTable[defaultModel], if (defaultModel in modelTable) {
name: defaultModel, modelTable[defaultModel].isDefault = true;
available: true, }
isDefault: true, } else {
}; for (const key of Object.keys(modelTable)) {
if (
modelTable[key].available &&
key.split("@").shift() == defaultModel
) {
modelTable[key].isDefault = true;
break;
}
}
}
} }
return modelTable; return modelTable;
} }
@ -117,7 +158,9 @@ export function collectModels(
customModels: string, customModels: string,
) { ) {
const modelTable = collectModelTable(models, customModels); const modelTable = collectModelTable(models, customModels);
const allModels = Object.values(modelTable); let allModels = Object.values(modelTable);
allModels = sortModelTable(allModels);
return allModels; return allModels;
} }
@ -132,7 +175,10 @@ export function collectModelsWithDefaultModel(
customModels, customModels,
defaultModel, defaultModel,
); );
const allModels = Object.values(modelTable); let allModels = Object.values(modelTable);
allModels = sortModelTable(allModels);
return allModels; return allModels;
} }

102
app/utils/tencent.ts Normal file
View File

@ -0,0 +1,102 @@
import { sign, hash as getHash, hex } from "./hmac";
// 使用 SHA-256 和 secret 进行 HMAC 加密
function sha256(message: any, secret: any, encoding?: string) {
const result = sign(secret, message);
return encoding == "hex" ? hex(result).toString() : result;
}
function getDate(timestamp: number) {
const date = new Date(timestamp * 1000);
const year = date.getUTCFullYear();
const month = ("0" + (date.getUTCMonth() + 1)).slice(-2);
const day = ("0" + date.getUTCDate()).slice(-2);
return `${year}-${month}-${day}`;
}
export async function getHeader(
payload: any,
SECRET_ID: string,
SECRET_KEY: string,
) {
// https://cloud.tencent.com/document/api/1729/105701
const endpoint = "hunyuan.tencentcloudapi.com";
const service = "hunyuan";
const region = ""; // optional
const action = "ChatCompletions";
const version = "2023-09-01";
const timestamp = Math.floor(Date.now() / 1000);
//时间处理, 获取世界时间日期
const date = getDate(timestamp);
// ************* 步骤 1拼接规范请求串 *************
const hashedRequestPayload = getHash(payload);
const httpRequestMethod = "POST";
const contentType = "application/json";
const canonicalUri = "/";
const canonicalQueryString = "";
const canonicalHeaders =
`content-type:${contentType}\n` +
"host:" +
endpoint +
"\n" +
"x-tc-action:" +
action.toLowerCase() +
"\n";
const signedHeaders = "content-type;host;x-tc-action";
const canonicalRequest = [
httpRequestMethod,
canonicalUri,
canonicalQueryString,
canonicalHeaders,
signedHeaders,
hashedRequestPayload,
].join("\n");
// ************* 步骤 2拼接待签名字符串 *************
const algorithm = "TC3-HMAC-SHA256";
const hashedCanonicalRequest = getHash(canonicalRequest);
const credentialScope = date + "/" + service + "/" + "tc3_request";
const stringToSign =
algorithm +
"\n" +
timestamp +
"\n" +
credentialScope +
"\n" +
hashedCanonicalRequest;
// ************* 步骤 3计算签名 *************
const kDate = sha256(date, "TC3" + SECRET_KEY);
const kService = sha256(service, kDate);
const kSigning = sha256("tc3_request", kService);
const signature = sha256(stringToSign, kSigning, "hex");
// ************* 步骤 4拼接 Authorization *************
const authorization =
algorithm +
" " +
"Credential=" +
SECRET_ID +
"/" +
credentialScope +
", " +
"SignedHeaders=" +
signedHeaders +
", " +
"Signature=" +
signature;
return {
Authorization: authorization,
"Content-Type": contentType,
Host: endpoint,
"X-TC-Action": action,
"X-TC-Timestamp": timestamp.toString(),
"X-TC-Version": version,
"X-TC-Region": region,
};
}

View File

@ -4,14 +4,14 @@
"license": "mit", "license": "mit",
"scripts": { "scripts": {
"mask": "npx tsx app/masks/build.ts", "mask": "npx tsx app/masks/build.ts",
"mask:watch": "npx watch 'yarn mask' app/masks", "mask:watch": "npx watch \"yarn mask\" app/masks",
"dev": "yarn run mask:watch & next dev", "dev": "concurrently -r \"yarn run mask:watch\" \"next dev\"",
"build": "yarn mask && cross-env BUILD_MODE=standalone next build", "build": "yarn mask && cross-env BUILD_MODE=standalone next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build", "export": "yarn mask && cross-env BUILD_MODE=export BUILD_APP=1 next build",
"export:dev": "yarn mask:watch & cross-env BUILD_MODE=export BUILD_APP=1 next dev", "export:dev": "concurrently -r \"yarn mask:watch\" \"cross-env BUILD_MODE=export BUILD_APP=1 next dev\"",
"app:dev": "yarn mask:watch & yarn tauri dev", "app:dev": "concurrently -r \"yarn mask:watch\" \"yarn tauri dev\"",
"app:build": "yarn mask && yarn tauri build", "app:build": "yarn mask && yarn tauri build",
"prompts": "node ./scripts/fetch-prompts.mjs", "prompts": "node ./scripts/fetch-prompts.mjs",
"prepare": "husky install", "prepare": "husky install",
@ -28,6 +28,7 @@
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"html-to-image": "^1.11.11", "html-to-image": "^1.11.11",
"lodash-es": "^4.17.21",
"mermaid": "^10.6.1", "mermaid": "^10.6.1",
"nanoid": "^5.0.3", "nanoid": "^5.0.3",
"next": "^14.1.1", "next": "^14.1.1",
@ -48,11 +49,13 @@
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "1.5.11", "@tauri-apps/cli": "1.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.30", "@types/node": "^20.11.30",
"@types/react": "^18.2.70", "@types/react": "^18.2.70",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-katex": "^3.0.0", "@types/react-katex": "^3.0.0",
"@types/spark-md5": "^3.0.4", "@types/spark-md5": "^3.0.4",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-config-next": "13.4.19", "eslint-config-next": "13.4.19",

View File

@ -15,6 +15,10 @@ self.addEventListener("install", function (event) {
); );
}); });
function jsonify(data) {
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } })
}
async function upload(request, url) { async function upload(request, url) {
const formData = await request.formData() const formData = await request.formData()
const file = formData.getAll('file')[0] const file = formData.getAll('file')[0]
@ -33,13 +37,13 @@ async function upload(request, url) {
'server': 'ServiceWorker', 'server': 'ServiceWorker',
} }
})) }))
return Response.json({ code: 0, data: fileUrl }) return jsonify({ code: 0, data: fileUrl })
} }
async function remove(request, url) { async function remove(request, url) {
const cache = await caches.open(CHATGPT_NEXT_WEB_FILE_CACHE) const cache = await caches.open(CHATGPT_NEXT_WEB_FILE_CACHE)
const res = await cache.delete(request.url) const res = await cache.delete(request.url)
return Response.json({ code: 0 }) return jsonify({ code: 0 })
} }
self.addEventListener("fetch", (e) => { self.addEventListener("fetch", (e) => {
@ -56,4 +60,3 @@ self.addEventListener("fetch", (e) => {
} }
} }
}); });

View File

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

111
yarn.lock
View File

@ -1035,6 +1035,13 @@
dependencies: dependencies:
regenerator-runtime "^0.14.0" regenerator-runtime "^0.14.0"
"@babel/runtime@^7.21.0":
version "7.25.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb"
integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.18.10", "@babel/template@^7.20.7": "@babel/template@^7.18.10", "@babel/template@^7.20.7":
version "7.20.7" version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
@ -1697,6 +1704,18 @@
resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe" resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe"
integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA== integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==
"@types/lodash-es@^4.17.12":
version "4.17.12"
resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.17.7"
resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612"
integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==
"@types/mdast@^3.0.0": "@types/mdast@^3.0.0":
version "3.0.11" version "3.0.11"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0"
@ -2269,7 +2288,7 @@ chalk@^2.0.0, chalk@^2.4.2:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" supports-color "^5.3.0"
chalk@^4.0.0: chalk@^4.0.0, chalk@^4.1.2:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@ -2335,6 +2354,15 @@ client-only@0.0.1:
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
cliui@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
color-convert@^1.9.0: color-convert@^1.9.0:
version "1.9.3" version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@ -2394,6 +2422,21 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concurrently@^8.2.2:
version "8.2.2"
resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-8.2.2.tgz#353141985c198cfa5e4a3ef90082c336b5851784"
integrity sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==
dependencies:
chalk "^4.1.2"
date-fns "^2.30.0"
lodash "^4.17.21"
rxjs "^7.8.1"
shell-quote "^1.8.1"
spawn-command "0.0.2"
supports-color "^8.1.1"
tree-kill "^1.2.2"
yargs "^17.7.2"
convert-source-map@^1.7.0: convert-source-map@^1.7.0:
version "1.9.0" version "1.9.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
@ -2801,6 +2844,13 @@ data-uri-to-buffer@^4.0.0:
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
date-fns@^2.30.0:
version "2.30.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
dependencies:
"@babel/runtime" "^7.21.0"
dayjs@^1.11.7: dayjs@^1.11.7:
version "1.11.7" version "1.11.7"
resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2"
@ -3562,6 +3612,11 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f"
@ -5480,6 +5535,11 @@ remark-rehype@^10.0.0:
mdast-util-to-hast "^12.1.0" mdast-util-to-hast "^12.1.0"
unified "^10.0.0" unified "^10.0.0"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
resolve-from@^4.0.0: resolve-from@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@ -5557,6 +5617,13 @@ rxjs@^7.8.0:
dependencies: dependencies:
tslib "^2.1.0" tslib "^2.1.0"
rxjs@^7.8.1:
version "7.8.1"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
dependencies:
tslib "^2.1.0"
sade@^1.7.3: sade@^1.7.3:
version "1.8.1" version "1.8.1"
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
@ -5639,6 +5706,11 @@ shebang-regex@^3.0.0:
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-quote@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
side-channel@^1.0.4: side-channel@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@ -5717,6 +5789,11 @@ spark-md5@^3.0.2:
resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc"
integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
spawn-command@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e"
integrity sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==
stable@^0.1.8: stable@^0.1.8:
version "0.1.8" version "0.1.8"
resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
@ -5739,7 +5816,7 @@ string-argv@^0.3.1:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
string-width@^4.1.0, string-width@^4.2.0: string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -5860,7 +5937,7 @@ supports-color@^7.1.0:
dependencies: dependencies:
has-flag "^4.0.0" has-flag "^4.0.0"
supports-color@^8.0.0: supports-color@^8.0.0, supports-color@^8.1.1:
version "8.1.1" version "8.1.1"
resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
@ -5956,6 +6033,11 @@ to-regex-range@^5.0.1:
dependencies: dependencies:
is-number "^7.0.0" is-number "^7.0.0"
tree-kill@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
trim-lines@^3.0.0: trim-lines@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
@ -6355,6 +6437,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yallist@^3.0.2: yallist@^3.0.2:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
@ -6375,6 +6462,24 @@ yaml@^2.2.2:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^17.7.2:
version "17.7.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
dependencies:
cliui "^8.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
require-directory "^2.1.1"
string-width "^4.2.3"
y18n "^5.0.5"
yargs-parser "^21.1.1"
yocto-queue@^0.1.0: yocto-queue@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"