Merge branch 'main' into main
76
app/api/[provider]/[...path]/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ApiPath } from "@/app/constant";
|
||||
import { NextRequest } 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";
|
||||
import { handle as xaiHandler } from "../../xai";
|
||||
import { handle as chatglmHandler } from "../../glm";
|
||||
import { handle as proxyHandler } from "../../proxy";
|
||||
|
||||
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 });
|
||||
case ApiPath.XAI:
|
||||
return xaiHandler(req, { params });
|
||||
case ApiPath.ChatGLM:
|
||||
return chatglmHandler(req, { params });
|
||||
case ApiPath.OpenAI:
|
||||
return openaiHandler(req, { params });
|
||||
default:
|
||||
return proxyHandler(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",
|
||||
];
|
||||
129
app/api/alibaba.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
ALIBABA_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";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Alibaba Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Qwen);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Alibaba] ", 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.Alibaba, "");
|
||||
|
||||
let baseUrl = serverConfig.alibabaUrl || ALIBABA_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") ?? "",
|
||||
"X-DashScope-SSE": req.headers.get("X-DashScope-SSE") ?? "disable",
|
||||
},
|
||||
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.Alibaba as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Alibaba] 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);
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,18 @@ import {
|
||||
ANTHROPIC_BASE_URL,
|
||||
Anthropic,
|
||||
ApiPath,
|
||||
DEFAULT_MODELS,
|
||||
ServiceProvider,
|
||||
ModelProvider,
|
||||
} from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "../../auth";
|
||||
import { collectModelTable } from "@/app/utils/model";
|
||||
import { auth } from "./auth";
|
||||
import { isModelAvailableInServer } from "@/app/utils/model";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
|
||||
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
|
||||
|
||||
async function handle(
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
@@ -54,30 +55,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();
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
@@ -113,12 +90,14 @@ async function request(req: NextRequest) {
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
const fetchUrl = `${baseUrl}${path}`;
|
||||
// try rebuild url, when using cloudflare ai gateway in server
|
||||
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}${path}`);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
[authHeaderName]: authValue,
|
||||
"anthropic-version":
|
||||
req.headers.get("anthropic-version") ||
|
||||
@@ -136,17 +115,19 @@ async function request(req: NextRequest) {
|
||||
// #1815 try to refuse some request to some models
|
||||
if (serverConfig.customModels && req.body) {
|
||||
try {
|
||||
const modelTable = collectModelTable(
|
||||
DEFAULT_MODELS,
|
||||
serverConfig.customModels,
|
||||
);
|
||||
const clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Anthropic as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -161,17 +142,17 @@ async function request(req: NextRequest) {
|
||||
console.error(`[Anthropic] filter`, e);
|
||||
}
|
||||
}
|
||||
console.log("[Anthropic request]", fetchOptions.headers, req.method);
|
||||
// console.log("[Anthropic request]", fetchOptions.headers, req.method);
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
console.log(
|
||||
"[Anthropic response]",
|
||||
res.status,
|
||||
" ",
|
||||
res.headers,
|
||||
res.url,
|
||||
);
|
||||
// console.log(
|
||||
// "[Anthropic response]",
|
||||
// res.status,
|
||||
// " ",
|
||||
// res.headers,
|
||||
// res.url,
|
||||
// );
|
||||
// to prevent browser prompt for credentials
|
||||
const newHeaders = new Headers(res.headers);
|
||||
newHeaders.delete("www-authenticate");
|
||||
73
app/api/artifacts/route.ts
Normal 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";
|
||||
@@ -67,15 +67,40 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
||||
let systemApiKey: string | undefined;
|
||||
|
||||
switch (modelProvider) {
|
||||
case ModelProvider.Stability:
|
||||
systemApiKey = serverConfig.stabilityApiKey;
|
||||
break;
|
||||
case ModelProvider.GeminiPro:
|
||||
systemApiKey = serverConfig.googleApiKey;
|
||||
break;
|
||||
case ModelProvider.Claude:
|
||||
systemApiKey = serverConfig.anthropicApiKey;
|
||||
break;
|
||||
case ModelProvider.Doubao:
|
||||
systemApiKey = serverConfig.bytedanceApiKey;
|
||||
break;
|
||||
case ModelProvider.Ernie:
|
||||
systemApiKey = serverConfig.baiduApiKey;
|
||||
break;
|
||||
case ModelProvider.Qwen:
|
||||
systemApiKey = serverConfig.alibabaApiKey;
|
||||
break;
|
||||
case ModelProvider.Moonshot:
|
||||
systemApiKey = serverConfig.moonshotApiKey;
|
||||
break;
|
||||
case ModelProvider.Iflytek:
|
||||
systemApiKey =
|
||||
serverConfig.iflytekApiKey + ":" + serverConfig.iflytekApiSecret;
|
||||
break;
|
||||
case ModelProvider.XAI:
|
||||
systemApiKey = serverConfig.xaiApiKey;
|
||||
break;
|
||||
case ModelProvider.ChatGLM:
|
||||
systemApiKey = serverConfig.chatglmApiKey;
|
||||
break;
|
||||
case ModelProvider.GPT:
|
||||
default:
|
||||
if (serverConfig.isAzure) {
|
||||
if (req.nextUrl.pathname.includes("azure/deployments")) {
|
||||
systemApiKey = serverConfig.azureApiKey;
|
||||
} else {
|
||||
systemApiKey = serverConfig.apiKey;
|
||||
|
||||
32
app/api/azure.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ModelProvider } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "./auth";
|
||||
import { requestOpenai } from "./common";
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Azure Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const subpath = params.path.join("/");
|
||||
|
||||
const authResult = auth(req, ModelProvider.GPT);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestOpenai(req);
|
||||
} catch (e) {
|
||||
console.error("[Azure] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
144
app/api/baidu.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
BAIDU_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 { getAccessToken } from "@/app/utils/baidu";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Baidu Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Ernie);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (!serverConfig.baiduApiKey || !serverConfig.baiduSecretKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `missing BAIDU_API_KEY or BAIDU_SECRET_KEY in server env vars`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Baidu] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Baidu, "");
|
||||
|
||||
let baseUrl = serverConfig.baiduUrl || BAIDU_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 { access_token } = await getAccessToken(
|
||||
serverConfig.baiduApiKey as string,
|
||||
serverConfig.baiduSecretKey as string,
|
||||
);
|
||||
const fetchUrl = `${baseUrl}${path}?access_token=${access_token}`;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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.Baidu as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Baidu] 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);
|
||||
}
|
||||
}
|
||||
129
app/api/bytedance.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
BYTEDANCE_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";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[ByteDance Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.Doubao);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[ByteDance] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function request(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.ByteDance, "");
|
||||
|
||||
let baseUrl = serverConfig.bytedanceUrl || BYTEDANCE_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.ByteDance as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ByteDance] 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);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "../config/server";
|
||||
import { DEFAULT_MODELS, OPENAI_BASE_URL, GEMINI_BASE_URL } from "../constant";
|
||||
import { collectModelTable } from "../utils/model";
|
||||
import { makeAzurePath } from "../azure";
|
||||
import { OPENAI_BASE_URL, ServiceProvider } from "../constant";
|
||||
import { cloudflareAIGatewayUrl } from "../utils/cloudflare";
|
||||
import { getModelProvider, isModelAvailableInServer } from "../utils/model";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function requestOpenai(req: NextRequest) {
|
||||
const controller = new AbortController();
|
||||
|
||||
const isAzure = req.nextUrl.pathname.includes("azure/deployments");
|
||||
|
||||
var authValue,
|
||||
authHeaderName = "";
|
||||
if (serverConfig.isAzure) {
|
||||
if (isAzure) {
|
||||
authValue =
|
||||
req.headers
|
||||
.get("Authorization")
|
||||
@@ -25,13 +27,10 @@ export async function requestOpenai(req: NextRequest) {
|
||||
authHeaderName = "Authorization";
|
||||
}
|
||||
|
||||
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
|
||||
"/api/openai/",
|
||||
"",
|
||||
);
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll("/api/openai/", "");
|
||||
|
||||
let baseUrl =
|
||||
serverConfig.azureUrl || serverConfig.baseUrl || OPENAI_BASE_URL;
|
||||
(isAzure ? serverConfig.azureUrl : serverConfig.baseUrl) || OPENAI_BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
@@ -51,17 +50,46 @@ export async function requestOpenai(req: NextRequest) {
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
if (serverConfig.isAzure) {
|
||||
if (!serverConfig.azureApiVersion) {
|
||||
return NextResponse.json({
|
||||
error: true,
|
||||
message: `missing AZURE_API_VERSION in server env vars`,
|
||||
});
|
||||
if (isAzure) {
|
||||
const azureApiVersion =
|
||||
req?.nextUrl?.searchParams?.get("api-version") ||
|
||||
serverConfig.azureApiVersion;
|
||||
baseUrl = baseUrl.split("/deployments").shift() as string;
|
||||
path = `${req.nextUrl.pathname.replaceAll(
|
||||
"/api/azure/",
|
||||
"",
|
||||
)}?api-version=${azureApiVersion}`;
|
||||
|
||||
// Forward compatibility:
|
||||
// if display_name(deployment_name) not set, and '{deploy-id}' in AZURE_URL
|
||||
// then using default '{deploy-id}'
|
||||
if (serverConfig.customModels && serverConfig.azureUrl) {
|
||||
const modelName = path.split("/")[1];
|
||||
let realDeployName = "";
|
||||
serverConfig.customModels
|
||||
.split(",")
|
||||
.filter((v) => !!v && !v.startsWith("-") && v.includes(modelName))
|
||||
.forEach((m) => {
|
||||
const [fullName, displayName] = m.split("=");
|
||||
const [_, providerName] = getModelProvider(fullName);
|
||||
if (providerName === "azure" && !displayName) {
|
||||
const [_, deployId] = (serverConfig?.azureUrl ?? "").split(
|
||||
"deployments/",
|
||||
);
|
||||
if (deployId) {
|
||||
realDeployName = deployId;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (realDeployName) {
|
||||
console.log("[Replace with DeployId", realDeployName);
|
||||
path = path.replaceAll(modelName, realDeployName);
|
||||
}
|
||||
}
|
||||
path = makeAzurePath(path, serverConfig.azureApiVersion);
|
||||
}
|
||||
|
||||
const fetchUrl = `${baseUrl}/${path}`;
|
||||
const fetchUrl = cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
|
||||
console.log("fetchUrl", fetchUrl);
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -83,17 +111,24 @@ export async function requestOpenai(req: NextRequest) {
|
||||
// #1815 try to refuse gpt4 request
|
||||
if (serverConfig.customModels && req.body) {
|
||||
try {
|
||||
const modelTable = collectModelTable(
|
||||
DEFAULT_MODELS,
|
||||
serverConfig.customModels,
|
||||
);
|
||||
const clonedBody = await req.text();
|
||||
fetchOptions.body = clonedBody;
|
||||
|
||||
const jsonBody = JSON.parse(clonedBody) as { model?: string };
|
||||
|
||||
// not undefined and is false
|
||||
if (modelTable[jsonBody?.model ?? ""].available === false) {
|
||||
if (
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.OpenAI as string,
|
||||
) ||
|
||||
isModelAvailableInServer(
|
||||
serverConfig.customModels,
|
||||
jsonBody?.model as string,
|
||||
ServiceProvider.Azure as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -112,16 +147,16 @@ export async function requestOpenai(req: NextRequest) {
|
||||
try {
|
||||
const res = await fetch(fetchUrl, fetchOptions);
|
||||
|
||||
// Extract the OpenAI-Organization header from the response
|
||||
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
|
||||
// Extract the OpenAI-Organization header from the response
|
||||
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
|
||||
|
||||
// Check if serverConfig.openaiOrgId is defined and not an empty string
|
||||
if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
|
||||
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
|
||||
console.log("[Org ID]", openaiOrganizationHeader);
|
||||
} else {
|
||||
console.log("[Org ID] is not set up.");
|
||||
}
|
||||
// Check if serverConfig.openaiOrgId is defined and not an empty string
|
||||
if (serverConfig.openaiOrgId && serverConfig.openaiOrgId.trim() !== "") {
|
||||
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
|
||||
console.log("[Org ID]", openaiOrganizationHeader);
|
||||
} else {
|
||||
console.log("[Org ID] is not set up.");
|
||||
}
|
||||
|
||||
// to prevent browser prompt for credentials
|
||||
const newHeaders = new Headers(res.headers);
|
||||
@@ -129,7 +164,6 @@ export async function requestOpenai(req: NextRequest) {
|
||||
// to disable nginx buffering
|
||||
newHeaders.set("X-Accel-Buffering", "no");
|
||||
|
||||
|
||||
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
|
||||
// Also, this is to prevent the header from being sent to the client
|
||||
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
|
||||
@@ -142,7 +176,6 @@ export async function requestOpenai(req: NextRequest) {
|
||||
// The browser will try to decode the response with brotli and fail
|
||||
newHeaders.delete("content-encoding");
|
||||
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
|
||||
129
app/api/glm.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
CHATGLM_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";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[GLM Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.ChatGLM);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[GLM] ", 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.ChatGLM, "");
|
||||
|
||||
let baseUrl = serverConfig.chatglmUrl || CHATGLM_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}`;
|
||||
console.log("[Fetch Url] ", fetchUrl);
|
||||
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.ChatGLM as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[GLM] 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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "../../auth";
|
||||
import { auth } from "./auth";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import { GEMINI_BASE_URL, Google, ModelProvider } from "@/app/constant";
|
||||
import { ApiPath, GEMINI_BASE_URL, ModelProvider } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
|
||||
async function handle(
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
{ params }: { params: { provider: string; path: string[] } },
|
||||
) {
|
||||
console.log("[Google Route] params ", params);
|
||||
|
||||
@@ -13,32 +16,6 @@ async function handle(
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
|
||||
|
||||
if (!baseUrl.startsWith("http")) {
|
||||
baseUrl = `https://${baseUrl}`;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll("/api/google/", "");
|
||||
|
||||
console.log("[Proxy] ", path);
|
||||
console.log("[Base Url]", baseUrl);
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
const authResult = auth(req, ModelProvider.GeminiPro);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
@@ -46,12 +23,13 @@ async function handle(
|
||||
});
|
||||
}
|
||||
|
||||
const bearToken = req.headers.get("Authorization") ?? "";
|
||||
const bearToken =
|
||||
req.headers.get("x-goog-api-key") || req.headers.get("Authorization") || "";
|
||||
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
|
||||
|
||||
const key = token ? token : serverConfig.googleApiKey;
|
||||
const apiKey = token ? token : serverConfig.googleApiKey;
|
||||
|
||||
if (!key) {
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -62,12 +40,70 @@ async function handle(
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await request(req, apiKey);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[Google] ", e);
|
||||
return NextResponse.json(prettyObject(e));
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUrl = `${baseUrl}/${path}?key=${key}`;
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
export const runtime = "edge";
|
||||
export const preferredRegion = [
|
||||
"bom1",
|
||||
"cle1",
|
||||
"cpt1",
|
||||
"gru1",
|
||||
"hnd1",
|
||||
"iad1",
|
||||
"icn1",
|
||||
"kix1",
|
||||
"pdx1",
|
||||
"sfo1",
|
||||
"sin1",
|
||||
"syd1",
|
||||
];
|
||||
|
||||
async function request(req: NextRequest, apiKey: string) {
|
||||
const controller = new AbortController();
|
||||
|
||||
let baseUrl = serverConfig.googleUrl || GEMINI_BASE_URL;
|
||||
|
||||
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Google, "");
|
||||
|
||||
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}${
|
||||
req?.nextUrl?.searchParams?.get("alt") === "sse" ? "?alt=sse" : ""
|
||||
}`;
|
||||
|
||||
console.log("[Fetch Url] ", fetchUrl);
|
||||
const fetchOptions: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"x-goog-api-key":
|
||||
req.headers.get("x-goog-api-key") ||
|
||||
(req.headers.get("Authorization") ?? "").replace("Bearer ", ""),
|
||||
},
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
@@ -95,22 +131,3 @@ async function handle(
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
export const runtime = "edge";
|
||||
export const preferredRegion = [
|
||||
"bom1",
|
||||
"cle1",
|
||||
"cpt1",
|
||||
"gru1",
|
||||
"hnd1",
|
||||
"iad1",
|
||||
"icn1",
|
||||
"kix1",
|
||||
"pdx1",
|
||||
"sfo1",
|
||||
"sin1",
|
||||
"syd1",
|
||||
];
|
||||
129
app/api/iflytek.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
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";
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
128
app/api/moonshot.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
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";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,26 @@ import { getServerSideConfig } from "@/app/config/server";
|
||||
import { ModelProvider, OpenaiPath } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "../../auth";
|
||||
import { requestOpenai } from "../../common";
|
||||
import { auth } from "./auth";
|
||||
import { requestOpenai } from "./common";
|
||||
|
||||
const ALLOWD_PATH = new Set(Object.values(OpenaiPath));
|
||||
const ALLOWED_PATH = new Set(Object.values(OpenaiPath));
|
||||
|
||||
function getModels(remoteModelRes: OpenAIListModelResponse) {
|
||||
const config = getServerSideConfig();
|
||||
|
||||
if (config.disableGPT4) {
|
||||
remoteModelRes.data = remoteModelRes.data.filter(
|
||||
(m) => !m.id.startsWith("gpt-4"),
|
||||
(m) =>
|
||||
!(m.id.startsWith("gpt-4") || m.id.startsWith("chatgpt-4o")) ||
|
||||
m.id.startsWith("gpt-4o-mini"),
|
||||
);
|
||||
}
|
||||
|
||||
return remoteModelRes;
|
||||
}
|
||||
|
||||
async function handle(
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
@@ -32,7 +34,7 @@ async function handle(
|
||||
|
||||
const subpath = params.path.join("/");
|
||||
|
||||
if (!ALLOWD_PATH.has(subpath)) {
|
||||
if (!ALLOWED_PATH.has(subpath)) {
|
||||
console.log("[OpenAI Route] forbidden path ", subpath);
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -70,27 +72,3 @@ async function handle(
|
||||
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",
|
||||
];
|
||||
89
app/api/proxy.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[Proxy Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
// remove path params from searchParams
|
||||
req.nextUrl.searchParams.delete("path");
|
||||
req.nextUrl.searchParams.delete("provider");
|
||||
|
||||
const subpath = params.path.join("/");
|
||||
const fetchUrl = `${req.headers.get(
|
||||
"x-base-url",
|
||||
)}/${subpath}?${req.nextUrl.searchParams.toString()}`;
|
||||
const skipHeaders = ["connection", "host", "origin", "referer", "cookie"];
|
||||
const headers = new Headers(
|
||||
Array.from(req.headers.entries()).filter((item) => {
|
||||
if (
|
||||
item[0].indexOf("x-") > -1 ||
|
||||
item[0].indexOf("sec-") > -1 ||
|
||||
skipHeaders.includes(item[0])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
// if dalle3 use openai api key
|
||||
const baseUrl = req.headers.get("x-base-url");
|
||||
if (baseUrl?.includes("api.openai.com")) {
|
||||
if (!serverConfig.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "OpenAI API key not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
headers.set("Authorization", `Bearer ${serverConfig.apiKey}`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const fetchOptions: RequestInit = {
|
||||
headers,
|
||||
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,
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
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");
|
||||
|
||||
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
|
||||
// So if the streaming is disabled, we need to remove the content-encoding header
|
||||
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
|
||||
// The browser will try to decode the response with brotli and fail
|
||||
newHeaders.delete("content-encoding");
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
99
app/api/stability.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
117
app/api/tencent/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import { TENCENT_BASE_URL, ModelProvider } from "@/app/constant";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/api/auth";
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const config = getServerSideConfig();
|
||||
|
||||
const mergedAllowedWebDavEndpoints = [
|
||||
...internalAllowedWebDavEndpoints,
|
||||
...config.allowedWebDevEndpoints,
|
||||
...config.allowedWebDavEndpoints,
|
||||
].filter((domain) => Boolean(domain.trim()));
|
||||
|
||||
const normalizeUrl = (url: string) => {
|
||||
@@ -29,6 +29,7 @@ async function handle(
|
||||
|
||||
const requestUrl = new URL(req.url);
|
||||
let endpoint = requestUrl.searchParams.get("endpoint");
|
||||
let proxy_method = requestUrl.searchParams.get("proxy_method") || req.method;
|
||||
|
||||
// Validate the endpoint to prevent potential SSRF attacks
|
||||
if (
|
||||
@@ -37,9 +38,13 @@ async function handle(
|
||||
const normalizedAllowedEndpoint = normalizeUrl(allowedEndpoint);
|
||||
const normalizedEndpoint = normalizeUrl(endpoint as string);
|
||||
|
||||
return normalizedEndpoint &&
|
||||
return (
|
||||
normalizedEndpoint &&
|
||||
normalizedEndpoint.hostname === normalizedAllowedEndpoint?.hostname &&
|
||||
normalizedEndpoint.pathname.startsWith(normalizedAllowedEndpoint.pathname);
|
||||
normalizedEndpoint.pathname.startsWith(
|
||||
normalizedAllowedEndpoint.pathname,
|
||||
)
|
||||
);
|
||||
})
|
||||
) {
|
||||
return NextResponse.json(
|
||||
@@ -61,7 +66,11 @@ async function handle(
|
||||
const targetPath = `${endpoint}${endpointPath}`;
|
||||
|
||||
// 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(
|
||||
{
|
||||
error: true,
|
||||
@@ -74,7 +83,7 @@ async function handle(
|
||||
}
|
||||
|
||||
// for MKCOL request, only allow request ${folder}
|
||||
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
|
||||
if (proxy_method === "MKCOL" && !targetPath.endsWith(folder)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -87,7 +96,7 @@ async function handle(
|
||||
}
|
||||
|
||||
// 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(
|
||||
{
|
||||
error: true,
|
||||
@@ -100,7 +109,7 @@ async function handle(
|
||||
}
|
||||
|
||||
// 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(
|
||||
{
|
||||
error: true,
|
||||
@@ -114,7 +123,7 @@ async function handle(
|
||||
|
||||
const targetUrl = targetPath;
|
||||
|
||||
const method = req.method;
|
||||
const method = proxy_method || req.method;
|
||||
const shouldNotHaveBody = ["get", "head"].includes(
|
||||
method?.toLowerCase() ?? "",
|
||||
);
|
||||
@@ -139,7 +148,7 @@ async function handle(
|
||||
"[Any Proxy]",
|
||||
targetUrl,
|
||||
{
|
||||
method: req.method,
|
||||
method: method,
|
||||
},
|
||||
{
|
||||
status: fetchResult?.status,
|
||||
|
||||
128
app/api/xai.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
import {
|
||||
XAI_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";
|
||||
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
) {
|
||||
console.log("[XAI Route] params ", params);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return NextResponse.json({ body: "OK" }, { status: 200 });
|
||||
}
|
||||
|
||||
const authResult = auth(req, ModelProvider.XAI);
|
||||
if (authResult.error) {
|
||||
return NextResponse.json(authResult, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(req);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error("[XAI] ", 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.XAI, "");
|
||||
|
||||
let baseUrl = serverConfig.xaiUrl || XAI_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.XAI as string,
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
message: `you are not allowed to use ${jsonBody?.model} model`,
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[XAI] 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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export function makeAzurePath(path: string, apiVersion: string) {
|
||||
// should omit /v1 prefix
|
||||
path = path.replaceAll("v1/", "");
|
||||
|
||||
// should add api-key to query string
|
||||
path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`;
|
||||
|
||||
return path;
|
||||
}
|
||||
@@ -1,18 +1,33 @@
|
||||
import { getClientConfig } from "../config/client";
|
||||
import {
|
||||
ACCESS_CODE_PREFIX,
|
||||
Azure,
|
||||
ModelProvider,
|
||||
ServiceProvider,
|
||||
} from "../constant";
|
||||
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
|
||||
import { ChatGPTApi } from "./platforms/openai";
|
||||
import {
|
||||
ChatMessageTool,
|
||||
ChatMessage,
|
||||
ModelType,
|
||||
useAccessStore,
|
||||
useChatStore,
|
||||
} from "../store";
|
||||
import { ChatGPTApi, DalleRequestPayload } from "./platforms/openai";
|
||||
import { GeminiProApi } from "./platforms/google";
|
||||
import { ClaudeApi } from "./platforms/anthropic";
|
||||
import { ErnieApi } from "./platforms/baidu";
|
||||
import { DoubaoApi } from "./platforms/bytedance";
|
||||
import { QwenApi } from "./platforms/alibaba";
|
||||
import { HunyuanApi } from "./platforms/tencent";
|
||||
import { MoonshotApi } from "./platforms/moonshot";
|
||||
import { SparkApi } from "./platforms/iflytek";
|
||||
import { XAIApi } from "./platforms/xai";
|
||||
import { ChatGLMApi } from "./platforms/glm";
|
||||
|
||||
export const ROLES = ["system", "user", "assistant"] as const;
|
||||
export type MessageRole = (typeof ROLES)[number];
|
||||
|
||||
export const Models = ["gpt-3.5-turbo", "gpt-4"] as const;
|
||||
export const TTSModels = ["tts-1", "tts-1-hd"] as const;
|
||||
export type ChatModel = ModelType;
|
||||
|
||||
export interface MultimodalContent {
|
||||
@@ -30,11 +45,24 @@ export interface RequestMessage {
|
||||
|
||||
export interface LLMConfig {
|
||||
model: string;
|
||||
providerName?: string;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stream?: boolean;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
size?: DalleRequestPayload["size"];
|
||||
quality?: DalleRequestPayload["quality"];
|
||||
style?: DalleRequestPayload["style"];
|
||||
}
|
||||
|
||||
export interface SpeechOptions {
|
||||
model: string;
|
||||
input: string;
|
||||
voice: string;
|
||||
response_format?: string;
|
||||
speed?: number;
|
||||
onController?: (controller: AbortController) => void;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
@@ -42,9 +70,11 @@ export interface ChatOptions {
|
||||
config: LLMConfig;
|
||||
|
||||
onUpdate?: (message: string, chunk: string) => void;
|
||||
onFinish: (message: string) => void;
|
||||
onFinish: (message: string, responseRes: Response) => void;
|
||||
onError?: (err: Error) => void;
|
||||
onController?: (controller: AbortController) => void;
|
||||
onBeforeTool?: (tool: ChatMessageTool) => void;
|
||||
onAfterTool?: (tool: ChatMessageTool) => void;
|
||||
}
|
||||
|
||||
export interface LLMUsage {
|
||||
@@ -54,18 +84,22 @@ export interface LLMUsage {
|
||||
|
||||
export interface LLMModel {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
available: boolean;
|
||||
provider: LLMModelProvider;
|
||||
sorted: number;
|
||||
}
|
||||
|
||||
export interface LLMModelProvider {
|
||||
id: string;
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
sorted: number;
|
||||
}
|
||||
|
||||
export abstract class LLMApi {
|
||||
abstract chat(options: ChatOptions): Promise<void>;
|
||||
abstract speech(options: SpeechOptions): Promise<ArrayBuffer>;
|
||||
abstract usage(): Promise<LLMUsage>;
|
||||
abstract models(): Promise<LLMModel[]>;
|
||||
}
|
||||
@@ -102,6 +136,30 @@ export class ClientApi {
|
||||
case ModelProvider.Claude:
|
||||
this.llm = new ClaudeApi();
|
||||
break;
|
||||
case ModelProvider.Ernie:
|
||||
this.llm = new ErnieApi();
|
||||
break;
|
||||
case ModelProvider.Doubao:
|
||||
this.llm = new DoubaoApi();
|
||||
break;
|
||||
case ModelProvider.Qwen:
|
||||
this.llm = new QwenApi();
|
||||
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;
|
||||
case ModelProvider.XAI:
|
||||
this.llm = new XAIApi();
|
||||
break;
|
||||
case ModelProvider.ChatGLM:
|
||||
this.llm = new ChatGLMApi();
|
||||
break;
|
||||
default:
|
||||
this.llm = new ChatGPTApi();
|
||||
}
|
||||
@@ -153,39 +211,144 @@ export class ClientApi {
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeaders() {
|
||||
const accessStore = useAccessStore.getState();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;
|
||||
const isGoogle = modelConfig.model.startsWith("gemini");
|
||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||
const authHeader = isAzure ? "api-key" : "Authorization";
|
||||
const apiKey = isGoogle
|
||||
? accessStore.googleApiKey
|
||||
: isAzure
|
||||
? accessStore.azureApiKey
|
||||
: accessStore.openaiApiKey;
|
||||
const clientConfig = getClientConfig();
|
||||
const makeBearer = (s: string) => `${isAzure ? "" : "Bearer "}${s.trim()}`;
|
||||
const validString = (x: string) => x && x.length > 0;
|
||||
export function getBearerToken(
|
||||
apiKey: string,
|
||||
noBearer: boolean = false,
|
||||
): string {
|
||||
return validString(apiKey)
|
||||
? `${noBearer ? "" : "Bearer "}${apiKey.trim()}`
|
||||
: "";
|
||||
}
|
||||
|
||||
// when using google api in app, not set auth header
|
||||
if (!(isGoogle && clientConfig?.isApp)) {
|
||||
// use user's api key first
|
||||
if (validString(apiKey)) {
|
||||
headers[authHeader] = makeBearer(apiKey);
|
||||
} else if (
|
||||
accessStore.enabledAccessControl() &&
|
||||
validString(accessStore.accessCode)
|
||||
) {
|
||||
headers[authHeader] = makeBearer(
|
||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||
);
|
||||
}
|
||||
export function validString(x: string): boolean {
|
||||
return x?.length > 0;
|
||||
}
|
||||
|
||||
export function getHeaders(ignoreHeaders: boolean = false) {
|
||||
const accessStore = useAccessStore.getState();
|
||||
const chatStore = useChatStore.getState();
|
||||
let headers: Record<string, string> = {};
|
||||
if (!ignoreHeaders) {
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
const clientConfig = getClientConfig();
|
||||
|
||||
function getConfig() {
|
||||
const modelConfig = chatStore.currentSession().mask.modelConfig;
|
||||
const isGoogle = modelConfig.providerName === ServiceProvider.Google;
|
||||
const isAzure = modelConfig.providerName === ServiceProvider.Azure;
|
||||
const isAnthropic = modelConfig.providerName === ServiceProvider.Anthropic;
|
||||
const isBaidu = modelConfig.providerName == ServiceProvider.Baidu;
|
||||
const isByteDance = modelConfig.providerName === ServiceProvider.ByteDance;
|
||||
const isAlibaba = modelConfig.providerName === ServiceProvider.Alibaba;
|
||||
const isMoonshot = modelConfig.providerName === ServiceProvider.Moonshot;
|
||||
const isIflytek = modelConfig.providerName === ServiceProvider.Iflytek;
|
||||
const isXAI = modelConfig.providerName === ServiceProvider.XAI;
|
||||
const isChatGLM = modelConfig.providerName === ServiceProvider.ChatGLM;
|
||||
const isEnabledAccessControl = accessStore.enabledAccessControl();
|
||||
const apiKey = isGoogle
|
||||
? accessStore.googleApiKey
|
||||
: isAzure
|
||||
? accessStore.azureApiKey
|
||||
: isAnthropic
|
||||
? accessStore.anthropicApiKey
|
||||
: isByteDance
|
||||
? accessStore.bytedanceApiKey
|
||||
: isAlibaba
|
||||
? accessStore.alibabaApiKey
|
||||
: isMoonshot
|
||||
? accessStore.moonshotApiKey
|
||||
: isXAI
|
||||
? accessStore.xaiApiKey
|
||||
: isChatGLM
|
||||
? accessStore.chatglmApiKey
|
||||
: isIflytek
|
||||
? accessStore.iflytekApiKey && accessStore.iflytekApiSecret
|
||||
? accessStore.iflytekApiKey + ":" + accessStore.iflytekApiSecret
|
||||
: ""
|
||||
: accessStore.openaiApiKey;
|
||||
return {
|
||||
isGoogle,
|
||||
isAzure,
|
||||
isAnthropic,
|
||||
isBaidu,
|
||||
isByteDance,
|
||||
isAlibaba,
|
||||
isMoonshot,
|
||||
isIflytek,
|
||||
isXAI,
|
||||
isChatGLM,
|
||||
apiKey,
|
||||
isEnabledAccessControl,
|
||||
};
|
||||
}
|
||||
|
||||
function getAuthHeader(): string {
|
||||
return isAzure
|
||||
? "api-key"
|
||||
: isAnthropic
|
||||
? "x-api-key"
|
||||
: isGoogle
|
||||
? "x-goog-api-key"
|
||||
: "Authorization";
|
||||
}
|
||||
|
||||
const {
|
||||
isGoogle,
|
||||
isAzure,
|
||||
isAnthropic,
|
||||
isBaidu,
|
||||
apiKey,
|
||||
isEnabledAccessControl,
|
||||
} = getConfig();
|
||||
// when using baidu api in app, not set auth header
|
||||
if (isBaidu && clientConfig?.isApp) return headers;
|
||||
|
||||
const authHeader = getAuthHeader();
|
||||
|
||||
const bearerToken = getBearerToken(
|
||||
apiKey,
|
||||
isAzure || isAnthropic || isGoogle,
|
||||
);
|
||||
|
||||
if (bearerToken) {
|
||||
headers[authHeader] = bearerToken;
|
||||
} else if (isEnabledAccessControl && validString(accessStore.accessCode)) {
|
||||
headers["Authorization"] = getBearerToken(
|
||||
ACCESS_CODE_PREFIX + accessStore.accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function getClientApi(provider: ServiceProvider): ClientApi {
|
||||
switch (provider) {
|
||||
case ServiceProvider.Google:
|
||||
return new ClientApi(ModelProvider.GeminiPro);
|
||||
case ServiceProvider.Anthropic:
|
||||
return new ClientApi(ModelProvider.Claude);
|
||||
case ServiceProvider.Baidu:
|
||||
return new ClientApi(ModelProvider.Ernie);
|
||||
case ServiceProvider.ByteDance:
|
||||
return new ClientApi(ModelProvider.Doubao);
|
||||
case ServiceProvider.Alibaba:
|
||||
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);
|
||||
case ServiceProvider.XAI:
|
||||
return new ClientApi(ModelProvider.XAI);
|
||||
case ServiceProvider.ChatGLM:
|
||||
return new ClientApi(ModelProvider.ChatGLM);
|
||||
default:
|
||||
return new ClientApi(ModelProvider.GPT);
|
||||
}
|
||||
}
|
||||
|
||||
277
app/client/platforms/alibaba.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
Alibaba,
|
||||
ALIBABA_BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
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 { fetch } from "@/app/utils/stream";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RequestInput {
|
||||
messages: {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | MultimodalContent[];
|
||||
}[];
|
||||
}
|
||||
interface RequestParam {
|
||||
result_format: string;
|
||||
incremental_output?: boolean;
|
||||
temperature: number;
|
||||
repetition_penalty?: number;
|
||||
top_p: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
interface RequestPayload {
|
||||
model: string;
|
||||
input: RequestInput;
|
||||
parameters: RequestParam;
|
||||
}
|
||||
|
||||
export class QwenApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.alibabaUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp ? ALIBABA_BASE_URL : ApiPath.Alibaba;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Alibaba)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res?.output?.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const requestPayload: RequestPayload = {
|
||||
model: modelConfig.model,
|
||||
input: {
|
||||
messages,
|
||||
},
|
||||
parameters: {
|
||||
result_format: "message",
|
||||
incremental_output: shouldStream,
|
||||
temperature: modelConfig.temperature,
|
||||
// max_tokens: modelConfig.max_tokens,
|
||||
top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(Alibaba.ChatPath);
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
"X-DashScope-SSE": shouldStream ? "enable" : "disable",
|
||||
},
|
||||
};
|
||||
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
let responseRes: Response;
|
||||
|
||||
// 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, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
fetch: fetch as any,
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[Alibaba] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
responseRes = res;
|
||||
|
||||
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.output.choices as Array<{
|
||||
message: { content: string };
|
||||
}>;
|
||||
const delta = choices[0]?.message?.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, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
export { Alibaba };
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ACCESS_CODE_PREFIX, Anthropic, ApiPath } from "@/app/constant";
|
||||
import { ChatOptions, LLMApi, MultimodalContent } from "../api";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||
import { RequestMessage } from "@/app/typing";
|
||||
import { Anthropic, ApiPath } from "@/app/constant";
|
||||
import { ChatOptions, getHeaders, LLMApi, SpeechOptions } from "../api";
|
||||
import {
|
||||
EventStreamContentType,
|
||||
fetchEventSource,
|
||||
} from "@fortaine/fetch-event-source";
|
||||
|
||||
import Locale from "../../locales";
|
||||
import { prettyObject } from "@/app/utils/format";
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
usePluginStore,
|
||||
ChatMessageTool,
|
||||
} from "@/app/store";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { ANTHROPIC_BASE_URL } from "@/app/constant";
|
||||
import { getMessageTextContent, isVisionModel } from "@/app/utils";
|
||||
import { preProcessImageContent, stream } from "@/app/utils/chat";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export type MultiBlockContent = {
|
||||
type: "image" | "text";
|
||||
@@ -72,6 +74,10 @@ const ClaudeMapper = {
|
||||
const keys = ["claude-2, claude-instant-1"];
|
||||
|
||||
export class ClaudeApi implements LLMApi {
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
console.log("[Response] claude response: ", res);
|
||||
|
||||
@@ -92,7 +98,12 @@ export class ClaudeApi implements LLMApi {
|
||||
},
|
||||
};
|
||||
|
||||
const messages = [...options.messages];
|
||||
// try get base64image from local cache image_url
|
||||
const messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = await preProcessImageContent(v.content);
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
const keys = ["system", "user"];
|
||||
|
||||
@@ -185,121 +196,135 @@ export class ClaudeApi implements LLMApi {
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
const payload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"x-api-key": accessStore.anthropicApiKey,
|
||||
"anthropic-version": accessStore.anthropicApiVersion,
|
||||
Authorization: getAuthKey(accessStore.anthropicApiKey),
|
||||
},
|
||||
};
|
||||
|
||||
if (shouldStream) {
|
||||
try {
|
||||
const context = {
|
||||
text: "",
|
||||
finished: false,
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (!context.finished) {
|
||||
options.onFinish(context.text);
|
||||
context.finished = true;
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
fetchEventSource(path, {
|
||||
...payload,
|
||||
async onopen(res) {
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("response content type: ", contentType);
|
||||
|
||||
if (contentType?.startsWith("text/plain")) {
|
||||
context.text = await res.clone().text();
|
||||
return finish();
|
||||
}
|
||||
|
||||
if (
|
||||
!res.ok ||
|
||||
!res.headers
|
||||
.get("content-type")
|
||||
?.startsWith(EventStreamContentType) ||
|
||||
res.status !== 200
|
||||
) {
|
||||
const responseTexts = [context.text];
|
||||
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);
|
||||
}
|
||||
|
||||
context.text = responseTexts.join("\n\n");
|
||||
|
||||
return finish();
|
||||
}
|
||||
},
|
||||
onmessage(msg) {
|
||||
let chunkJson:
|
||||
| undefined
|
||||
| {
|
||||
type: "content_block_delta" | "content_block_stop";
|
||||
delta?: {
|
||||
type: "text_delta";
|
||||
text: string;
|
||||
};
|
||||
index: number;
|
||||
let index = -1;
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
path,
|
||||
requestBody,
|
||||
{
|
||||
...getHeaders(),
|
||||
"anthropic-version": accessStore.anthropicApiVersion,
|
||||
},
|
||||
// @ts-ignore
|
||||
tools.map((tool) => ({
|
||||
name: tool?.function?.name,
|
||||
description: tool?.function?.description,
|
||||
input_schema: tool?.function?.parameters,
|
||||
})),
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
let chunkJson:
|
||||
| undefined
|
||||
| {
|
||||
type: "content_block_delta" | "content_block_stop";
|
||||
content_block?: {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
try {
|
||||
chunkJson = JSON.parse(msg.data);
|
||||
} catch (e) {
|
||||
console.error("[Response] parse error", msg.data);
|
||||
}
|
||||
delta?: {
|
||||
type: "text_delta" | "input_json_delta";
|
||||
text?: string;
|
||||
partial_json?: string;
|
||||
};
|
||||
index: number;
|
||||
};
|
||||
chunkJson = JSON.parse(text);
|
||||
|
||||
if (!chunkJson || chunkJson.type === "content_block_stop") {
|
||||
return finish();
|
||||
}
|
||||
|
||||
const { delta } = chunkJson;
|
||||
if (delta?.text) {
|
||||
context.text += delta.text;
|
||||
options.onUpdate?.(context.text, delta.text);
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
finish();
|
||||
},
|
||||
onerror(e) {
|
||||
options.onError?.(e);
|
||||
throw e;
|
||||
},
|
||||
openWhenHidden: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("failed to chat", e);
|
||||
options.onError?.(e as Error);
|
||||
}
|
||||
if (chunkJson?.content_block?.type == "tool_use") {
|
||||
index += 1;
|
||||
const id = chunkJson?.content_block.id;
|
||||
const name = chunkJson?.content_block.name;
|
||||
runTools.push({
|
||||
id,
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
arguments: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
chunkJson?.delta?.type == "input_json_delta" &&
|
||||
chunkJson?.delta?.partial_json
|
||||
) {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] +=
|
||||
chunkJson?.delta?.partial_json;
|
||||
}
|
||||
return chunkJson?.delta?.text;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// reset index value
|
||||
index = -1;
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
{
|
||||
role: "assistant",
|
||||
content: toolCallMessage.tool_calls.map(
|
||||
(tool: ChatMessageTool) => ({
|
||||
type: "tool_use",
|
||||
id: tool.id,
|
||||
name: tool?.function?.name,
|
||||
input: tool?.function?.arguments
|
||||
? JSON.parse(tool?.function?.arguments)
|
||||
: {},
|
||||
}),
|
||||
),
|
||||
},
|
||||
// @ts-ignore
|
||||
...toolCallResult.map((result) => ({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: result.tool_call_id,
|
||||
content: result.content,
|
||||
},
|
||||
],
|
||||
})),
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const payload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...getHeaders(), // get common headers
|
||||
"anthropic-version": accessStore.anthropicApiVersion,
|
||||
// do not send `anthropicApiKey` in browser!!!
|
||||
// Authorization: getAuthKey(accessStore.anthropicApiKey),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
controller.signal.onabort = () => options.onFinish("");
|
||||
controller.signal.onabort = () =>
|
||||
options.onFinish("", new Response(null, { status: 400 }));
|
||||
|
||||
const res = await fetch(path, payload);
|
||||
const resJson = await res.json();
|
||||
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
options.onFinish(message, res);
|
||||
} catch (e) {
|
||||
console.error("failed to chat", e);
|
||||
options.onError?.(e as Error);
|
||||
@@ -365,9 +390,7 @@ export class ClaudeApi implements LLMApi {
|
||||
if (baseUrl.trim().length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
|
||||
baseUrl = isApp
|
||||
? DEFAULT_API_HOST + "/api/proxy/anthropic"
|
||||
: ApiPath.Anthropic;
|
||||
baseUrl = isApp ? ANTHROPIC_BASE_URL : ApiPath.Anthropic;
|
||||
}
|
||||
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
|
||||
@@ -376,7 +399,8 @@ export class ClaudeApi implements LLMApi {
|
||||
|
||||
baseUrl = trimEnd(baseUrl, "/");
|
||||
|
||||
return `${baseUrl}/${path}`;
|
||||
// try rebuild url, when using cloudflare ai gateway in client
|
||||
return cloudflareAIGatewayUrl(`${baseUrl}/${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,27 +413,3 @@ function trimEnd(s: string, end = " ") {
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function bearer(value: string) {
|
||||
return `Bearer ${value.trim()}`;
|
||||
}
|
||||
|
||||
function getAuthKey(apiKey = "") {
|
||||
const accessStore = useAccessStore.getState();
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
let authKey = "";
|
||||
|
||||
if (apiKey) {
|
||||
// use user's api key first
|
||||
authKey = bearer(apiKey);
|
||||
} else if (
|
||||
accessStore.enabledAccessControl() &&
|
||||
!isApp &&
|
||||
!!accessStore.accessCode
|
||||
) {
|
||||
// or use access code
|
||||
authKey = bearer(ACCESS_CODE_PREFIX + accessStore.accessCode);
|
||||
}
|
||||
|
||||
return authKey;
|
||||
}
|
||||
|
||||
289
app/client/platforms/baidu.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
Baidu,
|
||||
BAIDU_BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import { getAccessToken } from "@/app/utils/baidu";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} 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 { fetch } from "@/app/utils/stream";
|
||||
|
||||
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;
|
||||
presence_penalty: number;
|
||||
frequency_penalty: number;
|
||||
top_p: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export class ErnieApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.baiduUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
// do not use proxy for baidubce api
|
||||
baseUrl = isApp ? BAIDU_BASE_URL : ApiPath.Baidu;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Baidu)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
// "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),
|
||||
}));
|
||||
|
||||
// "error_code": 336006, "error_msg": "the length of messages must be an odd number",
|
||||
if (messages.length % 2 === 0) {
|
||||
if (messages.at(0)?.role === "user") {
|
||||
messages.splice(1, 0, {
|
||||
role: "assistant",
|
||||
content: " ",
|
||||
});
|
||||
} else {
|
||||
messages.unshift({
|
||||
role: "user",
|
||||
content: " ",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: shouldStream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
};
|
||||
|
||||
console.log("[Request] Baidu payload: ", requestPayload);
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
let chatPath = this.path(Baidu.ChatPath(modelConfig.model));
|
||||
|
||||
// getAccessToken can not run in browser, because cors error
|
||||
if (!!getClientConfig()?.isApp) {
|
||||
const accessStore = useAccessStore.getState();
|
||||
if (accessStore.useCustomConfig) {
|
||||
if (accessStore.isValidBaidu()) {
|
||||
const { access_token } = await getAccessToken(
|
||||
accessStore.baiduApiKey,
|
||||
accessStore.baiduSecretKey,
|
||||
);
|
||||
chatPath = `${chatPath}${
|
||||
chatPath.includes("?") ? "&" : "?"
|
||||
}access_token=${access_token}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
let responseRes: Response;
|
||||
|
||||
// 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, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
fetch: fetch as any,
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Baidu] request response content type: ", contentType);
|
||||
responseRes = res;
|
||||
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 delta = json?.result;
|
||||
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 = resJson?.result;
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
export { Baidu };
|
||||
263
app/client/platforms/bytedance.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
ByteDance,
|
||||
BYTEDANCE_BASE_URL,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} 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 { fetch } from "@/app/utils/stream";
|
||||
|
||||
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;
|
||||
presence_penalty: number;
|
||||
frequency_penalty: number;
|
||||
top_p: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export class DoubaoApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.bytedanceUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp ? BYTEDANCE_BASE_URL : ApiPath.ByteDance;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ByteDance)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const messages = options.messages.map((v) => ({
|
||||
role: v.role,
|
||||
content: getMessageTextContent(v),
|
||||
}));
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
},
|
||||
};
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const requestPayload: RequestPayload = {
|
||||
messages,
|
||||
stream: shouldStream,
|
||||
model: modelConfig.model,
|
||||
temperature: modelConfig.temperature,
|
||||
presence_penalty: modelConfig.presence_penalty,
|
||||
frequency_penalty: modelConfig.frequency_penalty,
|
||||
top_p: modelConfig.top_p,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(ByteDance.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;
|
||||
let responseRes: Response;
|
||||
|
||||
// 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, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
fetch: fetch as any,
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[ByteDance] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
responseRes = res;
|
||||
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, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
export { ByteDance };
|
||||
197
app/client/platforms/glm.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
CHATGLM_BASE_URL,
|
||||
ChatGLM,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class ChatGLMApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.chatglmUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.ChatGLM;
|
||||
baseUrl = isApp ? CHATGLM_BASE_URL : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.ChatGLM)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
console.log("[Request] glm payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(ChatGLM.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) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,87 @@
|
||||
import { Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import { ChatOptions, getHeaders, LLMApi, LLMModel, LLMUsage } from "../api";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import { ApiPath, Google, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
LLMUsage,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
usePluginStore,
|
||||
ChatMessageTool,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { DEFAULT_API_HOST } from "@/app/constant";
|
||||
import { GEMINI_BASE_URL } from "@/app/constant";
|
||||
|
||||
import {
|
||||
getMessageTextContent,
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
} from "@/app/utils";
|
||||
import { preProcessImageContent } from "@/app/utils/chat";
|
||||
import { nanoid } from "nanoid";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class GeminiProApi implements LLMApi {
|
||||
path(path: string, shouldStream = false): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.googleUrl;
|
||||
}
|
||||
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
if (baseUrl.length === 0) {
|
||||
baseUrl = isApp ? GEMINI_BASE_URL : ApiPath.Google;
|
||||
}
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.Google)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
let chatPath = [baseUrl, path].join("/");
|
||||
if (shouldStream) {
|
||||
chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
|
||||
}
|
||||
|
||||
return chatPath;
|
||||
}
|
||||
extractMessage(res: any) {
|
||||
console.log("[Response] gemini-pro response: ", res);
|
||||
|
||||
return (
|
||||
res?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
||||
res?.at(0)?.candidates?.at(0)?.content?.parts.at(0)?.text ||
|
||||
res?.error?.message ||
|
||||
""
|
||||
);
|
||||
}
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions): Promise<void> {
|
||||
// const apiClient = this;
|
||||
const apiClient = this;
|
||||
let multimodal = false;
|
||||
const messages = options.messages.map((v) => {
|
||||
|
||||
// try get base64image from local cache image_url
|
||||
const _messages: ChatOptions["messages"] = [];
|
||||
for (const v of options.messages) {
|
||||
const content = await preProcessImageContent(v.content);
|
||||
_messages.push({ role: v.role, content });
|
||||
}
|
||||
const messages = _messages.map((v) => {
|
||||
let parts: any[] = [{ text: getMessageTextContent(v) }];
|
||||
if (isVisionModel(options.config.model)) {
|
||||
const images = getMessageImages(v);
|
||||
@@ -64,6 +123,9 @@ export class GeminiProApi implements LLMApi {
|
||||
// if (visionModel && messages.length > 1) {
|
||||
// options.onError?.(new Error("Multiturn chat is not enabled for models/gemini-pro-vision"));
|
||||
// }
|
||||
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
@@ -85,48 +147,33 @@ export class GeminiProApi implements LLMApi {
|
||||
safetySettings: [
|
||||
{
|
||||
category: "HARM_CATEGORY_HARASSMENT",
|
||||
threshold: "BLOCK_ONLY_HIGH",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||
threshold: "BLOCK_ONLY_HIGH",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: "BLOCK_ONLY_HIGH",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_ONLY_HIGH",
|
||||
threshold: accessStore.googleSafetySettings,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.googleUrl;
|
||||
}
|
||||
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
|
||||
let shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
try {
|
||||
// let baseUrl = accessStore.googleUrl;
|
||||
// https://github.com/google-gemini/cookbook/blob/main/quickstarts/rest/Streaming_REST.ipynb
|
||||
const chatPath = this.path(
|
||||
Google.ChatPath(modelConfig.model),
|
||||
shouldStream,
|
||||
);
|
||||
|
||||
if (!baseUrl) {
|
||||
baseUrl = isApp
|
||||
? DEFAULT_API_HOST + "/api/proxy/google/" + Google.ChatPath(modelConfig.model)
|
||||
: this.path(Google.ChatPath(modelConfig.model));
|
||||
}
|
||||
|
||||
if (isApp) {
|
||||
baseUrl += `?key=${accessStore.googleApiKey}`;
|
||||
}
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
@@ -139,108 +186,88 @@ export class GeminiProApi implements LLMApi {
|
||||
() => controller.abort(),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
|
||||
if (shouldStream) {
|
||||
let responseText = "";
|
||||
let remainText = "";
|
||||
let finished = false;
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
// @ts-ignore
|
||||
tools.length > 0
|
||||
? // @ts-ignore
|
||||
[{ functionDeclarations: tools.map((tool) => tool.function) }]
|
||||
: [],
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const chunkJson = JSON.parse(text);
|
||||
|
||||
let existingTexts: string[] = [];
|
||||
const finish = () => {
|
||||
finished = true;
|
||||
options.onFinish(existingTexts.join(""));
|
||||
};
|
||||
|
||||
// animate response to make it looks smooth
|
||||
function animateResponseText() {
|
||||
if (finished || controller.signal.aborted) {
|
||||
responseText += remainText;
|
||||
finish();
|
||||
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();
|
||||
|
||||
fetch(
|
||||
baseUrl.replace("generateContent", "streamGenerateContent"),
|
||||
chatPayload,
|
||||
)
|
||||
.then((response) => {
|
||||
const reader = response?.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let partialData = "";
|
||||
|
||||
return reader?.read().then(function processText({
|
||||
done,
|
||||
value,
|
||||
}): Promise<any> {
|
||||
if (done) {
|
||||
if (response.status !== 200) {
|
||||
try {
|
||||
let data = JSON.parse(ensureProperEnding(partialData));
|
||||
if (data && data[0].error) {
|
||||
options.onError?.(new Error(data[0].error.message));
|
||||
} else {
|
||||
options.onError?.(new Error("Request failed"));
|
||||
}
|
||||
} catch (_) {
|
||||
options.onError?.(new Error("Request failed"));
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Stream complete");
|
||||
// options.onFinish(responseText + remainText);
|
||||
finished = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
partialData += decoder.decode(value, { stream: true });
|
||||
|
||||
try {
|
||||
let data = JSON.parse(ensureProperEnding(partialData));
|
||||
|
||||
const textArray = data.reduce(
|
||||
(acc: string[], item: { candidates: any[] }) => {
|
||||
const texts = item.candidates.map((candidate) =>
|
||||
candidate.content.parts
|
||||
.map((part: { text: any }) => part.text)
|
||||
.join(""),
|
||||
);
|
||||
return acc.concat(texts);
|
||||
const functionCall = chunkJson?.candidates
|
||||
?.at(0)
|
||||
?.content.parts.at(0)?.functionCall;
|
||||
if (functionCall) {
|
||||
const { name, args } = functionCall;
|
||||
runTools.push({
|
||||
id: nanoid(),
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
arguments: JSON.stringify(args), // utils.chat call function, using JSON.parse
|
||||
},
|
||||
});
|
||||
}
|
||||
return chunkJson?.candidates?.at(0)?.content.parts.at(0)?.text;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.contents?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.contents?.length,
|
||||
0,
|
||||
{
|
||||
role: "model",
|
||||
parts: toolCallMessage.tool_calls.map(
|
||||
(tool: ChatMessageTool) => ({
|
||||
functionCall: {
|
||||
name: tool?.function?.name,
|
||||
args: JSON.parse(tool?.function?.arguments as string),
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
// @ts-ignore
|
||||
...toolCallResult.map((result) => ({
|
||||
role: "function",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: result.name,
|
||||
response: {
|
||||
name: result.name,
|
||||
content: result.content, // TODO just text content...
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (textArray.length > existingTexts.length) {
|
||||
const deltaArray = textArray.slice(existingTexts.length);
|
||||
existingTexts = textArray;
|
||||
remainText += deltaArray.join("");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.log("[Response Animation] error: ", error,partialData);
|
||||
// skip error message when parsing json
|
||||
}
|
||||
|
||||
return reader.read().then(processText);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
});
|
||||
],
|
||||
})),
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(baseUrl, chatPayload);
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
const resJson = await res.json();
|
||||
if (resJson?.promptFeedback?.blockReason) {
|
||||
@@ -252,8 +279,8 @@ export class GeminiProApi implements LLMApi {
|
||||
),
|
||||
);
|
||||
}
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message);
|
||||
const message = apiClient.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
@@ -266,14 +293,4 @@ export class GeminiProApi implements LLMApi {
|
||||
async models(): Promise<LLMModel[]> {
|
||||
return [];
|
||||
}
|
||||
path(path: string): string {
|
||||
return "/api/google/" + path;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureProperEnding(str: string) {
|
||||
if (str.startsWith("[") && !str.endsWith("]")) {
|
||||
return str + "]";
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
253
app/client/platforms/iflytek.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
import {
|
||||
ApiPath,
|
||||
IFLYTEK_BASE_URL,
|
||||
Iflytek,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} 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 { fetch } from "@/app/utils/stream";
|
||||
|
||||
import { 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 ? IFLYTEK_BASE_URL : 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 ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
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;
|
||||
let responseRes: Response;
|
||||
|
||||
// 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, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
fetch: fetch as any,
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log("[Spark] request response content type: ", contentType);
|
||||
responseRes = res;
|
||||
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, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
200
app/client/platforms/moonshot.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import {
|
||||
ApiPath,
|
||||
MOONSHOT_BASE_URL,
|
||||
Moonshot,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
} from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
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 ? MOONSHOT_BASE_URL : 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 ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
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) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import {
|
||||
ApiPath,
|
||||
DEFAULT_API_HOST,
|
||||
OPENAI_BASE_URL,
|
||||
DEFAULT_MODELS,
|
||||
OpenaiPath,
|
||||
Azure,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
ServiceProvider,
|
||||
} from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
import {
|
||||
ChatMessageTool,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { collectModelsWithDefaultModel } from "@/app/utils/model";
|
||||
import {
|
||||
preProcessImageContent,
|
||||
uploadImage,
|
||||
base64Image2Blob,
|
||||
stream,
|
||||
} from "@/app/utils/chat";
|
||||
import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare";
|
||||
import { DalleSize, DalleQuality, DalleStyle } from "@/app/typing";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
@@ -16,20 +33,16 @@ import {
|
||||
LLMModel,
|
||||
LLMUsage,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} 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 { makeAzurePath } from "@/app/azure";
|
||||
import {
|
||||
getMessageTextContent,
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
isDalle3 as _isDalle3,
|
||||
} from "@/app/utils";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export interface OpenAIListModelResponse {
|
||||
object: string;
|
||||
@@ -40,7 +53,7 @@ export interface OpenAIListModelResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RequestPayload {
|
||||
export interface RequestPayload {
|
||||
messages: {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | MultimodalContent[];
|
||||
@@ -52,6 +65,17 @@ interface RequestPayload {
|
||||
frequency_penalty: number;
|
||||
top_p: number;
|
||||
max_tokens?: number;
|
||||
max_completion_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 {
|
||||
@@ -62,86 +86,81 @@ export class ChatGPTApi implements LLMApi {
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
const isAzure = path.includes("deployments");
|
||||
if (accessStore.useCustomConfig) {
|
||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||
|
||||
if (isAzure && !accessStore.isValidAzure()) {
|
||||
throw Error(
|
||||
"incomplete azure config, please check it in your settings page",
|
||||
);
|
||||
}
|
||||
|
||||
if (isAzure) {
|
||||
path = makeAzurePath(path, accessStore.azureApiVersion);
|
||||
}
|
||||
|
||||
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp
|
||||
? DEFAULT_API_HOST + "/proxy" + ApiPath.OpenAI
|
||||
: ApiPath.OpenAI;
|
||||
const apiPath = isAzure ? ApiPath.Azure : ApiPath.OpenAI;
|
||||
baseUrl = isApp ? OPENAI_BASE_URL : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.OpenAI)) {
|
||||
if (
|
||||
!baseUrl.startsWith("http") &&
|
||||
!isAzure &&
|
||||
!baseUrl.startsWith(ApiPath.OpenAI)
|
||||
) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
// try rebuild url, when using cloudflare ai gateway in client
|
||||
return cloudflareAIGatewayUrl([baseUrl, path].join("/"));
|
||||
}
|
||||
|
||||
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) => ({
|
||||
role: 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 = {
|
||||
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);
|
||||
async extractMessage(res: any) {
|
||||
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;
|
||||
}
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
async speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
const requestPayload = {
|
||||
model: options.model,
|
||||
input: options.input,
|
||||
voice: options.voice,
|
||||
response_format: options.response_format,
|
||||
speed: options.speed,
|
||||
};
|
||||
|
||||
console.log("[Request] openai speech payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(OpenaiPath.ChatPath);
|
||||
const chatPayload = {
|
||||
const speechPath = this.path(OpenaiPath.SpeechPath);
|
||||
const speechPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
@@ -154,136 +173,201 @@ export class ChatGPTApi implements LLMApi {
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const res = await fetch(speechPath, speechPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
return await res.arrayBuffer();
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a speech request", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(options: ChatOptions) {
|
||||
const modelConfig = {
|
||||
...useAppConfig.getState().modelConfig,
|
||||
...useChatStore.getState().currentSession().mask.modelConfig,
|
||||
...{
|
||||
model: options.config.model,
|
||||
providerName: options.config.providerName,
|
||||
},
|
||||
};
|
||||
|
||||
let requestPayload: RequestPayload | DalleRequestPayload;
|
||||
|
||||
const isDalle3 = _isDalle3(options.config.model);
|
||||
const isO1 = options.config.model.startsWith("o1");
|
||||
if (isDalle3) {
|
||||
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);
|
||||
if (!(isO1 && v.role === "system"))
|
||||
messages.push({ role: v.role, content });
|
||||
}
|
||||
|
||||
// O1 not support image, tools (plugin in ChatGPTNextWeb) and system, stream, logprobs, temperature, top_p, n, presence_penalty, frequency_penalty yet.
|
||||
requestPayload = {
|
||||
messages,
|
||||
stream: !isO1 ? options.config.stream : false,
|
||||
model: modelConfig.model,
|
||||
temperature: !isO1 ? modelConfig.temperature : 1,
|
||||
presence_penalty: !isO1 ? modelConfig.presence_penalty : 0,
|
||||
frequency_penalty: !isO1 ? modelConfig.frequency_penalty : 0,
|
||||
top_p: !isO1 ? modelConfig.top_p : 1,
|
||||
// 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.
|
||||
};
|
||||
|
||||
// O1 使用 max_completion_tokens 控制token数 (https://platform.openai.com/docs/guides/reasoning#controlling-costs)
|
||||
if (isO1) {
|
||||
requestPayload["max_completion_tokens"] = modelConfig.max_tokens;
|
||||
}
|
||||
|
||||
// add max_tokens to vision model
|
||||
if (visionModel) {
|
||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !isDalle3 && !!options.config.stream && !isO1;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
let chatPath = "";
|
||||
if (modelConfig.providerName === ServiceProvider.Azure) {
|
||||
// find model, and get displayName as deployName
|
||||
const { models: configModels, customModels: configCustomModels } =
|
||||
useAppConfig.getState();
|
||||
const {
|
||||
defaultModel,
|
||||
customModels: accessCustomModels,
|
||||
useCustomConfig,
|
||||
} = useAccessStore.getState();
|
||||
const models = collectModelsWithDefaultModel(
|
||||
configModels,
|
||||
[configCustomModels, accessCustomModels].join(","),
|
||||
defaultModel,
|
||||
);
|
||||
const model = models.find(
|
||||
(model) =>
|
||||
model.name === modelConfig.model &&
|
||||
model?.provider?.providerName === ServiceProvider.Azure,
|
||||
);
|
||||
chatPath = this.path(
|
||||
(isDalle3 ? Azure.ImagePath : Azure.ChatPath)(
|
||||
(model?.displayName ?? model?.name) as string,
|
||||
useCustomConfig ? useAccessStore.getState().azureApiVersion : "",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chatPath = this.path(
|
||||
isDalle3 ? OpenaiPath.ImagePath : OpenaiPath.ChatPath,
|
||||
);
|
||||
}
|
||||
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"));
|
||||
let index = -1;
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
// console.log("getAsTools", tools, funcs);
|
||||
stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
index += 1;
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// reset index value
|
||||
index = -1;
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const chatPayload = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestPayload),
|
||||
signal: controller.signal,
|
||||
headers: getHeaders(),
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
// make a fetch request
|
||||
const requestTimeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
isDalle3 || isO1 ? REQUEST_TIMEOUT_MS * 4 : REQUEST_TIMEOUT_MS, // dalle3 using b64_json is slow.
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (
|
||||
textmoderation &&
|
||||
textmoderation.length > 0 &&
|
||||
ServiceProvider.Azure
|
||||
) {
|
||||
const contentFilterResults =
|
||||
textmoderation[0]?.content_filter_results;
|
||||
console.log(
|
||||
`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
|
||||
contentFilterResults,
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
const message = await this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[Request] failed to make a chat request", e);
|
||||
@@ -369,20 +453,26 @@ export class ChatGPTApi implements LLMApi {
|
||||
});
|
||||
|
||||
const resJson = (await res.json()) as OpenAIListModelResponse;
|
||||
const chatModels = resJson.data?.filter((m) => m.id.startsWith("gpt-"));
|
||||
const chatModels = resJson.data?.filter(
|
||||
(m) => m.id.startsWith("gpt-") || m.id.startsWith("chatgpt-"),
|
||||
);
|
||||
console.log("[Models]", chatModels);
|
||||
|
||||
if (!chatModels) {
|
||||
return [];
|
||||
}
|
||||
|
||||
//由于目前 OpenAI 的 disableListModels 默认为 true,所以当前实际不会运行到这场
|
||||
let seq = 1000; //同 Constant.ts 中的排序保持一致
|
||||
return chatModels.map((m) => ({
|
||||
name: m.id,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
sorted: 1,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
274
app/client/platforms/tencent.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
import { ApiPath, TENCENT_BASE_URL, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import { useAccessStore, useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
MultimodalContent,
|
||||
SpeechOptions,
|
||||
} 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";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
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 ? TENCENT_BASE_URL : 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 ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
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;
|
||||
let responseRes: Response;
|
||||
|
||||
// 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, responseRes);
|
||||
}
|
||||
};
|
||||
|
||||
controller.signal.onabort = finish;
|
||||
|
||||
fetchEventSource(chatPath, {
|
||||
fetch: fetch as any,
|
||||
...chatPayload,
|
||||
async onopen(res) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
const contentType = res.headers.get("content-type");
|
||||
console.log(
|
||||
"[Tencent] request response content type: ",
|
||||
contentType,
|
||||
);
|
||||
responseRes = res;
|
||||
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, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
193
app/client/platforms/xai.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
// azure and openai, using same models. so using same LLMApi.
|
||||
import { ApiPath, XAI_BASE_URL, XAI, REQUEST_TIMEOUT_MS } from "@/app/constant";
|
||||
import {
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
ChatMessageTool,
|
||||
usePluginStore,
|
||||
} from "@/app/store";
|
||||
import { stream } from "@/app/utils/chat";
|
||||
import {
|
||||
ChatOptions,
|
||||
getHeaders,
|
||||
LLMApi,
|
||||
LLMModel,
|
||||
SpeechOptions,
|
||||
} from "../api";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { getMessageTextContent } from "@/app/utils";
|
||||
import { RequestPayload } from "./openai";
|
||||
import { fetch } from "@/app/utils/stream";
|
||||
|
||||
export class XAIApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.xaiUrl;
|
||||
}
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
const apiPath = ApiPath.XAI;
|
||||
baseUrl = isApp ? XAI_BASE_URL : apiPath;
|
||||
}
|
||||
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.slice(0, baseUrl.length - 1);
|
||||
}
|
||||
if (!baseUrl.startsWith("http") && !baseUrl.startsWith(ApiPath.XAI)) {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
}
|
||||
|
||||
extractMessage(res: any) {
|
||||
return res.choices?.at(0)?.message?.content ?? "";
|
||||
}
|
||||
|
||||
speech(options: SpeechOptions): Promise<ArrayBuffer> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
console.log("[Request] xai payload: ", requestPayload);
|
||||
|
||||
const shouldStream = !!options.config.stream;
|
||||
const controller = new AbortController();
|
||||
options.onController?.(controller);
|
||||
|
||||
try {
|
||||
const chatPath = this.path(XAI.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) {
|
||||
const [tools, funcs] = usePluginStore
|
||||
.getState()
|
||||
.getAsTools(
|
||||
useChatStore.getState().currentSession().mask?.plugin || [],
|
||||
);
|
||||
return stream(
|
||||
chatPath,
|
||||
requestPayload,
|
||||
getHeaders(),
|
||||
tools as any,
|
||||
funcs,
|
||||
controller,
|
||||
// parseSSE
|
||||
(text: string, runTools: ChatMessageTool[]) => {
|
||||
// console.log("parseSSE", text, runTools);
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: {
|
||||
content: string;
|
||||
tool_calls: ChatMessageTool[];
|
||||
};
|
||||
}>;
|
||||
const tool_calls = choices[0]?.delta?.tool_calls;
|
||||
if (tool_calls?.length > 0) {
|
||||
const index = tool_calls[0]?.index;
|
||||
const id = tool_calls[0]?.id;
|
||||
const args = tool_calls[0]?.function?.arguments;
|
||||
if (id) {
|
||||
runTools.push({
|
||||
id,
|
||||
type: tool_calls[0]?.type,
|
||||
function: {
|
||||
name: tool_calls[0]?.function?.name as string,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
runTools[index]["function"]["arguments"] += args;
|
||||
}
|
||||
}
|
||||
return choices[0]?.delta?.content;
|
||||
},
|
||||
// processToolMessage, include tool_calls message and tool call results
|
||||
(
|
||||
requestPayload: RequestPayload,
|
||||
toolCallMessage: any,
|
||||
toolCallResult: any[],
|
||||
) => {
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.splice(
|
||||
// @ts-ignore
|
||||
requestPayload?.messages?.length,
|
||||
0,
|
||||
toolCallMessage,
|
||||
...toolCallResult,
|
||||
);
|
||||
},
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
const res = await fetch(chatPath, chatPayload);
|
||||
clearTimeout(requestTimeoutId);
|
||||
|
||||
const resJson = await res.json();
|
||||
const message = this.extractMessage(resJson);
|
||||
options.onFinish(message, res);
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,20 @@ interface ChatCommands {
|
||||
next?: Command;
|
||||
prev?: Command;
|
||||
clear?: Command;
|
||||
fork?: Command;
|
||||
del?: Command;
|
||||
}
|
||||
|
||||
export const ChatCommandPrefix = ":";
|
||||
// Compatible with Chinese colon character ":"
|
||||
export const ChatCommandPrefix = /^[::]/;
|
||||
|
||||
export function useChatCommand(commands: ChatCommands = {}) {
|
||||
function extract(userInput: string) {
|
||||
return (
|
||||
userInput.startsWith(ChatCommandPrefix) ? userInput.slice(1) : userInput
|
||||
) as keyof ChatCommands;
|
||||
const match = userInput.match(ChatCommandPrefix);
|
||||
if (match) {
|
||||
return userInput.slice(1) as keyof ChatCommands;
|
||||
}
|
||||
return userInput as keyof ChatCommands;
|
||||
}
|
||||
|
||||
function search(userInput: string) {
|
||||
@@ -57,7 +61,7 @@ export function useChatCommand(commands: ChatCommands = {}) {
|
||||
.filter((c) => c.startsWith(input))
|
||||
.map((c) => ({
|
||||
title: desc[c as keyof ChatCommands],
|
||||
content: ChatCommandPrefix + c,
|
||||
content: ":" + c,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
31
app/components/artifacts.module.scss
Normal 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);
|
||||
}
|
||||
266
app/components/artifacts.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useParams } from "react-router";
|
||||
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 ReloadButtonIcon from "../icons/reload.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";
|
||||
|
||||
type HTMLPreviewProps = {
|
||||
code: string;
|
||||
autoHeight?: boolean;
|
||||
height?: number | string;
|
||||
onLoad?: (title?: string) => void;
|
||||
};
|
||||
|
||||
export type HTMLPreviewHander = {
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>(
|
||||
function HTMLPreview(props, ref) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [frameId, setFrameId] = useState<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) {
|
||||
setIframeHeight(height);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [frameId]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reload: () => {
|
||||
setFrameId(nanoid());
|
||||
},
|
||||
}));
|
||||
|
||||
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>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`;
|
||||
if (props.code.includes("<!DOCTYPE html>")) {
|
||||
props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script);
|
||||
}
|
||||
return script + props.code;
|
||||
}, [props.code, frameId]);
|
||||
|
||||
const handleOnLoad = () => {
|
||||
if (props?.onLoad) {
|
||||
props.onLoad(title);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className={styles["artifacts-iframe"]}
|
||||
key={frameId}
|
||||
ref={iframeRef}
|
||||
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("");
|
||||
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
|
||||
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>
|
||||
<IconButton
|
||||
bordered
|
||||
style={{ marginLeft: 20 }}
|
||||
icon={<ReloadButtonIcon />}
|
||||
shadow
|
||||
onClick={() => previewRef.current?.reload()}
|
||||
/>
|
||||
<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}
|
||||
ref={previewRef}
|
||||
autoHeight={false}
|
||||
height={"100%"}
|
||||
onLoad={(title) => {
|
||||
setFileName(title as string);
|
||||
setLoading(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,70 @@
|
||||
.auth-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
.top-banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 64px;
|
||||
box-sizing: border-box;
|
||||
background: var(--second);
|
||||
.top-banner-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 150%;
|
||||
span {
|
||||
gap: 8px;
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
margin-left: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.top-banner-close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 48px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.top-banner {
|
||||
padding: 12px 24px 12px 12px;
|
||||
.top-banner-close {
|
||||
right: 10px;
|
||||
}
|
||||
.top-banner-inner {
|
||||
.top-banner-logo {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
animation: slide-in-from-top ease 0.3s;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
margin-top: 10vh;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
@@ -14,6 +72,7 @@
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
line-height: 2;
|
||||
margin-bottom: 1vh;
|
||||
}
|
||||
|
||||
.auth-tips {
|
||||
@@ -24,6 +83,10 @@
|
||||
margin: 3vh 0;
|
||||
}
|
||||
|
||||
.auth-input-second {
|
||||
margin: 0 0 3vh 0;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import styles from "./auth.module.scss";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { Path, SAAS_CHAT_URL } from "../constant";
|
||||
import { useAccessStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
|
||||
import Delete from "../icons/close.svg";
|
||||
import Arrow from "../icons/arrow.svg";
|
||||
import Logo from "../icons/logo.svg";
|
||||
import { useMobileScreen } from "@/app/utils";
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
import { useEffect } from "react";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { PasswordInput } from "./ui-lib";
|
||||
import LeftIcon from "@/app/icons/left.svg";
|
||||
import { safeLocalStorage } from "@/app/utils";
|
||||
import {
|
||||
trackSettingsPageGuideToCPaymentClick,
|
||||
trackAuthorizationPageButtonToCPaymentClick,
|
||||
} from "../utils/auth-settings-events";
|
||||
import clsx from "clsx";
|
||||
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
export function AuthPage() {
|
||||
const navigate = useNavigate();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const goHome = () => navigate(Path.Home);
|
||||
const goChat = () => navigate(Path.Chat);
|
||||
const goSaas = () => {
|
||||
trackAuthorizationPageButtonToCPaymentClick();
|
||||
window.location.href = SAAS_CHAT_URL;
|
||||
};
|
||||
|
||||
const resetAccessCode = () => {
|
||||
accessStore.update((access) => {
|
||||
access.openaiApiKey = "";
|
||||
@@ -32,43 +48,58 @@ export function AuthPage() {
|
||||
|
||||
return (
|
||||
<div className={styles["auth-page"]}>
|
||||
<div className={`no-dark ${styles["auth-logo"]}`}>
|
||||
<TopBanner></TopBanner>
|
||||
<div className={styles["auth-header"]}>
|
||||
<IconButton
|
||||
icon={<LeftIcon />}
|
||||
text={Locale.Auth.Return}
|
||||
onClick={() => navigate(Path.Home)}
|
||||
></IconButton>
|
||||
</div>
|
||||
<div className={clsx("no-dark", styles["auth-logo"])}>
|
||||
<BotIcon />
|
||||
</div>
|
||||
|
||||
<div className={styles["auth-title"]}>{Locale.Auth.Title}</div>
|
||||
<div className={styles["auth-tips"]}>{Locale.Auth.Tips}</div>
|
||||
|
||||
<input
|
||||
className={styles["auth-input"]}
|
||||
type="password"
|
||||
placeholder={Locale.Auth.Input}
|
||||
<PasswordInput
|
||||
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||
aria={Locale.Settings.ShowPassword}
|
||||
aria-label={Locale.Auth.Input}
|
||||
value={accessStore.accessCode}
|
||||
type="text"
|
||||
placeholder={Locale.Auth.Input}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.accessCode = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!accessStore.hideUserApiKey ? (
|
||||
<>
|
||||
<div className={styles["auth-tips"]}>{Locale.Auth.SubTips}</div>
|
||||
<input
|
||||
className={styles["auth-input"]}
|
||||
type="password"
|
||||
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||
<PasswordInput
|
||||
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||
aria={Locale.Settings.ShowPassword}
|
||||
aria-label={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||
value={accessStore.openaiApiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Access.OpenAI.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.openaiApiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className={styles["auth-input"]}
|
||||
type="password"
|
||||
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||
<PasswordInput
|
||||
style={{ marginTop: "3vh", marginBottom: "3vh" }}
|
||||
aria={Locale.Settings.ShowPassword}
|
||||
aria-label={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||
value={accessStore.googleApiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Access.Google.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.googleApiKey = e.currentTarget.value),
|
||||
@@ -85,13 +116,74 @@ export function AuthPage() {
|
||||
onClick={goChat}
|
||||
/>
|
||||
<IconButton
|
||||
text={Locale.Auth.Later}
|
||||
text={Locale.Auth.SaasTips}
|
||||
onClick={() => {
|
||||
resetAccessCode();
|
||||
goHome();
|
||||
goSaas();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBanner() {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const isMobile = useMobileScreen();
|
||||
useEffect(() => {
|
||||
// 检查 localStorage 中是否有标记
|
||||
const bannerDismissed = storage.getItem("bannerDismissed");
|
||||
// 如果标记不存在,存储默认值并显示横幅
|
||||
if (!bannerDismissed) {
|
||||
storage.setItem("bannerDismissed", "false");
|
||||
setIsVisible(true); // 显示横幅
|
||||
} else if (bannerDismissed === "true") {
|
||||
// 如果标记为 "true",则隐藏横幅
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovered(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
storage.setItem("bannerDismissed", "true");
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles["top-banner"]}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className={clsx(styles["top-banner-inner"], "no-dark")}>
|
||||
<Logo className={styles["top-banner-logo"]}></Logo>
|
||||
<span>
|
||||
{Locale.Auth.TopTips}
|
||||
<a
|
||||
href={SAAS_CHAT_URL}
|
||||
rel="stylesheet"
|
||||
onClick={() => {
|
||||
trackSettingsPageGuideToCPaymentClick();
|
||||
}}
|
||||
>
|
||||
{Locale.Settings.Access.SaasStart.ChatNow}
|
||||
<Arrow style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{(isHovered || isMobile) && (
|
||||
<Delete className={styles["top-banner-close"]} onClick={handleClose} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
|
||||
import styles from "./button.module.scss";
|
||||
import { CSSProperties } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type ButtonType = "primary" | "danger" | null;
|
||||
|
||||
@@ -16,35 +18,48 @@ export function IconButton(props: {
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
autoFocus?: boolean;
|
||||
style?: CSSProperties;
|
||||
aria?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
styles["icon-button"] +
|
||||
` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
|
||||
props.className ?? ""
|
||||
} clickable ${styles[props.type ?? ""]}`
|
||||
}
|
||||
className={clsx(
|
||||
"clickable",
|
||||
styles["icon-button"],
|
||||
{
|
||||
[styles.border]: props.bordered,
|
||||
[styles.shadow]: props.shadow,
|
||||
},
|
||||
styles[props.type ?? ""],
|
||||
props.className,
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
disabled={props.disabled}
|
||||
role="button"
|
||||
tabIndex={props.tabIndex}
|
||||
autoFocus={props.autoFocus}
|
||||
style={props.style}
|
||||
aria-label={props.aria}
|
||||
>
|
||||
{props.icon && (
|
||||
<div
|
||||
className={
|
||||
styles["icon-button-icon"] +
|
||||
` ${props.type === "primary" && "no-dark"}`
|
||||
}
|
||||
aria-label={props.text || props.title}
|
||||
className={clsx(styles["icon-button-icon"], {
|
||||
"no-dark": props.type === "primary",
|
||||
})}
|
||||
>
|
||||
{props.icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
import {
|
||||
@@ -12,13 +11,14 @@ import {
|
||||
import { useChatStore } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Path } from "../constant";
|
||||
import { MaskAvatar } from "./mask";
|
||||
import { Mask } from "../store/mask";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { useMobileScreen } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function ChatItem(props: {
|
||||
onClick?: () => void;
|
||||
@@ -46,11 +46,11 @@ export function ChatItem(props: {
|
||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={`${styles["chat-item"]} ${
|
||||
props.selected &&
|
||||
(currentPath === Path.Chat || currentPath === Path.Home) &&
|
||||
styles["chat-item-selected"]
|
||||
}`}
|
||||
className={clsx(styles["chat-item"], {
|
||||
[styles["chat-item-selected"]]:
|
||||
props.selected &&
|
||||
(currentPath === Path.Chat || currentPath === Path.Home),
|
||||
})}
|
||||
onClick={props.onClick}
|
||||
ref={(ele) => {
|
||||
draggableRef.current = ele;
|
||||
@@ -64,7 +64,7 @@ export function ChatItem(props: {
|
||||
>
|
||||
{props.narrow ? (
|
||||
<div className={styles["chat-item-narrow"]}>
|
||||
<div className={styles["chat-item-avatar"] + " no-dark"}>
|
||||
<div className={clsx(styles["chat-item-avatar"], "no-dark")}>
|
||||
<MaskAvatar
|
||||
avatar={props.mask.avatar}
|
||||
model={props.mask.modelConfig.model}
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
|
||||
&-end {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chat-input-action {
|
||||
display: inline-flex;
|
||||
@@ -62,10 +70,6 @@
|
||||
width: var(--icon-width);
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
@@ -231,10 +235,12 @@
|
||||
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
$linear: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0));
|
||||
$linear: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
mask-image: $linear;
|
||||
|
||||
@mixin show {
|
||||
@@ -346,6 +352,12 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-model-name {
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-container {
|
||||
@@ -367,7 +379,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container {
|
||||
.chat-message-user > .chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@@ -407,6 +419,21 @@
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chat-message-tools {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
.chat-message-tool {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
svg {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-item {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
@@ -422,6 +449,25 @@
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-message-audio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: var(--border-in-light);
|
||||
position: relative;
|
||||
transition: all ease 0.3s;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
audio {
|
||||
height: 30px; /* 调整高度 */
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
@@ -450,23 +496,27 @@
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
||||
$calc-image-width: calc(100vw / 3 * 2 / var(--image-count));
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $calc-image-width;
|
||||
height: $calc-image-width;
|
||||
}
|
||||
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(100vw/3*2);
|
||||
max-width: calc(100vw / 3 * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
||||
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
||||
$max-image-width: calc(
|
||||
calc(1200px - var(--sidebar-width)) / 3 * 2 / var(--image-count)
|
||||
);
|
||||
$image-width: calc(
|
||||
calc(var(--window-width) - var(--sidebar-width)) / 3 * 2 /
|
||||
var(--image-count)
|
||||
);
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $image-width;
|
||||
@@ -476,7 +526,7 @@
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||
max-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +544,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||
.chat-message-user > .chat-message-container > .chat-message-item {
|
||||
background-color: var(--second);
|
||||
|
||||
&:hover {
|
||||
@@ -605,7 +655,8 @@
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {}
|
||||
.chat-input:focus {
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
@@ -624,4 +675,79 @@
|
||||
.chat-input-send {
|
||||
bottom: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-key-container {
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shortcut-key-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shortcut-key-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.shortcut-key-title {
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.shortcut-key-keys {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
background-color: var(--gray);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.shortcut-key span {
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
.chat-body-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-side-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--white);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
transform: translateX(100%);
|
||||
transition: all ease 0.3s;
|
||||
&-show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ export function Avatar(props: { model?: ModelType; avatar?: string }) {
|
||||
if (props.model) {
|
||||
return (
|
||||
<div className="no-dark">
|
||||
{props.model?.startsWith("gpt-4") ? (
|
||||
{props.model?.startsWith("gpt-4") ||
|
||||
props.model?.startsWith("chatgpt-4o") ? (
|
||||
<BlackBotIcon className="user-avatar" />
|
||||
) : (
|
||||
<BotIcon className="user-avatar" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { IconButton } from "./button";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
@@ -6,6 +8,7 @@ import { ISSUE_URL } from "../constant";
|
||||
import Locale from "../locales";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { useSyncStore } from "../store/sync";
|
||||
import { useChatStore } from "../store/chat";
|
||||
|
||||
interface IErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@@ -28,8 +31,7 @@ export class ErrorBoundary extends React.Component<any, IErrorBoundaryState> {
|
||||
try {
|
||||
useSyncStore.getState().export();
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
useChatStore.getState().clearAllData();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ChatMessage, ModelType, useAppConfig, useChatStore } from "../store";
|
||||
import { ChatMessage, useAppConfig, useChatStore } from "../store";
|
||||
import Locale from "../locales";
|
||||
import styles from "./exporter.module.scss";
|
||||
import {
|
||||
@@ -36,11 +36,11 @@ import { toBlob, toPng } from "html-to-image";
|
||||
import { DEFAULT_MASK_AVATAR } from "../store/mask";
|
||||
|
||||
import { prettyObject } from "../utils/format";
|
||||
import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
|
||||
import { EXPORT_MESSAGE_CLASS_NAME } from "../constant";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { ClientApi } from "../client/api";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||
import clsx from "clsx";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
@@ -119,9 +119,10 @@ function Steps<
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles["step"]} ${
|
||||
styles[i <= props.index ? "step-finished" : ""]
|
||||
} ${i === props.index && styles["step-current"]} clickable`}
|
||||
className={clsx("clickable", styles["step"], {
|
||||
[styles["step-finished"]]: i <= props.index,
|
||||
[styles["step-current"]]: i === props.index,
|
||||
})}
|
||||
onClick={() => {
|
||||
props.onStepChange?.(i);
|
||||
}}
|
||||
@@ -313,14 +314,7 @@ export function PreviewActions(props: {
|
||||
const onRenderMsgs = (msgs: ChatMessage[]) => {
|
||||
setShouldExport(false);
|
||||
|
||||
var api: ClientApi;
|
||||
if (config.modelConfig.model.startsWith("gemini")) {
|
||||
api = new ClientApi(ModelProvider.GeminiPro);
|
||||
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||
api = new ClientApi(ModelProvider.Claude);
|
||||
} else {
|
||||
api = new ClientApi(ModelProvider.GPT);
|
||||
}
|
||||
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
||||
|
||||
api
|
||||
.share(msgs)
|
||||
@@ -533,11 +527,11 @@ export function ImagePreviewer(props: {
|
||||
messages={props.messages}
|
||||
/>
|
||||
<div
|
||||
className={`${styles["preview-body"]} ${styles["default-theme"]}`}
|
||||
className={clsx(styles["preview-body"], styles["default-theme"])}
|
||||
ref={previewRef}
|
||||
>
|
||||
<div className={styles["chat-info"]}>
|
||||
<div className={styles["logo"] + " no-dark"}>
|
||||
<div className={clsx(styles["logo"], "no-dark")}>
|
||||
<NextImage
|
||||
src={ChatGptIcon.src}
|
||||
alt="logo"
|
||||
@@ -549,7 +543,7 @@ export function ImagePreviewer(props: {
|
||||
<div>
|
||||
<div className={styles["main-title"]}>NextChat</div>
|
||||
<div className={styles["sub-title"]}>
|
||||
github.com/Yidadaa/ChatGPT-Next-Web
|
||||
github.com/ChatGPTNextWeb/ChatGPT-Next-Web
|
||||
</div>
|
||||
<div className={styles["icons"]}>
|
||||
<ExportAvatar avatar={config.avatar} />
|
||||
@@ -578,7 +572,7 @@ export function ImagePreviewer(props: {
|
||||
{props.messages.map((m, i) => {
|
||||
return (
|
||||
<div
|
||||
className={styles["message"] + " " + styles["message-" + m.role]}
|
||||
className={clsx(styles["message"], styles["message-" + m.role])}
|
||||
key={i}
|
||||
>
|
||||
<div className={styles["avatar"]}>
|
||||
@@ -591,6 +585,7 @@ export function ImagePreviewer(props: {
|
||||
<Markdown
|
||||
content={getMessageTextContent(m)}
|
||||
fontSize={config.fontSize}
|
||||
fontFamily={config.fontFamily}
|
||||
defaultShow
|
||||
/>
|
||||
{getMessageImages(m).length == 1 && (
|
||||
|
||||
@@ -137,12 +137,21 @@
|
||||
position: relative;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
&-narrow {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 18px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar-title-container {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
require("../polyfill");
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
@@ -12,7 +11,7 @@ import LoadingIcon from "../icons/three-dots.svg";
|
||||
import { getCSSVar, useMobileScreen } from "../utils";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { ModelProvider, Path, SlotID } from "../constant";
|
||||
import { Path, SlotID } from "../constant";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import { getISOLang, getLang } from "../locales";
|
||||
@@ -27,19 +26,23 @@ import { SideBar } from "./sidebar";
|
||||
import { useAppConfig } from "../store/config";
|
||||
import { AuthPage } from "./auth";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { ClientApi } from "../client/api";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { useAccessStore } from "../store";
|
||||
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
<div className={styles["loading-content"] + " no-dark"}>
|
||||
<div className={clsx("no-dark", styles["loading-content"])}>
|
||||
{!props.noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
@@ -56,6 +59,21 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const SearchChat = dynamic(
|
||||
async () => (await import("./search-chat")).SearchChatPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const Sd = dynamic(async () => (await import("./sd")).Sd, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
@@ -123,11 +141,23 @@ const loadAsyncGoogleFont = () => {
|
||||
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() {
|
||||
const config = useAppConfig();
|
||||
const location = useLocation();
|
||||
const isArtifact = location.pathname.includes(Path.Artifacts);
|
||||
const isHome = location.pathname === Path.Home;
|
||||
const isAuth = location.pathname === Path.Auth;
|
||||
const isSd = location.pathname === Path.Sd;
|
||||
const isSdNew = location.pathname === Path.SdNew;
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const shouldTightBorder =
|
||||
getClientConfig()?.isApp || (config.tightBorder && !isMobileScreen);
|
||||
@@ -136,34 +166,47 @@ function Screen() {
|
||||
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={clsx({
|
||||
[styles["sidebar-show"]]: isHome,
|
||||
})}
|
||||
/>
|
||||
<WindowContent>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route path={Path.NewChat} element={<NewChat />} />
|
||||
<Route path={Path.Masks} element={<MaskPage />} />
|
||||
<Route path={Path.Plugins} element={<PluginPage />} />
|
||||
<Route path={Path.SearchChat} element={<SearchChat />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
</WindowContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles.container +
|
||||
` ${shouldTightBorder ? styles["tight-container"] : styles.container} ${
|
||||
getLang() === "ar" ? styles["rtl-screen"] : ""
|
||||
}`
|
||||
}
|
||||
className={clsx(styles.container, {
|
||||
[styles["tight-container"]]: shouldTightBorder,
|
||||
[styles["rtl-screen"]]: getLang() === "ar",
|
||||
})}
|
||||
>
|
||||
{isAuth ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,14 +214,8 @@ function Screen() {
|
||||
export function useLoadData() {
|
||||
const config = useAppConfig();
|
||||
|
||||
var api: ClientApi;
|
||||
if (config.modelConfig.model.startsWith("gemini")) {
|
||||
api = new ClientApi(ModelProvider.GeminiPro);
|
||||
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||
api = new ClientApi(ModelProvider.Claude);
|
||||
} else {
|
||||
api = new ClientApi(ModelProvider.GPT);
|
||||
}
|
||||
const api: ClientApi = getClientApi(config.modelConfig.providerName);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const models = await api.llm.models();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styles from "./input-range.module.scss";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface InputRangeProps {
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
@@ -9,6 +10,7 @@ interface InputRangeProps {
|
||||
min: string;
|
||||
max: string;
|
||||
step: string;
|
||||
aria: string;
|
||||
}
|
||||
|
||||
export function InputRange({
|
||||
@@ -19,11 +21,13 @@ export function InputRange({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
aria,
|
||||
}: InputRangeProps) {
|
||||
return (
|
||||
<div className={styles["input-range"] + ` ${className ?? ""}`}>
|
||||
<div className={clsx(styles["input-range"], className)}>
|
||||
{title || value}
|
||||
<input
|
||||
aria-label={aria}
|
||||
type="range"
|
||||
title={title}
|
||||
value={value}
|
||||
|
||||
@@ -6,13 +6,24 @@ import RehypeKatex from "rehype-katex";
|
||||
import RemarkGfm from "remark-gfm";
|
||||
import RehypeHighlight from "rehype-highlight";
|
||||
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
|
||||
import { copyToClipboard } from "../utils";
|
||||
import { copyToClipboard, useWindowSize } from "../utils";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
import Locale from "../locales";
|
||||
import LoadingIcon from "../icons/three-dots.svg";
|
||||
import ReloadButtonIcon from "../icons/reload.svg";
|
||||
import React from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { showImageModal } from "./ui-lib";
|
||||
import { showImageModal, FullScreen } from "./ui-lib";
|
||||
import {
|
||||
ArtifactsShareButton,
|
||||
HTMLPreview,
|
||||
HTMLPreviewHander,
|
||||
} from "./artifacts";
|
||||
import { useChatStore } from "../store";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
import { useAppConfig } from "../store/config";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Mermaid(props: { code: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -47,7 +58,7 @@ export function Mermaid(props: { code: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="no-dark mermaid"
|
||||
className={clsx("no-dark", "mermaid")}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
overflow: "auto",
|
||||
@@ -62,58 +73,159 @@ export function Mermaid(props: { code: string }) {
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const refText = ref.current?.innerText;
|
||||
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
const [mermaidCode, setMermaidCode] = useState("");
|
||||
const [htmlCode, setHtmlCode] = useState("");
|
||||
const { height } = useWindowSize();
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
const renderMermaid = useDebouncedCallback(() => {
|
||||
const renderArtifacts = useDebouncedCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const mermaidDom = ref.current.querySelector("code.language-mermaid");
|
||||
if (mermaidDom) {
|
||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||
}
|
||||
const htmlDom = ref.current.querySelector("code.language-html");
|
||||
const refText = ref.current.querySelector("code")?.innerText;
|
||||
if (htmlDom) {
|
||||
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
} else if (
|
||||
refText?.startsWith("<!DOCTYPE") ||
|
||||
refText?.startsWith("<svg") ||
|
||||
refText?.startsWith("<?xml")
|
||||
) {
|
||||
setHtmlCode(refText);
|
||||
}
|
||||
}, 600);
|
||||
|
||||
const config = useAppConfig();
|
||||
const enableArtifacts =
|
||||
session.mask?.enableArtifacts !== false && config.enableArtifacts;
|
||||
|
||||
//Wrap the paragraph for plain-text
|
||||
useEffect(() => {
|
||||
setTimeout(renderMermaid, 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refText]);
|
||||
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";
|
||||
}
|
||||
});
|
||||
setTimeout(renderArtifacts, 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mermaidCode.length > 0 && (
|
||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
||||
)}
|
||||
<pre ref={ref}>
|
||||
<span
|
||||
className="copy-code-button"
|
||||
onClick={() => {
|
||||
if (ref.current) {
|
||||
const code = ref.current.innerText;
|
||||
copyToClipboard(code);
|
||||
copyToClipboard(
|
||||
ref.current.querySelector("code")?.innerText ?? "",
|
||||
);
|
||||
}
|
||||
}}
|
||||
></span>
|
||||
{props.children}
|
||||
</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}
|
||||
/>
|
||||
<IconButton
|
||||
style={{ position: "absolute", right: 120, top: 10 }}
|
||||
bordered
|
||||
icon={<ReloadButtonIcon />}
|
||||
shadow
|
||||
onClick={() => previewRef.current?.reload()}
|
||||
/>
|
||||
<HTMLPreview
|
||||
ref={previewRef}
|
||||
code={htmlCode}
|
||||
autoHeight={!document.fullscreenElement}
|
||||
height={!document.fullscreenElement ? 600 : height}
|
||||
/>
|
||||
</FullScreen>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeDollarNumber(text: string) {
|
||||
let escapedText = "";
|
||||
function CustomCode(props: { children: any; className?: string }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
const enableCodeFold =
|
||||
session.mask?.enableCodeFold !== false && config.enableCodeFold;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
let char = text[i];
|
||||
const nextChar = text[i + 1] || " ";
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [showToggle, setShowToggle] = useState(false);
|
||||
|
||||
if (char === "$" && nextChar >= "0" && nextChar <= "9") {
|
||||
char = "\\$";
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const codeHeight = ref.current.scrollHeight;
|
||||
setShowToggle(codeHeight > 400);
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}
|
||||
}, [props.children]);
|
||||
|
||||
escapedText += char;
|
||||
}
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((collapsed) => !collapsed);
|
||||
};
|
||||
const renderShowMoreButton = () => {
|
||||
if (showToggle && enableCodeFold && collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("show-hide-button", {
|
||||
collapsed,
|
||||
expanded: !collapsed,
|
||||
})}
|
||||
>
|
||||
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<code
|
||||
className={clsx(props?.className)}
|
||||
ref={ref}
|
||||
style={{
|
||||
maxHeight: enableCodeFold && collapsed ? "400px" : "none",
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</code>
|
||||
|
||||
return escapedText;
|
||||
{renderShowMoreButton()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeBrackets(text: string) {
|
||||
@@ -134,9 +246,30 @@ function escapeBrackets(text: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function tryWrapHtmlCode(text: string) {
|
||||
// try add wrap html code (fixed: html codeblock include 2 newline)
|
||||
// ignore embed codeblock
|
||||
if (text.includes("```")) {
|
||||
return text;
|
||||
}
|
||||
return text
|
||||
.replace(
|
||||
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
|
||||
(match, quoteStart, lang, newLine, doctype) => {
|
||||
return !quoteStart ? "\n```html\n" + doctype : match;
|
||||
},
|
||||
)
|
||||
.replace(
|
||||
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
|
||||
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
|
||||
return !quoteEnd ? bodyEnd + space + htmlEnd + "\n```\n" : match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function _MarkDownContent(props: { content: string }) {
|
||||
const escapedContent = useMemo(() => {
|
||||
return escapeBrackets(escapeDollarNumber(props.content));
|
||||
return tryWrapHtmlCode(escapeBrackets(props.content));
|
||||
}, [props.content]);
|
||||
|
||||
return (
|
||||
@@ -154,9 +287,24 @@ function _MarkDownContent(props: { content: string }) {
|
||||
]}
|
||||
components={{
|
||||
pre: PreCode,
|
||||
code: CustomCode,
|
||||
p: (pProps) => <p {...pProps} dir="auto" />,
|
||||
a: (aProps) => {
|
||||
const href = aProps.href || "";
|
||||
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
||||
return (
|
||||
<figure>
|
||||
<audio controls src={href}></audio>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
||||
return (
|
||||
<video controls width="99.9%">
|
||||
<source src={href} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
const isInternal = /^\/#/i.test(href);
|
||||
const target = isInternal ? "_self" : aProps.target ?? "_blank";
|
||||
return <a {...aProps} target={target} />;
|
||||
@@ -175,6 +323,7 @@ export function Markdown(
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
parentRef?: RefObject<HTMLDivElement>;
|
||||
defaultShow?: boolean;
|
||||
} & React.DOMAttributes<HTMLDivElement>,
|
||||
@@ -186,6 +335,7 @@ export function Markdown(
|
||||
className="markdown-body"
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
fontFamily: props.fontFamily || "inherit",
|
||||
}}
|
||||
ref={mdRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
|
||||
@@ -37,7 +37,7 @@ import Locale, { AllLangs, ALL_LANG_OPTIONS, Lang } from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import chatStyle from "./chat.module.scss";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadAs,
|
||||
@@ -48,7 +48,6 @@ import { Updater } from "../typing";
|
||||
import { ModelConfigList } from "./model-config";
|
||||
import { FileName, Path } from "../constant";
|
||||
import { BUILTIN_MASK_STORE } from "../masks";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
@@ -56,6 +55,7 @@ import {
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
// drag and drop helper function
|
||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||
@@ -127,6 +127,8 @@ export function MaskConfig(props: {
|
||||
onClose={() => setShowPicker(false)}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
aria-label={Locale.Mask.Config.Avatar}
|
||||
onClick={() => setShowPicker(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
@@ -139,6 +141,7 @@ export function MaskConfig(props: {
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Mask.Config.Name}>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Name}
|
||||
type="text"
|
||||
value={props.mask.name}
|
||||
onInput={(e) =>
|
||||
@@ -153,6 +156,7 @@ export function MaskConfig(props: {
|
||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.HideContext.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.hideContext}
|
||||
onChange={(e) => {
|
||||
@@ -163,12 +167,48 @@ export function MaskConfig(props: {
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
{globalConfig.enableArtifacts && (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Artifacts.Title}
|
||||
subTitle={Locale.Mask.Config.Artifacts.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Artifacts.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.enableArtifacts !== false}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.enableArtifacts = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
{globalConfig.enableCodeFold && (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.CodeFold.Title}
|
||||
subTitle={Locale.Mask.Config.CodeFold.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.CodeFold.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.enableCodeFold !== false}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.enableCodeFold = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{!props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Share.Title}
|
||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||
>
|
||||
<IconButton
|
||||
aria={Locale.Mask.Config.Share.Title}
|
||||
icon={<CopyIcon />}
|
||||
text={Locale.Mask.Config.Share.Action}
|
||||
onClick={copyMaskLink}
|
||||
@@ -182,6 +222,7 @@ export function MaskConfig(props: {
|
||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Sync.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.syncGlobalConfig}
|
||||
onChange={async (e) => {
|
||||
@@ -404,16 +445,7 @@ export function MaskPage() {
|
||||
const maskStore = useMaskStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
||||
() => localStorage.getItem("Mask-language") as Lang | undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (filterLang) {
|
||||
localStorage.setItem("Mask-language", filterLang);
|
||||
} else {
|
||||
localStorage.removeItem("Mask-language");
|
||||
}
|
||||
}, [filterLang]);
|
||||
const filterLang = maskStore.language;
|
||||
|
||||
const allMasks = maskStore
|
||||
.getAll()
|
||||
@@ -520,9 +552,9 @@ export function MaskPage() {
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === Locale.Settings.Lang.All) {
|
||||
setFilterLang(undefined);
|
||||
maskStore.setLanguage(undefined);
|
||||
} else {
|
||||
setFilterLang(value as Lang);
|
||||
maskStore.setLanguage(value as Lang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -557,7 +589,7 @@ export function MaskPage() {
|
||||
</div>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>{m.name}</div>
|
||||
<div className={styles["mask-info"] + " one-line"}>
|
||||
<div className={clsx(styles["mask-info"], "one-line")}>
|
||||
{`${Locale.Mask.Item.Info(m.context.length)} / ${
|
||||
ALL_LANG_OPTIONS[m.lang]
|
||||
} / ${m.modelConfig.model}`}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Locale from "../locales";
|
||||
|
||||
import styles from "./message-selector.module.scss";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import clsx from "clsx";
|
||||
|
||||
function useShiftRange() {
|
||||
const [startIndex, setStartIndex] = useState<number>();
|
||||
@@ -71,6 +72,7 @@ export function MessageSelector(props: {
|
||||
defaultSelectAll?: boolean;
|
||||
onSelected?: (messages: ChatMessage[]) => void;
|
||||
}) {
|
||||
const LATEST_COUNT = 4;
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const isValid = (m: ChatMessage) => m.content && !m.isError && !m.streaming;
|
||||
@@ -141,15 +143,13 @@ export function MessageSelector(props: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [startIndex, endIndex]);
|
||||
|
||||
const LATEST_COUNT = 4;
|
||||
|
||||
return (
|
||||
<div className={styles["message-selector"]}>
|
||||
<div className={styles["message-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={Locale.Select.Search}
|
||||
className={styles["filter-item"] + " " + styles["search-bar"]}
|
||||
className={clsx(styles["filter-item"], styles["search-bar"])}
|
||||
value={searchInput}
|
||||
onInput={(e) => {
|
||||
setSearchInput(e.currentTarget.value);
|
||||
@@ -196,9 +196,9 @@ export function MessageSelector(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles["message"]} ${
|
||||
props.selection.has(m.id!) && styles["message-selected"]
|
||||
}`}
|
||||
className={clsx(styles["message"], {
|
||||
[styles["message-selected"]]: props.selection.has(m.id!),
|
||||
})}
|
||||
key={i}
|
||||
onClick={() => {
|
||||
props.updateSelection((selection) => {
|
||||
@@ -221,7 +221,7 @@ export function MessageSelector(props: {
|
||||
<div className={styles["date"]}>
|
||||
{new Date(m.date).toLocaleString()}
|
||||
</div>
|
||||
<div className={`${styles["content"]} one-line`}>
|
||||
<div className={clsx(styles["content"], "one-line")}>
|
||||
{getMessageTextContent(m)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
7
app/components/model-config.module.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.select-compress-model {
|
||||
width: 60%;
|
||||
select {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,52 @@
|
||||
import { ServiceProvider } from "@/app/constant";
|
||||
import { ModalConfigValidator, ModelConfig } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { InputRange } from "./input-range";
|
||||
import { ListItem, Select } from "./ui-lib";
|
||||
import { useAllModels } from "../utils/hooks";
|
||||
import { groupBy } from "lodash-es";
|
||||
import styles from "./model-config.module.scss";
|
||||
import { getModelProvider } from "../utils/model";
|
||||
|
||||
export function ModelConfigList(props: {
|
||||
modelConfig: ModelConfig;
|
||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||
}) {
|
||||
const allModels = useAllModels();
|
||||
const groupModels = groupBy(
|
||||
allModels.filter((v) => v.available),
|
||||
"provider.providerName",
|
||||
);
|
||||
const value = `${props.modelConfig.model}@${props.modelConfig?.providerName}`;
|
||||
const compressModelValue = `${props.modelConfig.compressModel}@${props.modelConfig?.compressProviderName}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Model}>
|
||||
<Select
|
||||
value={props.modelConfig.model}
|
||||
aria-label={Locale.Settings.Model}
|
||||
value={value}
|
||||
align="left"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.model = ModalConfigValidator.model(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
const [model, providerName] = getModelProvider(
|
||||
e.currentTarget.value,
|
||||
);
|
||||
props.updateConfig((config) => {
|
||||
config.model = ModalConfigValidator.model(model);
|
||||
config.providerName = providerName as ServiceProvider;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{allModels
|
||||
.filter((v) => v.available)
|
||||
.map((v, i) => (
|
||||
<option value={v.name} key={i}>
|
||||
{v.displayName}({v.provider?.providerName})
|
||||
</option>
|
||||
))}
|
||||
{Object.keys(groupModels).map((providerName, index) => (
|
||||
<optgroup label={providerName} key={index}>
|
||||
{groupModels[providerName].map((v, i) => (
|
||||
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||
{v.displayName}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
@@ -39,6 +54,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.Temperature.Title}
|
||||
value={props.modelConfig.temperature?.toFixed(1)}
|
||||
min="0"
|
||||
max="1" // lets limit it to 0-1
|
||||
@@ -58,6 +74,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.TopP.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.TopP.Title}
|
||||
value={(props.modelConfig.top_p ?? 1).toFixed(1)}
|
||||
min="0"
|
||||
max="1"
|
||||
@@ -77,6 +94,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.MaxTokens.Title}
|
||||
type="number"
|
||||
min={1024}
|
||||
max={512000}
|
||||
@@ -92,13 +110,14 @@ export function ModelConfigList(props: {
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
{props.modelConfig.model.startsWith("gemini") ? null : (
|
||||
{props.modelConfig?.providerName == ServiceProvider.Google ? null : (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.PresencePenalty.Title}
|
||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.PresencePenalty.Title}
|
||||
value={props.modelConfig.presence_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
@@ -120,6 +139,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.FrequencyPenalty.Title}
|
||||
value={props.modelConfig.frequency_penalty?.toFixed(1)}
|
||||
min="-2"
|
||||
max="2"
|
||||
@@ -141,6 +161,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.InjectSystemPrompts.Title}
|
||||
type="checkbox"
|
||||
checked={props.modelConfig.enableInjectSystemPrompts}
|
||||
onChange={(e) =>
|
||||
@@ -158,6 +179,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.InputTemplate.Title}
|
||||
type="text"
|
||||
value={props.modelConfig.template}
|
||||
onChange={(e) =>
|
||||
@@ -174,6 +196,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.HistoryCount.Title}
|
||||
title={props.modelConfig.historyMessageCount.toString()}
|
||||
value={props.modelConfig.historyMessageCount}
|
||||
min="0"
|
||||
@@ -192,6 +215,7 @@ export function ModelConfigList(props: {
|
||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Settings.CompressThreshold.Title}
|
||||
type="number"
|
||||
min={500}
|
||||
max={4000}
|
||||
@@ -207,6 +231,7 @@ export function ModelConfigList(props: {
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||
<input
|
||||
aria-label={Locale.Memory.Title}
|
||||
type="checkbox"
|
||||
checked={props.modelConfig.sendMemory}
|
||||
onChange={(e) =>
|
||||
@@ -216,6 +241,33 @@ export function ModelConfigList(props: {
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.CompressModel.Title}
|
||||
subTitle={Locale.Settings.CompressModel.SubTitle}
|
||||
>
|
||||
<Select
|
||||
className={styles["select-compress-model"]}
|
||||
aria-label={Locale.Settings.CompressModel.Title}
|
||||
value={compressModelValue}
|
||||
onChange={(e) => {
|
||||
const [model, providerName] = getModelProvider(
|
||||
e.currentTarget.value,
|
||||
);
|
||||
props.updateConfig((config) => {
|
||||
config.compressModel = ModalConfigValidator.model(model);
|
||||
config.compressProviderName = providerName as ServiceProvider;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{allModels
|
||||
.filter((v) => v.available)
|
||||
.map((v, i) => (
|
||||
<option value={`${v.name}@${v.provider?.providerName}`} key={i}>
|
||||
{v.displayName}({v.provider?.providerName})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
|
||||
import { useCommand } from "../command";
|
||||
import { showConfirm } from "./ui-lib";
|
||||
import { BUILTIN_MASK_STORE } from "../masks";
|
||||
import clsx from "clsx";
|
||||
|
||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||
return (
|
||||
@@ -24,7 +25,9 @@ function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||
avatar={props.mask.avatar}
|
||||
model={props.mask.modelConfig.model}
|
||||
/>
|
||||
<div className={styles["mask-name"] + " one-line"}>{props.mask.name}</div>
|
||||
<div className={clsx(styles["mask-name"], "one-line")}>
|
||||
{props.mask.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
app/components/plugin.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
.plugin-title {
|
||||
font-weight: bolder;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.plugin-content {
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
pre code {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
min-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-schema {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: row;
|
||||
|
||||
input {
|
||||
margin-right: 20px;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
button {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
370
app/components/plugin.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import OpenAPIClientAxios from "openapi-client-axios";
|
||||
import yaml from "js-yaml";
|
||||
import { PLUGINS_REPO_URL } from "../constant";
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import styles from "./mask.module.scss";
|
||||
import pluginStyles from "./plugin.module.scss";
|
||||
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import ConfirmIcon from "../icons/confirm.svg";
|
||||
import ReloadIcon from "../icons/reload.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
|
||||
import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
|
||||
import {
|
||||
PasswordInput,
|
||||
List,
|
||||
ListItem,
|
||||
Modal,
|
||||
showConfirm,
|
||||
showToast,
|
||||
} from "./ui-lib";
|
||||
import Locale from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function PluginPage() {
|
||||
const navigate = useNavigate();
|
||||
const pluginStore = usePluginStore();
|
||||
|
||||
const allPlugins = pluginStore.getAll();
|
||||
const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
|
||||
|
||||
// refactored already, now it accurate
|
||||
const onSearch = (text: string) => {
|
||||
setSearchText(text);
|
||||
if (text.length > 0) {
|
||||
const result = allPlugins.filter(
|
||||
(m) => m?.title.toLowerCase().includes(text.toLowerCase()),
|
||||
);
|
||||
setSearchPlugins(result);
|
||||
} else {
|
||||
setSearchPlugins(allPlugins);
|
||||
}
|
||||
};
|
||||
|
||||
const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
|
||||
const editingPlugin = pluginStore.get(editingPluginId);
|
||||
const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
|
||||
const closePluginModal = () => setEditingPluginId(undefined);
|
||||
|
||||
const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
|
||||
const content = e.target.innerText;
|
||||
try {
|
||||
const api = new OpenAPIClientAxios({
|
||||
definition: yaml.load(content) as any,
|
||||
});
|
||||
api
|
||||
.init()
|
||||
.then(() => {
|
||||
if (content != editingPlugin.content) {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.content = content;
|
||||
const tool = FunctionToolService.add(plugin, true);
|
||||
plugin.title = tool.api.definition.info.title;
|
||||
plugin.version = tool.api.definition.info.version;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
}
|
||||
}, 100).bind(null, editingPlugin);
|
||||
|
||||
const [loadUrl, setLoadUrl] = useState<string>("");
|
||||
const loadFromUrl = (loadUrl: string) =>
|
||||
fetch(loadUrl)
|
||||
.catch((e) => {
|
||||
const p = new URL(loadUrl);
|
||||
return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
|
||||
headers: {
|
||||
"X-Base-URL": p.origin,
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then((content) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, " ");
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
})
|
||||
.then((content) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.content = content;
|
||||
const tool = FunctionToolService.add(plugin, true);
|
||||
plugin.title = tool.api.definition.info.title;
|
||||
plugin.version = tool.api.definition.info.version;
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
});
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.Plugin.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.Plugin.Page.SubTitle(plugins.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<a
|
||||
href={PLUGINS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton icon={<GithubIcon />} bordered />
|
||||
</a>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.Plugin.Page.Search}
|
||||
autoFocus
|
||||
onInput={(e) => onSearch(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles["mask-create"]}
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Plugin.Page.Create}
|
||||
bordered
|
||||
onClick={() => {
|
||||
const createdPlugin = pluginStore.create();
|
||||
setEditingPluginId(createdPlugin.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{plugins.length == 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: "60px auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Locale.Plugin.Page.Find}
|
||||
<a
|
||||
href={PLUGINS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: 16 }}
|
||||
>
|
||||
<IconButton icon={<GithubIcon />} bordered />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{plugins.map((m) => (
|
||||
<div className={styles["mask-item"]} key={m.id}>
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-icon"]}></div>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>
|
||||
{m.title}@<small>{m.version}</small>
|
||||
</div>
|
||||
<div className={clsx(styles["mask-info"], "one-line")}>
|
||||
{Locale.Plugin.Item.Info(
|
||||
FunctionToolService.add(m).length,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["mask-actions"]}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text={Locale.Plugin.Item.Edit}
|
||||
onClick={() => setEditingPluginId(m.id)}
|
||||
/>
|
||||
{!m.builtin && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
text={Locale.Plugin.Item.Delete}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await showConfirm(Locale.Plugin.Item.DeleteConfirm)
|
||||
) {
|
||||
pluginStore.delete(m.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingPlugin && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
|
||||
onClose={closePluginModal}
|
||||
actions={[
|
||||
<IconButton
|
||||
icon={<ConfirmIcon />}
|
||||
text={Locale.UI.Confirm}
|
||||
key="export"
|
||||
bordered
|
||||
onClick={() => setEditingPluginId("")}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List>
|
||||
<ListItem title={Locale.Plugin.EditModal.Auth}>
|
||||
<select
|
||||
value={editingPlugin?.authType}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authType = e.target.value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{Locale.Plugin.Auth.None}</option>
|
||||
<option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
|
||||
<option value="basic">{Locale.Plugin.Auth.Basic}</option>
|
||||
<option value="custom">{Locale.Plugin.Auth.Custom}</option>
|
||||
</select>
|
||||
</ListItem>
|
||||
{["bearer", "basic", "custom"].includes(
|
||||
editingPlugin.authType as string,
|
||||
) && (
|
||||
<ListItem title={Locale.Plugin.Auth.Location}>
|
||||
<select
|
||||
value={editingPlugin?.authLocation}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authLocation = e.target.value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="header">
|
||||
{Locale.Plugin.Auth.LocationHeader}
|
||||
</option>
|
||||
<option value="query">
|
||||
{Locale.Plugin.Auth.LocationQuery}
|
||||
</option>
|
||||
<option value="body">
|
||||
{Locale.Plugin.Auth.LocationBody}
|
||||
</option>
|
||||
</select>
|
||||
</ListItem>
|
||||
)}
|
||||
{editingPlugin.authType == "custom" && (
|
||||
<ListItem title={Locale.Plugin.Auth.CustomHeader}>
|
||||
<input
|
||||
type="text"
|
||||
value={editingPlugin?.authHeader}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authHeader = e.target.value;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
{["bearer", "basic", "custom"].includes(
|
||||
editingPlugin.authType as string,
|
||||
) && (
|
||||
<ListItem title={Locale.Plugin.Auth.Token}>
|
||||
<PasswordInput
|
||||
type="text"
|
||||
value={editingPlugin?.authToken}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authToken = e.currentTarget.value;
|
||||
});
|
||||
}}
|
||||
></PasswordInput>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
<List>
|
||||
<ListItem title={Locale.Plugin.EditModal.Content}>
|
||||
<div className={pluginStyles["plugin-schema"]}>
|
||||
<input
|
||||
type="text"
|
||||
style={{ minWidth: 200 }}
|
||||
onInput={(e) => setLoadUrl(e.currentTarget.value)}
|
||||
></input>
|
||||
<IconButton
|
||||
icon={<ReloadIcon />}
|
||||
text={Locale.Plugin.EditModal.Load}
|
||||
bordered
|
||||
onClick={() => loadFromUrl(loadUrl)}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
subTitle={
|
||||
<div
|
||||
className={clsx(
|
||||
"markdown-body",
|
||||
pluginStyles["plugin-content"],
|
||||
)}
|
||||
dir="auto"
|
||||
>
|
||||
<pre>
|
||||
<code
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: editingPlugin.content,
|
||||
}}
|
||||
onBlur={onChangePlugin}
|
||||
></code>
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
></ListItem>
|
||||
{editingPluginTool?.tools.map((tool, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
title={tool?.function?.name}
|
||||
subTitle={tool?.function?.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
1
app/components/realtime-chat/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./realtime-chat";
|
||||
74
app/components/realtime-chat/realtime-chat.module.scss
Normal file
@@ -0,0 +1,74 @@
|
||||
.realtime-chat {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
.circle-mic {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(to bottom right, #a0d8ef, #f0f8ff);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.icon-center {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.bottom-icons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.icon-left,
|
||||
.icon-right {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
font-size: 36px;
|
||||
background: var(--second);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&.mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
359
app/components/realtime-chat/realtime-chat.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import VoiceIcon from "@/app/icons/voice.svg";
|
||||
import VoiceOffIcon from "@/app/icons/voice-off.svg";
|
||||
import PowerIcon from "@/app/icons/power.svg";
|
||||
|
||||
import styles from "./realtime-chat.module.scss";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
import { useChatStore, createMessage, useAppConfig } from "@/app/store";
|
||||
|
||||
import { IconButton } from "@/app/components/button";
|
||||
|
||||
import {
|
||||
Modality,
|
||||
RTClient,
|
||||
RTInputAudioItem,
|
||||
RTResponse,
|
||||
TurnDetection,
|
||||
} from "rt-client";
|
||||
import { AudioHandler } from "@/app/lib/audio";
|
||||
import { uploadImage } from "@/app/utils/chat";
|
||||
import { VoicePrint } from "@/app/components/voice-print";
|
||||
|
||||
interface RealtimeChatProps {
|
||||
onClose?: () => void;
|
||||
onStartVoice?: () => void;
|
||||
onPausedVoice?: () => void;
|
||||
}
|
||||
|
||||
export function RealtimeChat({
|
||||
onClose,
|
||||
onStartVoice,
|
||||
onPausedVoice,
|
||||
}: RealtimeChatProps) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
const [status, setStatus] = useState("");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [modality, setModality] = useState("audio");
|
||||
const [useVAD, setUseVAD] = useState(true);
|
||||
const [frequencies, setFrequencies] = useState<Uint8Array | undefined>();
|
||||
|
||||
const clientRef = useRef<RTClient | null>(null);
|
||||
const audioHandlerRef = useRef<AudioHandler | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
const temperature = config.realtimeConfig.temperature;
|
||||
const apiKey = config.realtimeConfig.apiKey;
|
||||
const model = config.realtimeConfig.model;
|
||||
const azure = config.realtimeConfig.provider === "Azure";
|
||||
const azureEndpoint = config.realtimeConfig.azure.endpoint;
|
||||
const azureDeployment = config.realtimeConfig.azure.deployment;
|
||||
const voice = config.realtimeConfig.voice;
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (isConnecting) return;
|
||||
if (!isConnected) {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
clientRef.current = azure
|
||||
? new RTClient(
|
||||
new URL(azureEndpoint),
|
||||
{ key: apiKey },
|
||||
{ deployment: azureDeployment },
|
||||
)
|
||||
: new RTClient({ key: apiKey }, { model });
|
||||
const modalities: Modality[] =
|
||||
modality === "audio" ? ["text", "audio"] : ["text"];
|
||||
const turnDetection: TurnDetection = useVAD
|
||||
? { type: "server_vad" }
|
||||
: null;
|
||||
await clientRef.current.configure({
|
||||
instructions: "",
|
||||
voice,
|
||||
input_audio_transcription: { model: "whisper-1" },
|
||||
turn_detection: turnDetection,
|
||||
tools: [],
|
||||
temperature,
|
||||
modalities,
|
||||
});
|
||||
startResponseListener();
|
||||
|
||||
setIsConnected(true);
|
||||
// TODO
|
||||
// try {
|
||||
// const recentMessages = chatStore.getMessagesWithMemory();
|
||||
// for (const message of recentMessages) {
|
||||
// const { role, content } = message;
|
||||
// if (typeof content === "string") {
|
||||
// await clientRef.current.sendItem({
|
||||
// type: "message",
|
||||
// role: role as any,
|
||||
// content: [
|
||||
// {
|
||||
// type: (role === "assistant" ? "text" : "input_text") as any,
|
||||
// text: content as string,
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// // await clientRef.current.generateResponse();
|
||||
// } catch (error) {
|
||||
// console.error("Set message failed:", error);
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
setStatus("Connection failed");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
} else {
|
||||
await disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
if (clientRef.current) {
|
||||
try {
|
||||
await clientRef.current.close();
|
||||
clientRef.current = null;
|
||||
setIsConnected(false);
|
||||
} catch (error) {
|
||||
console.error("Disconnect failed:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startResponseListener = async () => {
|
||||
if (!clientRef.current) return;
|
||||
|
||||
try {
|
||||
for await (const serverEvent of clientRef.current.events()) {
|
||||
if (serverEvent.type === "response") {
|
||||
await handleResponse(serverEvent);
|
||||
} else if (serverEvent.type === "input_audio") {
|
||||
await handleInputAudio(serverEvent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (clientRef.current) {
|
||||
console.error("Response iteration error:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponse = async (response: RTResponse) => {
|
||||
for await (const item of response) {
|
||||
if (item.type === "message" && item.role === "assistant") {
|
||||
const botMessage = createMessage({
|
||||
role: item.role,
|
||||
content: "",
|
||||
});
|
||||
// add bot message first
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat([botMessage]);
|
||||
});
|
||||
let hasAudio = false;
|
||||
for await (const content of item) {
|
||||
if (content.type === "text") {
|
||||
for await (const text of content.textChunks()) {
|
||||
botMessage.content += text;
|
||||
}
|
||||
} else if (content.type === "audio") {
|
||||
const textTask = async () => {
|
||||
for await (const text of content.transcriptChunks()) {
|
||||
botMessage.content += text;
|
||||
}
|
||||
};
|
||||
const audioTask = async () => {
|
||||
audioHandlerRef.current?.startStreamingPlayback();
|
||||
for await (const audio of content.audioChunks()) {
|
||||
hasAudio = true;
|
||||
audioHandlerRef.current?.playChunk(audio);
|
||||
}
|
||||
};
|
||||
await Promise.all([textTask(), audioTask()]);
|
||||
}
|
||||
// update message.content
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
}
|
||||
if (hasAudio) {
|
||||
// upload audio get audio_url
|
||||
const blob = audioHandlerRef.current?.savePlayFile();
|
||||
uploadImage(blob!).then((audio_url) => {
|
||||
botMessage.audio_url = audio_url;
|
||||
// update text and audio_url
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputAudio = async (item: RTInputAudioItem) => {
|
||||
await item.waitForCompletion();
|
||||
if (item.transcription) {
|
||||
const userMessage = createMessage({
|
||||
role: "user",
|
||||
content: item.transcription,
|
||||
});
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat([userMessage]);
|
||||
});
|
||||
// save input audio_url, and update session
|
||||
const { audioStartMillis, audioEndMillis } = item;
|
||||
// upload audio get audio_url
|
||||
const blob = audioHandlerRef.current?.saveRecordFile(
|
||||
audioStartMillis,
|
||||
audioEndMillis,
|
||||
);
|
||||
uploadImage(blob!).then((audio_url) => {
|
||||
userMessage.audio_url = audio_url;
|
||||
chatStore.updateTargetSession(session, (session) => {
|
||||
session.messages = session.messages.concat();
|
||||
});
|
||||
});
|
||||
}
|
||||
// stop streaming play after get input audio.
|
||||
audioHandlerRef.current?.stopStreamingPlayback();
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (!isRecording && clientRef.current) {
|
||||
try {
|
||||
if (!audioHandlerRef.current) {
|
||||
audioHandlerRef.current = new AudioHandler();
|
||||
await audioHandlerRef.current.initialize();
|
||||
}
|
||||
await audioHandlerRef.current.startRecording(async (chunk) => {
|
||||
await clientRef.current?.sendAudio(chunk);
|
||||
});
|
||||
setIsRecording(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to start recording:", error);
|
||||
}
|
||||
} else if (audioHandlerRef.current) {
|
||||
try {
|
||||
audioHandlerRef.current.stopRecording();
|
||||
if (!useVAD) {
|
||||
const inputAudio = await clientRef.current?.commitAudio();
|
||||
await handleInputAudio(inputAudio!);
|
||||
await clientRef.current?.generateResponse();
|
||||
}
|
||||
setIsRecording(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop recording:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 防止重复初始化
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
const initAudioHandler = async () => {
|
||||
const handler = new AudioHandler();
|
||||
await handler.initialize();
|
||||
audioHandlerRef.current = handler;
|
||||
await handleConnect();
|
||||
await toggleRecording();
|
||||
};
|
||||
|
||||
initAudioHandler().catch((error) => {
|
||||
setStatus(error);
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (isRecording) {
|
||||
toggleRecording();
|
||||
}
|
||||
audioHandlerRef.current?.close().catch(console.error);
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number;
|
||||
|
||||
if (isConnected && isRecording) {
|
||||
const animationFrame = () => {
|
||||
if (audioHandlerRef.current) {
|
||||
const freqData = audioHandlerRef.current.getByteFrequencyData();
|
||||
setFrequencies(freqData);
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(animationFrame);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(animationFrame);
|
||||
} else {
|
||||
setFrequencies(undefined);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, [isConnected, isRecording]);
|
||||
|
||||
// update session params
|
||||
useEffect(() => {
|
||||
clientRef.current?.configure({ voice });
|
||||
}, [voice]);
|
||||
useEffect(() => {
|
||||
clientRef.current?.configure({ temperature });
|
||||
}, [temperature]);
|
||||
|
||||
const handleClose = async () => {
|
||||
onClose?.();
|
||||
if (isRecording) {
|
||||
await toggleRecording();
|
||||
}
|
||||
disconnect().catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["realtime-chat"]}>
|
||||
<div
|
||||
className={clsx(styles["circle-mic"], {
|
||||
[styles["pulse"]]: isRecording,
|
||||
})}
|
||||
>
|
||||
<VoicePrint frequencies={frequencies} isActive={isRecording} />
|
||||
</div>
|
||||
|
||||
<div className={styles["bottom-icons"]}>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={isRecording ? <VoiceIcon /> : <VoiceOffIcon />}
|
||||
onClick={toggleRecording}
|
||||
disabled={!isConnected}
|
||||
shadow
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["icon-center"]}>{status}</div>
|
||||
<div>
|
||||
<IconButton
|
||||
icon={<PowerIcon />}
|
||||
onClick={handleClose}
|
||||
shadow
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
app/components/realtime-chat/realtime-config.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { RealtimeConfig } from "@/app/store";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib";
|
||||
|
||||
import { InputRange } from "@/app/components/input-range";
|
||||
import { Voice } from "rt-client";
|
||||
import { ServiceProvider } from "@/app/constant";
|
||||
|
||||
const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure];
|
||||
|
||||
const models = ["gpt-4o-realtime-preview-2024-10-01"];
|
||||
|
||||
const voice = ["alloy", "shimmer", "echo"];
|
||||
|
||||
export function RealtimeConfigList(props: {
|
||||
realtimeConfig: RealtimeConfig;
|
||||
updateConfig: (updater: (config: RealtimeConfig) => void) => void;
|
||||
}) {
|
||||
const azureConfigComponent = props.realtimeConfig.provider ===
|
||||
ServiceProvider.Azure && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Azure.Endpoint.Title}
|
||||
subTitle={Locale.Settings.Realtime.Azure.Endpoint.SubTitle}
|
||||
>
|
||||
<input
|
||||
value={props.realtimeConfig?.azure?.endpoint}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Realtime.Azure.Endpoint.Title}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.azure.endpoint = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Azure.Deployment.Title}
|
||||
subTitle={Locale.Settings.Realtime.Azure.Deployment.SubTitle}
|
||||
>
|
||||
<input
|
||||
value={props.realtimeConfig?.azure?.deployment}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Realtime.Azure.Deployment.Title}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.azure.deployment = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Enable.Title}
|
||||
subTitle={Locale.Settings.Realtime.Enable.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.realtimeConfig.enable}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.enable = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
{props.realtimeConfig.enable && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Provider.Title}
|
||||
subTitle={Locale.Settings.Realtime.Provider.SubTitle}
|
||||
>
|
||||
<Select
|
||||
aria-label={Locale.Settings.Realtime.Provider.Title}
|
||||
value={props.realtimeConfig.provider}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.provider = e.target.value as ServiceProvider),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{providers.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Model.Title}
|
||||
subTitle={Locale.Settings.Realtime.Model.SubTitle}
|
||||
>
|
||||
<Select
|
||||
aria-label={Locale.Settings.Realtime.Model.Title}
|
||||
value={props.realtimeConfig.model}
|
||||
onChange={(e) => {
|
||||
props.updateConfig((config) => (config.model = e.target.value));
|
||||
}}
|
||||
>
|
||||
{models.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Realtime.ApiKey.SubTitle}
|
||||
>
|
||||
<PasswordInput
|
||||
aria={Locale.Settings.ShowPassword}
|
||||
aria-label={Locale.Settings.Realtime.ApiKey.Title}
|
||||
value={props.realtimeConfig.apiKey}
|
||||
type="text"
|
||||
placeholder={Locale.Settings.Realtime.ApiKey.Placeholder}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.apiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{azureConfigComponent}
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Voice.Title}
|
||||
subTitle={Locale.Settings.TTS.Voice.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={props.realtimeConfig.voice}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.voice = e.currentTarget.value as Voice),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{voice.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Realtime.Temperature.Title}
|
||||
subTitle={Locale.Settings.Realtime.Temperature.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.Temperature.Title}
|
||||
value={props.realtimeConfig?.temperature?.toFixed(1)}
|
||||
min="0.6"
|
||||
max="1"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.temperature = e.currentTarget.valueAsNumber),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
app/components/sd/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./sd";
|
||||
export * from "./sd-panel";
|
||||
45
app/components/sd/sd-panel.module.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
321
app/components/sd/sd-panel.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
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";
|
||||
import clsx from "clsx";
|
||||
|
||||
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={clsx(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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
140
app/components/sd/sd-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
app/components/sd/sd.module.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
340
app/components/sd/sd.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
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";
|
||||
import clsx from "clsx";
|
||||
|
||||
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={clsx({ [homeStyles["sidebar-show"]]: isSd })} />
|
||||
<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={clsx(
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
app/components/search-chat.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { ErrorBoundary } from "./error";
|
||||
import styles from "./mask.module.scss";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { IconButton } from "./button";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import Locale from "../locales";
|
||||
import { Path } from "../constant";
|
||||
|
||||
import { useChatStore } from "../store";
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
export function SearchChatPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const sessions = chatStore.sessions;
|
||||
const selectSession = chatStore.selectSession;
|
||||
|
||||
const [searchResults, setSearchResults] = useState<Item[]>([]);
|
||||
|
||||
const previousValueRef = useRef<string>("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const doSearch = useCallback((text: string) => {
|
||||
const lowerCaseText = text.toLowerCase();
|
||||
const results: Item[] = [];
|
||||
|
||||
sessions.forEach((session, index) => {
|
||||
const fullTextContents: string[] = [];
|
||||
|
||||
session.messages.forEach((message) => {
|
||||
const content = message.content as string;
|
||||
if (!content.toLowerCase || content === "") return;
|
||||
const lowerCaseContent = content.toLowerCase();
|
||||
|
||||
// full text search
|
||||
let pos = lowerCaseContent.indexOf(lowerCaseText);
|
||||
while (pos !== -1) {
|
||||
const start = Math.max(0, pos - 35);
|
||||
const end = Math.min(content.length, pos + lowerCaseText.length + 35);
|
||||
fullTextContents.push(content.substring(start, end));
|
||||
pos = lowerCaseContent.indexOf(
|
||||
lowerCaseText,
|
||||
pos + lowerCaseText.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (fullTextContents.length > 0) {
|
||||
results.push({
|
||||
id: index,
|
||||
name: session.topic,
|
||||
content: fullTextContents.join("... "), // concat content with...
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// sort by length of matching content
|
||||
results.sort((a, b) => b.content.length - a.content.length);
|
||||
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (searchInputRef.current) {
|
||||
const currentValue = searchInputRef.current.value;
|
||||
if (currentValue !== previousValueRef.current) {
|
||||
if (currentValue.length > 0) {
|
||||
const result = doSearch(currentValue);
|
||||
setSearchResults(result);
|
||||
}
|
||||
previousValueRef.current = currentValue;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Cleanup the interval on component unmount
|
||||
return () => clearInterval(intervalId);
|
||||
}, [doSearch]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
{/* header */}
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.SearchChat.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.SearchChat.Page.SubTitle(searchResults.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
{/**搜索输入框 */}
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.SearchChat.Page.Search}
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const searchText = e.currentTarget.value;
|
||||
if (searchText.length > 0) {
|
||||
const result = doSearch(searchText);
|
||||
setSearchResults(result);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{searchResults.map((item) => (
|
||||
<div
|
||||
className={styles["mask-item"]}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
navigate(Path.Chat);
|
||||
selectSession(item.id);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{/** 搜索匹配的文本 */}
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>{item.name}</div>
|
||||
{item.content.slice(0, 70)}
|
||||
</div>
|
||||
</div>
|
||||
{/** 操作按钮 */}
|
||||
<div className={styles["mask-actions"]}>
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text={Locale.SearchChat.Item.View}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -72,3 +72,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle-button {
|
||||
button {
|
||||
overflow:visible ;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,11 +7,10 @@ import SettingsIcon from "../icons/settings.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import ChatGptIcon from "../icons/chatgpt.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import MaskIcon from "../icons/mask.svg";
|
||||
import PluginIcon from "../icons/plugin.svg";
|
||||
import DragIcon from "../icons/drag.svg";
|
||||
import DiscoveryIcon from "../icons/discovery.svg";
|
||||
|
||||
import Locale from "../locales";
|
||||
|
||||
@@ -23,19 +22,21 @@ import {
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
NARROW_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
PLUGINS,
|
||||
REPO_URL,
|
||||
} from "../constant";
|
||||
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { isIOS, useMobileScreen } from "../utils";
|
||||
import dynamic from "next/dynamic";
|
||||
import { showConfirm, showToast } from "./ui-lib";
|
||||
import { showConfirm, Selector } from "./ui-lib";
|
||||
import clsx from "clsx";
|
||||
|
||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
function useHotKey() {
|
||||
export function useHotKey() {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,7 +55,7 @@ function useHotKey() {
|
||||
});
|
||||
}
|
||||
|
||||
function useDragSideBar() {
|
||||
export function useDragSideBar() {
|
||||
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
|
||||
|
||||
const config = useAppConfig();
|
||||
@@ -127,69 +128,154 @@ function useDragSideBar() {
|
||||
shouldNarrow,
|
||||
};
|
||||
}
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// drag side bar
|
||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
export function SideBarContainer(props: {
|
||||
children: React.ReactNode;
|
||||
onDragStart: (e: MouseEvent) => void;
|
||||
shouldNarrow: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const isIOSMobile = useMemo(
|
||||
() => isIOS() && isMobileScreen,
|
||||
[isMobileScreen],
|
||||
);
|
||||
|
||||
useHotKey();
|
||||
|
||||
const { children, className, onDragStart, shouldNarrow } = props;
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebar} ${props.className} ${
|
||||
shouldNarrow && styles["narrow-sidebar"]
|
||||
}`}
|
||||
className={clsx(styles.sidebar, className, {
|
||||
[styles["narrow-sidebar"]]: shouldNarrow,
|
||||
})}
|
||||
style={{
|
||||
// #3016 disable transition on ios mobile screen
|
||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
||||
<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>
|
||||
|
||||
{children}
|
||||
<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;
|
||||
shouldNarrow?: boolean;
|
||||
}) {
|
||||
const { title, subTitle, logo, children, shouldNarrow } = props;
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className={clsx(styles["sidebar-header"], {
|
||||
[styles["sidebar-header-narrow"]]: shouldNarrow,
|
||||
})}
|
||||
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={clsx(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 />}
|
||||
shouldNarrow={shouldNarrow}
|
||||
>
|
||||
<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={[
|
||||
...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) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
navigate(Path.Home);
|
||||
@@ -197,32 +283,41 @@ export function SideBar(props: { className?: string }) {
|
||||
}}
|
||||
>
|
||||
<ChatList narrow={shouldNarrow} />
|
||||
</div>
|
||||
|
||||
<div className={styles["sidebar-tail"]}>
|
||||
<div className={styles["sidebar-actions"]}>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Home.DeleteChat)) {
|
||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<Link to={Path.Settings}>
|
||||
<IconButton icon={<SettingsIcon />} shadow />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
</SideBarBody>
|
||||
<SideBarTail
|
||||
primaryAction={
|
||||
<>
|
||||
<div className={clsx(styles["sidebar-action"], styles.mobile)}>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Home.DeleteChat)) {
|
||||
chatStore.deleteSession(chatStore.currentSessionIndex);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<Link to={Path.Settings}>
|
||||
<IconButton
|
||||
aria={Locale.Settings.Title}
|
||||
icon={<SettingsIcon />}
|
||||
shadow
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles["sidebar-action"]}>
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton
|
||||
aria={Locale.Export.MessageFromChatGPT}
|
||||
icon={<GithubIcon />}
|
||||
shadow
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={shouldNarrow ? undefined : Locale.Home.NewChat}
|
||||
@@ -236,15 +331,8 @@ export function SideBar(props: { className?: string }) {
|
||||
}}
|
||||
shadow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles["sidebar-drag"]}
|
||||
onPointerDown={(e) => onDragStart(e as any)}
|
||||
>
|
||||
<DragIcon />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SideBarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
133
app/components/tts-config.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { TTSConfig, TTSConfigValidator } from "../store";
|
||||
|
||||
import Locale from "../locales";
|
||||
import { ListItem, Select } from "./ui-lib";
|
||||
import {
|
||||
DEFAULT_TTS_ENGINE,
|
||||
DEFAULT_TTS_ENGINES,
|
||||
DEFAULT_TTS_MODELS,
|
||||
DEFAULT_TTS_VOICES,
|
||||
} from "../constant";
|
||||
import { InputRange } from "./input-range";
|
||||
|
||||
export function TTSConfigList(props: {
|
||||
ttsConfig: TTSConfig;
|
||||
updateConfig: (updater: (config: TTSConfig) => void) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Enable.Title}
|
||||
subTitle={Locale.Settings.TTS.Enable.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.ttsConfig.enable}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.enable = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem>
|
||||
{/* <ListItem
|
||||
title={Locale.Settings.TTS.Autoplay.Title}
|
||||
subTitle={Locale.Settings.TTS.Autoplay.SubTitle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.ttsConfig.autoplay}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.autoplay = e.currentTarget.checked),
|
||||
)
|
||||
}
|
||||
></input>
|
||||
</ListItem> */}
|
||||
<ListItem title={Locale.Settings.TTS.Engine}>
|
||||
<Select
|
||||
value={props.ttsConfig.engine}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.engine = TTSConfigValidator.engine(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{DEFAULT_TTS_ENGINES.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
{props.ttsConfig.engine === DEFAULT_TTS_ENGINE && (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.TTS.Model}>
|
||||
<Select
|
||||
value={props.ttsConfig.model}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.model = TTSConfigValidator.model(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{DEFAULT_TTS_MODELS.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Voice.Title}
|
||||
subTitle={Locale.Settings.TTS.Voice.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={props.ttsConfig.voice}
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.voice = TTSConfigValidator.voice(
|
||||
e.currentTarget.value,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{DEFAULT_TTS_VOICES.map((v, i) => (
|
||||
<option value={v} key={i}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.TTS.Speed.Title}
|
||||
subTitle={Locale.Settings.TTS.Speed.SubTitle}
|
||||
>
|
||||
<InputRange
|
||||
aria={Locale.Settings.TTS.Speed.Title}
|
||||
value={props.ttsConfig.speed?.toFixed(1)}
|
||||
min="0.3"
|
||||
max="4.0"
|
||||
step="0.1"
|
||||
onChange={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.speed = TTSConfigValidator.speed(
|
||||
e.currentTarget.valueAsNumber,
|
||||
)),
|
||||
);
|
||||
}}
|
||||
></InputRange>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
app/components/tts.module.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
@import "../styles/animation.scss";
|
||||
.plugin-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.plugin-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.plugin-filter {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
animation: slide-in ease 0.3s;
|
||||
height: 40px;
|
||||
|
||||
display: flex;
|
||||
|
||||
.search-bar {
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-bar:focus {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.plugin-filter-lang {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.plugin-create {
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
box-sizing: border-box;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
border: var(--border-in-light);
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.plugin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.plugin-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
.plugin-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.plugin-info {
|
||||
font-size: 12px;
|
||||
}
|
||||
.plugin-runtime-warning {
|
||||
font-size: 12px;
|
||||
color: #f86c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
transition: all ease 0.3s;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--card-shadow);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,19 @@
|
||||
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 {
|
||||
@@ -239,6 +252,12 @@
|
||||
position: relative;
|
||||
max-width: fit-content;
|
||||
|
||||
&.left-align-option {
|
||||
option {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.select-with-icon-select {
|
||||
height: 100%;
|
||||
border: var(--border-in-light);
|
||||
@@ -291,7 +310,12 @@
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
|
||||
.selector-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&-content {
|
||||
min-width: 300px;
|
||||
.list {
|
||||
max-height: 90vh;
|
||||
overflow-x: hidden;
|
||||
@@ -312,3 +336,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,17 @@ import MinIcon from "../icons/min.svg";
|
||||
import Locale from "../locales";
|
||||
|
||||
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 clsx from "clsx";
|
||||
|
||||
export function Popover(props: {
|
||||
children: JSX.Element;
|
||||
@@ -37,21 +46,28 @@ export function Popover(props: {
|
||||
|
||||
export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||||
return (
|
||||
<div className={styles.card + " " + props.className}>{props.children}</div>
|
||||
<div className={clsx(styles.card, props.className)}>{props.children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListItem(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
title?: string;
|
||||
subTitle?: string | JSX.Element;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
icon?: JSX.Element;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={styles["list-item"] + ` ${props.className || ""}`}
|
||||
className={clsx(
|
||||
styles["list-item"],
|
||||
{
|
||||
[styles["vertical"]]: props.vertical,
|
||||
},
|
||||
props.className,
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<div className={styles["list-header"]}>
|
||||
@@ -122,9 +138,9 @@ export function Modal(props: ModalProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||||
}
|
||||
className={clsx(styles["modal-container"], {
|
||||
[styles["modal-container-max"]]: isMax,
|
||||
})}
|
||||
>
|
||||
<div className={styles["modal-header"]}>
|
||||
<div className={styles["modal-title"]}>{props.title}</div>
|
||||
@@ -247,14 +263,15 @@ export function Input(props: InputProps) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={`${styles["input"]} ${props.className}`}
|
||||
className={clsx(styles["input"], props.className)}
|
||||
></textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||
export function PasswordInput(
|
||||
props: HTMLProps<HTMLInputElement> & { aria?: string },
|
||||
) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function changeVisibility() {
|
||||
setVisible(!visible);
|
||||
}
|
||||
@@ -262,6 +279,7 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||
return (
|
||||
<div className={"password-input-container"}>
|
||||
<IconButton
|
||||
aria={props.aria}
|
||||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||||
onClick={changeVisibility}
|
||||
className={"password-eye"}
|
||||
@@ -277,13 +295,23 @@ export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
|
||||
|
||||
export function Select(
|
||||
props: React.DetailedHTMLProps<
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
align?: "left" | "center";
|
||||
},
|
||||
HTMLSelectElement
|
||||
>,
|
||||
) {
|
||||
const { className, children, ...otherProps } = props;
|
||||
const { className, children, align, ...otherProps } = props;
|
||||
return (
|
||||
<div className={`${styles["select-with-icon"]} ${className}`}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles["select-with-icon"],
|
||||
{
|
||||
[styles["left-align-option"]]: align === "left",
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||||
{children}
|
||||
</select>
|
||||
@@ -420,17 +448,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({
|
||||
title: Locale.Export.Image.Modal,
|
||||
defaultMax: defaultMax,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
|
||||
<img
|
||||
src={img}
|
||||
alt="preview"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
style={
|
||||
style ?? {
|
||||
maxWidth: "100%",
|
||||
}
|
||||
}
|
||||
></img>
|
||||
</div>
|
||||
),
|
||||
@@ -442,27 +478,56 @@ export function Selector<T>(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
value: T;
|
||||
disable?: boolean;
|
||||
}>;
|
||||
defaultSelectedValue?: T;
|
||||
defaultSelectedValue?: T[] | T;
|
||||
onSelection?: (selection: T[]) => void;
|
||||
onClose?: () => void;
|
||||
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 (
|
||||
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||||
<div className={styles["selector-content"]}>
|
||||
<List>
|
||||
{props.items.map((item, i) => {
|
||||
const selected = props.defaultSelectedValue === item.value;
|
||||
const selected = selectedValues.includes(item.value);
|
||||
return (
|
||||
<ListItem
|
||||
className={styles["selector-item"]}
|
||||
className={clsx(styles["selector-item"], {
|
||||
[styles["selector-item-disabled"]]: item.disable,
|
||||
})}
|
||||
key={i}
|
||||
title={item.title}
|
||||
subTitle={item.subTitle}
|
||||
onClick={() => {
|
||||
props.onSelection?.([item.value]);
|
||||
props.onClose?.();
|
||||
onClick={(e) => {
|
||||
if (item.disable) {
|
||||
e.stopPropagation();
|
||||
} else {
|
||||
handleSelection(e, item.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selected ? (
|
||||
@@ -485,3 +550,38 @@ export function Selector<T>(props: {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
1
app/components/voice-print/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./voice-print";
|
||||
11
app/components/voice-print/voice-print.module.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.voice-print {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
margin: 20px 0;
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: brightness(1.2); // 增加整体亮度
|
||||
}
|
||||
}
|
||||
180
app/components/voice-print/voice-print.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import styles from "./voice-print.module.scss";
|
||||
|
||||
interface VoicePrintProps {
|
||||
frequencies?: Uint8Array;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function VoicePrint({ frequencies, isActive }: VoicePrintProps) {
|
||||
// Canvas引用,用于获取绘图上下文
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// 存储历史频率数据,用于平滑处理
|
||||
const historyRef = useRef<number[][]>([]);
|
||||
// 控制保留的历史数据帧数,影响平滑度
|
||||
const historyLengthRef = useRef(10);
|
||||
// 存储动画帧ID,用于清理
|
||||
const animationFrameRef = useRef<number>();
|
||||
|
||||
/**
|
||||
* 更新频率历史数据
|
||||
* 使用FIFO队列维护固定长度的历史记录
|
||||
*/
|
||||
const updateHistory = useCallback((freqArray: number[]) => {
|
||||
historyRef.current.push(freqArray);
|
||||
if (historyRef.current.length > historyLengthRef.current) {
|
||||
historyRef.current.shift();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
/**
|
||||
* 处理高DPI屏幕显示
|
||||
* 根据设备像素比例调整canvas实际渲染分辨率
|
||||
*/
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.offsetWidth * dpr;
|
||||
canvas.height = canvas.offsetHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
/**
|
||||
* 主要绘制函数
|
||||
* 使用requestAnimationFrame实现平滑动画
|
||||
* 包含以下步骤:
|
||||
* 1. 清空画布
|
||||
* 2. 更新历史数据
|
||||
* 3. 计算波形点
|
||||
* 4. 绘制上下对称的声纹
|
||||
*/
|
||||
const draw = () => {
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (!frequencies || !isActive) {
|
||||
historyRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const freqArray = Array.from(frequencies);
|
||||
updateHistory(freqArray);
|
||||
|
||||
// 绘制声纹
|
||||
const points: [number, number][] = [];
|
||||
const centerY = canvas.height / 2;
|
||||
const width = canvas.width;
|
||||
const sliceWidth = width / (frequencies.length - 1);
|
||||
|
||||
// 绘制主波形
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, centerY);
|
||||
|
||||
/**
|
||||
* 声纹绘制算法:
|
||||
* 1. 使用历史数据平均值实现平滑过渡
|
||||
* 2. 通过正弦函数添加自然波动
|
||||
* 3. 使用贝塞尔曲线连接点,使曲线更平滑
|
||||
* 4. 绘制对称部分形成完整声纹
|
||||
*/
|
||||
for (let i = 0; i < frequencies.length; i++) {
|
||||
const x = i * sliceWidth;
|
||||
let avgFrequency = frequencies[i];
|
||||
|
||||
/**
|
||||
* 波形平滑处理:
|
||||
* 1. 收集历史数据中对应位置的频率值
|
||||
* 2. 计算当前值与历史值的加权平均
|
||||
* 3. 根据平均值计算实际显示高度
|
||||
*/
|
||||
if (historyRef.current.length > 0) {
|
||||
const historicalValues = historyRef.current.map((h) => h[i] || 0);
|
||||
avgFrequency =
|
||||
(avgFrequency + historicalValues.reduce((a, b) => a + b, 0)) /
|
||||
(historyRef.current.length + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 波形变换:
|
||||
* 1. 归一化频率值到0-1范围
|
||||
* 2. 添加时间相关的正弦变换
|
||||
* 3. 使用贝塞尔曲线平滑连接点
|
||||
*/
|
||||
const normalized = avgFrequency / 255.0;
|
||||
const height = normalized * (canvas.height / 2);
|
||||
const y = centerY + height * Math.sin(i * 0.2 + Date.now() * 0.002);
|
||||
|
||||
points.push([x, y]);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
// 使用贝塞尔曲线使波形更平滑
|
||||
const prevPoint = points[i - 1];
|
||||
const midX = (prevPoint[0] + x) / 2;
|
||||
ctx.quadraticCurveTo(
|
||||
prevPoint[0],
|
||||
prevPoint[1],
|
||||
midX,
|
||||
(prevPoint[1] + y) / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制对称的下半部分
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
const [x, y] = points[i];
|
||||
const symmetricY = centerY - (y - centerY);
|
||||
if (i === points.length - 1) {
|
||||
ctx.lineTo(x, symmetricY);
|
||||
} else {
|
||||
const nextPoint = points[i + 1];
|
||||
const midX = (nextPoint[0] + x) / 2;
|
||||
ctx.quadraticCurveTo(
|
||||
nextPoint[0],
|
||||
centerY - (nextPoint[1] - centerY),
|
||||
midX,
|
||||
centerY - ((nextPoint[1] + y) / 2 - centerY),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
|
||||
/**
|
||||
* 渐变效果:
|
||||
* 从左到右应用三色渐变,带透明度
|
||||
* 使用蓝色系配色提升视觉效果
|
||||
*/
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)");
|
||||
gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)");
|
||||
gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
// 启动动画循环
|
||||
draw();
|
||||
|
||||
// 清理函数:在组件卸载时取消动画
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [frequencies, isActive, updateHistory]);
|
||||
|
||||
return (
|
||||
<div className={styles["voice-print"]}>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { BuildConfig, getBuildConfig } from "./build";
|
||||
export function getClientConfig() {
|
||||
if (typeof document !== "undefined") {
|
||||
// client side
|
||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
||||
return JSON.parse(queryMeta("config") || "{}") as BuildConfig;
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import md5 from "spark-md5";
|
||||
import { DEFAULT_MODELS } from "../constant";
|
||||
import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant";
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
@@ -21,7 +21,11 @@ declare global {
|
||||
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
|
||||
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
||||
CUSTOM_MODELS?: string; // to control custom models
|
||||
DEFAULT_MODEL?: string; // to cnntrol 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_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
||||
@@ -35,6 +39,46 @@ declare global {
|
||||
// google tag manager
|
||||
GTM_ID?: string;
|
||||
|
||||
// anthropic only
|
||||
ANTHROPIC_URL?: string;
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
ANTHROPIC_API_VERSION?: string;
|
||||
|
||||
// baidu only
|
||||
BAIDU_URL?: string;
|
||||
BAIDU_API_KEY?: string;
|
||||
BAIDU_SECRET_KEY?: string;
|
||||
|
||||
// bytedance only
|
||||
BYTEDANCE_URL?: string;
|
||||
BYTEDANCE_API_KEY?: string;
|
||||
|
||||
// alibaba only
|
||||
ALIBABA_URL?: 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;
|
||||
|
||||
// xai only
|
||||
XAI_URL?: string;
|
||||
XAI_API_KEY?: string;
|
||||
|
||||
// chatglm only
|
||||
CHATGLM_URL?: string;
|
||||
CHATGLM_API_KEY?: string;
|
||||
|
||||
// custom template for preprocessing user input
|
||||
DEFAULT_INPUT_TEMPLATE?: string;
|
||||
}
|
||||
@@ -83,16 +127,35 @@ export const getServerSideConfig = () => {
|
||||
|
||||
if (disableGPT4) {
|
||||
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("chatgpt-4o")) &&
|
||||
!m.name.startsWith("gpt-4o-mini"),
|
||||
)
|
||||
.map((m) => "-" + m.name)
|
||||
.join(",");
|
||||
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
|
||||
if (
|
||||
(defaultModel.startsWith("gpt-4") ||
|
||||
defaultModel.startsWith("chatgpt-4o")) &&
|
||||
!defaultModel.startsWith("gpt-4o-mini")
|
||||
)
|
||||
defaultModel = "";
|
||||
}
|
||||
|
||||
const isStability = !!process.env.STABILITY_API_KEY;
|
||||
|
||||
const isAzure = !!process.env.AZURE_URL;
|
||||
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||
const isTencent = !!process.env.TENCENT_API_KEY;
|
||||
|
||||
const isBaidu = !!process.env.BAIDU_API_KEY;
|
||||
const isBytedance = !!process.env.BYTEDANCE_API_KEY;
|
||||
const isAlibaba = !!process.env.ALIBABA_API_KEY;
|
||||
const isMoonshot = !!process.env.MOONSHOT_API_KEY;
|
||||
const isIflytek = !!process.env.IFLYTEK_API_KEY;
|
||||
const isXAI = !!process.env.XAI_API_KEY;
|
||||
const isChatGLM = !!process.env.CHATGLM_API_KEY;
|
||||
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||
@@ -101,8 +164,8 @@ export const getServerSideConfig = () => {
|
||||
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
||||
// );
|
||||
|
||||
const allowedWebDevEndpoints = (
|
||||
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
|
||||
const allowedWebDavEndpoints = (
|
||||
process.env.WHITE_WEBDAV_ENDPOINTS ?? ""
|
||||
).split(",");
|
||||
|
||||
return {
|
||||
@@ -110,6 +173,10 @@ export const getServerSideConfig = () => {
|
||||
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
||||
openaiOrgId: process.env.OPENAI_ORG_ID,
|
||||
|
||||
isStability,
|
||||
stabilityUrl: process.env.STABILITY_URL,
|
||||
stabilityApiKey: getApiKey(process.env.STABILITY_API_KEY),
|
||||
|
||||
isAzure,
|
||||
azureUrl: process.env.AZURE_URL,
|
||||
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
||||
@@ -124,7 +191,48 @@ export const getServerSideConfig = () => {
|
||||
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
||||
anthropicUrl: process.env.ANTHROPIC_URL,
|
||||
|
||||
isBaidu,
|
||||
baiduUrl: process.env.BAIDU_URL,
|
||||
baiduApiKey: getApiKey(process.env.BAIDU_API_KEY),
|
||||
baiduSecretKey: process.env.BAIDU_SECRET_KEY,
|
||||
|
||||
isBytedance,
|
||||
bytedanceApiKey: getApiKey(process.env.BYTEDANCE_API_KEY),
|
||||
bytedanceUrl: process.env.BYTEDANCE_URL,
|
||||
|
||||
isAlibaba,
|
||||
alibabaUrl: process.env.ALIBABA_URL,
|
||||
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,
|
||||
|
||||
isXAI,
|
||||
xaiUrl: process.env.XAI_URL,
|
||||
xaiApiKey: getApiKey(process.env.XAI_API_KEY),
|
||||
|
||||
isChatGLM,
|
||||
chatglmUrl: process.env.CHATGLM_URL,
|
||||
chatglmApiKey: getApiKey(process.env.CHATGLM_API_KEY),
|
||||
|
||||
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,
|
||||
gaId: process.env.GA_ID || DEFAULT_GA_ID,
|
||||
|
||||
needCode: ACCESS_CODES.size > 0,
|
||||
code: process.env.CODE,
|
||||
@@ -139,6 +247,6 @@ export const getServerSideConfig = () => {
|
||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||
customModels,
|
||||
defaultModel,
|
||||
allowedWebDevEndpoints,
|
||||
allowedWebDavEndpoints,
|
||||
};
|
||||
};
|
||||
|
||||
361
app/constant.ts
@@ -8,25 +8,62 @@ 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 RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
||||
|
||||
export const DEFAULT_API_HOST = "https://api.nextchat.dev";
|
||||
export const STABILITY_BASE_URL = "https://api.stability.ai";
|
||||
|
||||
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
|
||||
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
|
||||
|
||||
export const BAIDU_BASE_URL = "https://aip.baidubce.com";
|
||||
export const BAIDU_OATUH_URL = `${BAIDU_BASE_URL}/oauth/2.0/token`;
|
||||
|
||||
export const BYTEDANCE_BASE_URL = "https://ark.cn-beijing.volces.com";
|
||||
|
||||
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 XAI_BASE_URL = "https://api.x.ai";
|
||||
|
||||
export const CHATGLM_BASE_URL = "https://open.bigmodel.cn";
|
||||
|
||||
export const CACHE_URL_PREFIX = "/api/cache";
|
||||
export const UPLOAD_URL = `${CACHE_URL_PREFIX}/upload`;
|
||||
|
||||
export enum Path {
|
||||
Home = "/",
|
||||
Chat = "/chat",
|
||||
Settings = "/settings",
|
||||
NewChat = "/new-chat",
|
||||
Masks = "/masks",
|
||||
Plugins = "/plugins",
|
||||
Auth = "/auth",
|
||||
Sd = "/sd",
|
||||
SdNew = "/sd-new",
|
||||
Artifacts = "/artifacts",
|
||||
SearchChat = "/search-chat",
|
||||
}
|
||||
|
||||
export enum ApiPath {
|
||||
Cors = "",
|
||||
Azure = "/api/azure",
|
||||
OpenAI = "/api/openai",
|
||||
Anthropic = "/api/anthropic",
|
||||
Google = "/api/google",
|
||||
Baidu = "/api/baidu",
|
||||
ByteDance = "/api/bytedance",
|
||||
Alibaba = "/api/alibaba",
|
||||
Tencent = "/api/tencent",
|
||||
Moonshot = "/api/moonshot",
|
||||
Iflytek = "/api/iflytek",
|
||||
Stability = "/api/stability",
|
||||
Artifacts = "/api/artifacts",
|
||||
XAI = "/api/xai",
|
||||
ChatGLM = "/api/chatglm",
|
||||
}
|
||||
|
||||
export enum SlotID {
|
||||
@@ -41,12 +78,14 @@ export enum FileName {
|
||||
|
||||
export enum StoreKey {
|
||||
Chat = "chat-next-web-store",
|
||||
Plugin = "chat-next-web-plugin",
|
||||
Access = "access-control",
|
||||
Config = "app-config",
|
||||
Mask = "mask-store",
|
||||
Prompt = "prompt-store",
|
||||
Update = "chat-update",
|
||||
Sync = "sync",
|
||||
SdList = "sd-list",
|
||||
}
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||
@@ -70,14 +109,46 @@ export enum ServiceProvider {
|
||||
Azure = "Azure",
|
||||
Google = "Google",
|
||||
Anthropic = "Anthropic",
|
||||
Baidu = "Baidu",
|
||||
ByteDance = "ByteDance",
|
||||
Alibaba = "Alibaba",
|
||||
Tencent = "Tencent",
|
||||
Moonshot = "Moonshot",
|
||||
Stability = "Stability",
|
||||
Iflytek = "Iflytek",
|
||||
XAI = "XAI",
|
||||
ChatGLM = "ChatGLM",
|
||||
}
|
||||
|
||||
// Google API safety settings, see https://ai.google.dev/gemini-api/docs/safety-settings
|
||||
// BLOCK_NONE will not block any content, and BLOCK_ONLY_HIGH will block only high-risk content.
|
||||
export enum GoogleSafetySettingsThreshold {
|
||||
BLOCK_NONE = "BLOCK_NONE",
|
||||
BLOCK_ONLY_HIGH = "BLOCK_ONLY_HIGH",
|
||||
BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE",
|
||||
BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE",
|
||||
}
|
||||
|
||||
export enum ModelProvider {
|
||||
Stability = "Stability",
|
||||
GPT = "GPT",
|
||||
GeminiPro = "GeminiPro",
|
||||
Claude = "Claude",
|
||||
Ernie = "Ernie",
|
||||
Doubao = "Doubao",
|
||||
Qwen = "Qwen",
|
||||
Hunyuan = "Hunyuan",
|
||||
Moonshot = "Moonshot",
|
||||
Iflytek = "Iflytek",
|
||||
XAI = "XAI",
|
||||
ChatGLM = "ChatGLM",
|
||||
}
|
||||
|
||||
export const Stability = {
|
||||
GeneratePath: "v2beta/stable-image/generate",
|
||||
ExampleEndpoint: "https://api.stability.ai",
|
||||
};
|
||||
|
||||
export const Anthropic = {
|
||||
ChatPath: "v1/messages",
|
||||
ChatPath1: "v1/complete",
|
||||
@@ -87,18 +158,80 @@ export const Anthropic = {
|
||||
|
||||
export const OpenaiPath = {
|
||||
ChatPath: "v1/chat/completions",
|
||||
SpeechPath: "v1/audio/speech",
|
||||
ImagePath: "v1/images/generations",
|
||||
UsagePath: "dashboard/billing/usage",
|
||||
SubsPath: "dashboard/billing/subscription",
|
||||
ListModelPath: "v1/models",
|
||||
};
|
||||
|
||||
export const Azure = {
|
||||
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
|
||||
ChatPath: (deployName: string, apiVersion: string) =>
|
||||
`deployments/${deployName}/chat/completions?api-version=${apiVersion}`,
|
||||
// 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 = {
|
||||
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
||||
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||
ChatPath: (modelName: string) =>
|
||||
`v1beta/models/${modelName}:streamGenerateContent`,
|
||||
};
|
||||
|
||||
export const Baidu = {
|
||||
ExampleEndpoint: BAIDU_BASE_URL,
|
||||
ChatPath: (modelName: string) => {
|
||||
let endpoint = modelName;
|
||||
if (modelName === "ernie-4.0-8k") {
|
||||
endpoint = "completions_pro";
|
||||
}
|
||||
if (modelName === "ernie-4.0-8k-preview-0518") {
|
||||
endpoint = "completions_adv_pro";
|
||||
}
|
||||
if (modelName === "ernie-3.5-8k") {
|
||||
endpoint = "completions";
|
||||
}
|
||||
if (modelName === "ernie-speed-8k") {
|
||||
endpoint = "ernie_speed";
|
||||
}
|
||||
return `rpc/2.0/ai_custom/v1/wenxinworkshop/chat/${endpoint}`;
|
||||
},
|
||||
};
|
||||
|
||||
export const ByteDance = {
|
||||
ExampleEndpoint: "https://ark.cn-beijing.volces.com/api/",
|
||||
ChatPath: "api/v3/chat/completions",
|
||||
};
|
||||
|
||||
export const Alibaba = {
|
||||
ExampleEndpoint: ALIBABA_BASE_URL,
|
||||
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 XAI = {
|
||||
ExampleEndpoint: XAI_BASE_URL,
|
||||
ChatPath: "v1/chat/completions",
|
||||
};
|
||||
|
||||
export const ChatGLM = {
|
||||
ExampleEndpoint: CHATGLM_BASE_URL,
|
||||
ChatPath: "api/paas/v4/chat/completions",
|
||||
};
|
||||
|
||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||
@@ -119,7 +252,7 @@ Latex inline: \\(x^2\\)
|
||||
Latex block: $$e=mc^2$$
|
||||
`;
|
||||
|
||||
export const SUMMARIZE_MODEL = "gpt-3.5-turbo";
|
||||
export const SUMMARIZE_MODEL = "gpt-4o-mini";
|
||||
export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
||||
|
||||
export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
@@ -129,13 +262,34 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
"gpt-4-turbo-preview": "2023-12",
|
||||
"gpt-4o": "2023-10",
|
||||
"gpt-4o-2024-05-13": "2023-10",
|
||||
"gpt-4o-2024-08-06": "2023-10",
|
||||
"gpt-4o-2024-11-20": "2023-10",
|
||||
"chatgpt-4o-latest": "2023-10",
|
||||
"gpt-4o-mini": "2023-10",
|
||||
"gpt-4o-mini-2024-07-18": "2023-10",
|
||||
"gpt-4-vision-preview": "2023-04",
|
||||
"o1-mini": "2023-10",
|
||||
"o1-preview": "2023-10",
|
||||
// After improvements,
|
||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||
"gemini-pro": "2023-12",
|
||||
"gemini-pro-vision": "2023-12",
|
||||
};
|
||||
|
||||
export const DEFAULT_TTS_ENGINE = "OpenAI-TTS";
|
||||
export const DEFAULT_TTS_ENGINES = ["OpenAI-TTS", "Edge-TTS"];
|
||||
export const DEFAULT_TTS_MODEL = "tts-1";
|
||||
export const DEFAULT_TTS_VOICE = "alloy";
|
||||
export const DEFAULT_TTS_MODELS = ["tts-1", "tts-1-hd"];
|
||||
export const DEFAULT_TTS_VOICES = [
|
||||
"alloy",
|
||||
"echo",
|
||||
"fable",
|
||||
"onyx",
|
||||
"nova",
|
||||
"shimmer",
|
||||
];
|
||||
|
||||
const openaiModels = [
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-1106",
|
||||
@@ -148,14 +302,26 @@ const openaiModels = [
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4o-2024-11-20",
|
||||
"chatgpt-4o-latest",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4-vision-preview",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-1106-preview",
|
||||
"dall-e-3",
|
||||
"o1-mini",
|
||||
"o1-preview",
|
||||
];
|
||||
|
||||
const googleModels = [
|
||||
"gemini-1.0-pro",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-exp-1114",
|
||||
"gemini-exp-1121",
|
||||
"learnlm-1.5-pro-experimental",
|
||||
"gemini-pro-vision",
|
||||
];
|
||||
|
||||
@@ -165,36 +331,213 @@ const anthropicModels = [
|
||||
"claude-2.1",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-opus-latest",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-sonnet-latest",
|
||||
];
|
||||
|
||||
const baiduModels = [
|
||||
"ernie-4.0-turbo-8k",
|
||||
"ernie-4.0-8k",
|
||||
"ernie-4.0-8k-preview",
|
||||
"ernie-4.0-8k-preview-0518",
|
||||
"ernie-4.0-8k-latest",
|
||||
"ernie-3.5-8k",
|
||||
"ernie-3.5-8k-0205",
|
||||
"ernie-speed-128k",
|
||||
"ernie-speed-8k",
|
||||
"ernie-lite-8k",
|
||||
"ernie-tiny-8k",
|
||||
];
|
||||
|
||||
const bytedanceModels = [
|
||||
"Doubao-lite-4k",
|
||||
"Doubao-lite-32k",
|
||||
"Doubao-lite-128k",
|
||||
"Doubao-pro-4k",
|
||||
"Doubao-pro-32k",
|
||||
"Doubao-pro-128k",
|
||||
];
|
||||
|
||||
const alibabaModes = [
|
||||
"qwen-turbo",
|
||||
"qwen-plus",
|
||||
"qwen-max",
|
||||
"qwen-max-0428",
|
||||
"qwen-max-0403",
|
||||
"qwen-max-0107",
|
||||
"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",
|
||||
];
|
||||
|
||||
const xAIModes = ["grok-beta"];
|
||||
|
||||
const chatglmModels = [
|
||||
"glm-4-plus",
|
||||
"glm-4-0520",
|
||||
"glm-4",
|
||||
"glm-4-air",
|
||||
"glm-4-airx",
|
||||
"glm-4-long",
|
||||
"glm-4-flashx",
|
||||
"glm-4-flash",
|
||||
];
|
||||
|
||||
let seq = 1000; // 内置的模型序号生成器从1000开始
|
||||
export const DEFAULT_MODELS = [
|
||||
...openaiModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++, // Global sequence sort(index)
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
sorted: 1, // 这里是固定的,确保顺序与之前内置的版本一致
|
||||
},
|
||||
})),
|
||||
...openaiModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "azure",
|
||||
providerName: "Azure",
|
||||
providerType: "azure",
|
||||
sorted: 2,
|
||||
},
|
||||
})),
|
||||
...googleModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "google",
|
||||
providerName: "Google",
|
||||
providerType: "google",
|
||||
sorted: 3,
|
||||
},
|
||||
})),
|
||||
...anthropicModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
sorted: 4,
|
||||
},
|
||||
})),
|
||||
...baiduModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "baidu",
|
||||
providerName: "Baidu",
|
||||
providerType: "baidu",
|
||||
sorted: 5,
|
||||
},
|
||||
})),
|
||||
...bytedanceModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "bytedance",
|
||||
providerName: "ByteDance",
|
||||
providerType: "bytedance",
|
||||
sorted: 6,
|
||||
},
|
||||
})),
|
||||
...alibabaModes.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "alibaba",
|
||||
providerName: "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,
|
||||
},
|
||||
})),
|
||||
...xAIModes.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "xai",
|
||||
providerName: "XAI",
|
||||
providerType: "xai",
|
||||
sorted: 11,
|
||||
},
|
||||
})),
|
||||
...chatglmModels.map((name) => ({
|
||||
name,
|
||||
available: true,
|
||||
sorted: seq++,
|
||||
provider: {
|
||||
id: "chatglm",
|
||||
providerName: "ChatGLM",
|
||||
providerType: "chatglm",
|
||||
sorted: 12,
|
||||
},
|
||||
})),
|
||||
] as const;
|
||||
@@ -214,3 +557,13 @@ export const internalAllowedWebDavEndpoints = [
|
||||
"https://webdav.yandex.com",
|
||||
"https://app.koofr.net/dav/Koofr",
|
||||
];
|
||||
|
||||
export const DEFAULT_GA_ID = "G-89WN60ZK2E";
|
||||
export const PLUGINS = [
|
||||
{ name: "Plugins", path: Path.Plugins },
|
||||
{ name: "Stable Diffusion", path: Path.Sd },
|
||||
{ name: "Search Chat", path: Path.SearchChat },
|
||||
];
|
||||
|
||||
export const SAAS_CHAT_URL = "https://nextchat.dev/chat";
|
||||
export const SAAS_CHAT_UTM_URL = "https://nextchat.dev/chat?utm=github";
|
||||
|
||||
15
app/global.d.ts
vendored
@@ -21,10 +21,23 @@ declare interface Window {
|
||||
writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
|
||||
writeTextFile(path: string, data: string): Promise<void>;
|
||||
};
|
||||
notification:{
|
||||
notification: {
|
||||
requestPermission(): Promise<Permission>;
|
||||
isPermissionGranted(): Promise<boolean>;
|
||||
sendNotification(options: string | Options): void;
|
||||
};
|
||||
updater: {
|
||||
checkUpdate(): Promise<UpdateResult>;
|
||||
installUpdate(): Promise<void>;
|
||||
onUpdaterEvent(
|
||||
handler: (status: UpdateStatusResult) => void,
|
||||
): Promise<UnlistenFn>;
|
||||
};
|
||||
http: {
|
||||
fetch<T>(
|
||||
url: string,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<Response<T>>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
1
app/icons/arrow.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="icon--SJP_d" width="16" height="16" fill="none" viewBox="0 0 16 16" style="min-width: 16px; min-height: 16px;"><g><path data-follow-fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M5.248 14.444a.625.625 0 0 1-.005-.884l5.068-5.12a.625.625 0 0 0 0-.88L5.243 2.44a.625.625 0 1 1 .889-.88l5.067 5.121c.723.73.723 1.907 0 2.638l-5.067 5.12a.625.625 0 0 1-.884.005Z" fill="currentColor"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 426 B |
7
app/icons/discovery.svg
Normal 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 |
1
app/icons/fire.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12.832 21.801c3.126-.626 7.168-2.875 7.168-8.69c0-5.291-3.873-8.815-6.658-10.434c-.619-.36-1.342.113-1.342.828v1.828c0 1.442-.606 4.074-2.29 5.169c-.86.559-1.79-.278-1.894-1.298l-.086-.838c-.1-.974-1.092-1.565-1.87-.971C4.461 8.46 3 10.33 3 13.11C3 20.221 8.289 22 10.933 22q.232 0 .484-.015C10.111 21.874 8 21.064 8 18.444c0-2.05 1.495-3.435 2.631-4.11c.306-.18.663.055.663.41v.59c0 .45.175 1.155.59 1.637c.47.546 1.159-.026 1.214-.744c.018-.226.246-.37.442-.256c.641.375 1.46 1.175 1.46 2.473c0 2.048-1.129 2.99-2.168 3.357"/></svg>
|
||||
|
After Width: | Height: | Size: 648 B |
4
app/icons/hd.svg
Normal 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 |
11
app/icons/headphone.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 28C4 26.8954 4.89543 26 6 26H10V38H6C4.89543 38 4 37.1046 4 36V28Z" fill="none" />
|
||||
<path d="M38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||
fill="none" />
|
||||
<path
|
||||
d="M10 36V24C10 16.268 16.268 10 24 10C31.732 10 38 16.268 38 24V36M10 26H6C4.89543 26 4 26.8954 4 28V36C4 37.1046 4.89543 38 6 38H10V26ZM38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 32H20L22 26L26 38L28 32H32" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 808 B |
10
app/icons/history.svg
Normal 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 |
19
app/icons/logo.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="38.73" height="42" viewBox="0 0 221 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="160.697" y="38.125" width="65.007" height="145.932" rx="32.503" transform="rotate(21.987 160.697 38.125)" fill="url(#logo_svg__a)"></rect>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="m48.642 79.125-25.92 71.213c-6.139 16.869 2.558 35.52 19.427 41.66 16.868 6.14 35.52-2.558 41.66-19.426L94.23 143.94l-36.658-37.439a32.42 32.42 0 0 1-9.244-23.497c.033-1.326.14-2.62.314-3.879Z" fill="url(#logo_svg__b)"></path>
|
||||
<path d="M172.578 132.787a32.765 32.765 0 0 1 8.981 24.238c-1.458 28.748-36.622 41.778-56.46 20.92l-67.644-71.122a32.763 32.763 0 0 1-8.981-24.238c1.457-28.748 36.622-41.778 56.46-20.92l67.644 71.122Z" fill="url(#logo_svg__c)" fill-opacity="0.96"></path>
|
||||
<defs>
|
||||
<linearGradient id="logo_svg__a" x1="215.063" y1="59.628" x2="160.714" y2="157.96" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#3EADFE"></stop>
|
||||
<stop offset="1" stop-color="#2A7AFF"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="logo_svg__b" x1="105.376" y1="84.416" x2="19.745" y2="131.163" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#01B3FF"></stop>
|
||||
<stop offset="1" stop-color="#59ECFA"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="logo_svg__c" x1="102.734" y1="136.396" x2="192.577" y2="155.859" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#023BFF" stop-opacity="0.82"></stop>
|
||||
<stop offset="0.88" stop-color="#2D86FF" stop-opacity="0.76"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
4
app/icons/palette.svg
Normal 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 |
7
app/icons/power.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.5 8C13.8406 8.37652 13.2062 8.79103 12.6 9.24051C11.5625 10.0097 10.6074 10.8814 9.75 11.8402C6.79377 15.1463 5 19.4891 5 24.2455C5 34.6033 13.5066 43 24 43C34.4934 43 43 34.6033 43 24.2455C43 19.4891 41.2062 15.1463 38.25 11.8402C37.3926 10.8814 36.4375 10.0097 35.4 9.24051C34.7938 8.79103 34.1594 8.37652 33.5 8"
|
||||
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 4V24" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 675 B |
12
app/icons/sd.svg
Normal 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/shortcutkey.svg
Normal 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 9V37C4 38.1046 4.89543 39 6 39H42C43.1046 39 44 38.1046 44 37V9C44 7.89543 43.1046 7 42 7Z" fill="none" stroke="#000" stroke-width="3" stroke-linejoin="round"/><path d="M12 19H14" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 19H23" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M29 19H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 28H36" stroke="#000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 734 B |
1
app/icons/size.svg
Normal 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 |
1
app/icons/speak-stop.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="16" height="16" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 495 B |
1
app/icons/speak.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="16" height="16" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 485 B |
13
app/icons/voice-off.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M31 24V11C31 7.13401 27.866 4 24 4C20.134 4 17 7.13401 17 11V24C17 27.866 20.134 31 24 31C27.866 31 31 27.866 31 24Z"
|
||||
stroke="#d0021b" stroke-width="4" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M9 23C9 31.2843 15.7157 38 24 38C25.7532 38 27.4361 37.6992 29 37.1465M39 23C39 25.1333 38.5547 27.1626 37.7519 29"
|
||||
stroke="#d0021b" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 38V44" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M42 42L6 6" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 811 B |
16
app/icons/voice-white.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 20 20">
|
||||
<defs>
|
||||
<rect id="path_0" width="20" height="20" x="0" y="0" />
|
||||
</defs>
|
||||
<g opacity="1" transform="translate(0 0) rotate(0 8 8)">
|
||||
<mask id="bg-mask-0" fill="#fff">
|
||||
<use xlink:href="#path_0" />
|
||||
</mask>
|
||||
<g mask="url(#bg-mask-0)">
|
||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" fill="#333333">
|
||||
</path>
|
||||
<path d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z" fill="#333333">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 708 B |
9
app/icons/voice.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="17" y="4" width="14" height="27" rx="7" fill="none" stroke="#333" stroke-width="4"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M9 23C9 31.2843 15.7157 38 24 38C32.2843 38 39 31.2843 39 23" stroke="#333"
|
||||
stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 38V44" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
1
app/icons/zoom.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" height="1.2rem" viewBox="0 0 24 24"><g fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></g></svg>
|
||||
|
After Width: | Height: | Size: 285 B |
@@ -3,30 +3,31 @@ import "./styles/globals.scss";
|
||||
import "./styles/markdown.scss";
|
||||
import "./styles/highlight.scss";
|
||||
import { getClientConfig } from "./config/client";
|
||||
import { type Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { getServerSideConfig } from "./config/server";
|
||||
import { GoogleTagManager } from "@next/third-parties/google";
|
||||
import { GoogleTagManager, GoogleAnalytics } from "@next/third-parties/google";
|
||||
const serverConfig = getServerSideConfig();
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NextChat",
|
||||
description: "Your personal ChatGPT Chat Bot.",
|
||||
viewport: {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
},
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#151515" },
|
||||
],
|
||||
appleWebApp: {
|
||||
title: "NextChat",
|
||||
statusBarStyle: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#151515" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -36,8 +37,15 @@ export default function RootLayout({
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="config" content={JSON.stringify(getClientConfig())} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<link rel="manifest" href="/site.webmanifest"></link>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/site.webmanifest"
|
||||
crossOrigin="use-credentials"
|
||||
></link>
|
||||
<script src="/serviceWorkerRegister.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -52,6 +60,11 @@ export default function RootLayout({
|
||||
<GoogleTagManager gtmId={serverConfig.gtmId} />
|
||||
</>
|
||||
)}
|
||||
{serverConfig?.gaId && (
|
||||
<>
|
||||
<GoogleAnalytics gaId={serverConfig.gaId} />
|
||||
</>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||