Compare commits

..

4 Commits

Author SHA1 Message Date
fred-bf
52316785d1 Merge branch 'main' into feat/voice-input 2024-03-19 17:52:00 +08:00
Fred
e2b15f785a feat: update voice input button 2024-03-18 17:53:59 +08:00
Fred
42d04d473e fix: update ui 2024-03-13 23:06:35 +08:00
Fred
2f53107581 feat: init voice support 2024-03-04 20:04:19 +08:00
226 changed files with 1787 additions and 17077 deletions

View File

@@ -1,97 +1,8 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Node.js dependencies
/node_modules
/jspm_packages
# TypeScript v1 declaration files
typings
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.test
# local env files
.env*.local
# Next.js build output
.next
out
# docker-compose env files
.env
# Nuxt.js build output
.nuxt
dist
# Gatsby files
.cache/
# Vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Temporary folders
tmp
temp
# IDE and editor directories
.idea
.vscode
*.swp
*.swo
*~
# OS generated files
.DS_Store
Thumbs.db
# secret key
*.key
*.key.pub
*.key.pub

View File

@@ -2,7 +2,7 @@
# Your openai api key. (required)
OPENAI_API_KEY=sk-xxxx
# Access password, separated by comma. (optional)
# Access passsword, separated by comma. (optional)
CODE=your-password
# You can start service behind a proxy
@@ -47,17 +47,3 @@ ENABLE_BALANCE_QUERY=
# If you want to disable parse settings from url, set this value to 1.
DISABLE_FAST_LINK=
# anthropic claude Api Key.(optional)
ANTHROPIC_API_KEY=
### anthropic claude Api version. (optional)
ANTHROPIC_API_VERSION=
### anthropic claude Api url (optional)
ANTHROPIC_URL=
### (optional)
WHITE_WEBDEV_ENDPOINTS=

View File

@@ -1,12 +1,4 @@
{
"extends": "next/core-web-vitals",
"plugins": [
"prettier"
],
"parserOptions": {
"ecmaFeatures": {
"legacyDecorators": true
}
},
"ignorePatterns": ["globals.css"]
"plugins": ["prettier"]
}

View File

@@ -200,18 +200,6 @@ Google Gemini Pro Api Key.
Google Gemini Pro Api Url.
### `ANTHROPIC_API_KEY` (optional)
anthropic claude Api Key.
### `ANTHROPIC_API_VERSION` (optional)
anthropic claude Api version.
### `ANTHROPIC_URL` (optional)
anthropic claude Api Url.
### `HIDE_USER_API_KEY` (optional)
> Default: Empty
@@ -228,7 +216,7 @@ If you do not want users to use GPT-4, set this value to 1.
> Default: Empty
If you do want users to query balance, set this value to 1.
If you do want users to query balance, set this value to 1, or you should set it to 0.
### `DISABLE_FAST_LINK` (optional)
@@ -245,17 +233,6 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model
User `-all` to disable all default models, `+all` to enable all default models.
### `WHITE_WEBDEV_ENDPOINTS` (optional)
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format
- Each address must be a complete endpoint
> `https://xxxx/yyy`
- Multiple addresses are connected by ', '
### `DEFAULT_INPUT_TEMPLATE` (optional)
Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
## Requirements
NodeJS >= 18, Docker >= 20

View File

@@ -114,18 +114,6 @@ Google Gemini Pro 密钥.
Google Gemini Pro Api Url.
### `ANTHROPIC_API_KEY` (optional)
anthropic claude Api Key.
### `ANTHROPIC_API_VERSION` (optional)
anthropic claude Api version.
### `ANTHROPIC_URL` (optional)
anthropic claude Api Url.
### `HIDE_USER_API_KEY` (可选)
如果你不想让用户自行填入 API Key将此环境变量设置为 1 即可。
@@ -142,13 +130,6 @@ anthropic claude Api Url.
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
### `WHITE_WEBDEV_ENDPOINTS` (可选)
如果你想增加允许访问的webdav服务地址可以使用该选项格式要求
- 每一个地址必须是一个完整的 endpoint
> `https://xxxx/xxx`
- 多个地址以`,`相连
### `CUSTOM_MODELS` (可选)
> 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。
@@ -156,9 +137,6 @@ anthropic claude Api Url.
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
### `DEFAULT_INPUT_TEMPLATE` (可选)
自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项
## 开发
点击下方按钮,开始二次开发:

View File

@@ -1,189 +0,0 @@
import { getServerSideConfig } from "@/app/config/server";
import {
ANTHROPIC_BASE_URL,
Anthropic,
ApiPath,
DEFAULT_MODELS,
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";
const ALLOWD_PATH = new Set([Anthropic.ChatPath, Anthropic.ChatPath1]);
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
console.log("[Anthropic Route] params ", params);
if (req.method === "OPTIONS") {
return NextResponse.json({ body: "OK" }, { status: 200 });
}
const subpath = params.path.join("/");
if (!ALLOWD_PATH.has(subpath)) {
console.log("[Anthropic Route] forbidden path ", subpath);
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, ModelProvider.Claude);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await request(req);
return response;
} catch (e) {
console.error("[Anthropic] ", 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",
];
const serverConfig = getServerSideConfig();
async function request(req: NextRequest) {
const controller = new AbortController();
let authHeaderName = "x-api-key";
let authValue =
req.headers.get(authHeaderName) ||
req.headers.get("Authorization")?.replaceAll("Bearer ", "").trim() ||
serverConfig.anthropicApiKey ||
"";
let path = `${req.nextUrl.pathname}`.replaceAll(ApiPath.Anthropic, "");
let baseUrl =
serverConfig.anthropicUrl || serverConfig.baseUrl || ANTHROPIC_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",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
"anthropic-version":
req.headers.get("anthropic-version") ||
serverConfig.anthropicApiVersion ||
Anthropic.Vision,
},
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 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) {
return NextResponse.json(
{
error: true,
message: `you are not allowed to use ${jsonBody?.model} model`,
},
{
status: 403,
},
);
}
} catch (e) {
console.error(`[Anthropic] filter`, e);
}
}
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,
);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -57,31 +57,12 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
if (!apiKey) {
const serverConfig = getServerSideConfig();
// const systemApiKey =
// modelProvider === ModelProvider.GeminiPro
// ? serverConfig.googleApiKey
// : serverConfig.isAzure
// ? serverConfig.azureApiKey
// : serverConfig.apiKey;
let systemApiKey: string | undefined;
switch (modelProvider) {
case ModelProvider.GeminiPro:
systemApiKey = serverConfig.googleApiKey;
break;
case ModelProvider.Claude:
systemApiKey = serverConfig.anthropicApiKey;
break;
case ModelProvider.GPT:
default:
if (serverConfig.isAzure) {
systemApiKey = serverConfig.azureApiKey;
} else {
systemApiKey = serverConfig.apiKey;
}
}
const systemApiKey =
modelProvider === ModelProvider.GeminiPro
? serverConfig.googleApiKey
: serverConfig.isAzure
? serverConfig.azureApiKey
: serverConfig.apiKey;
if (systemApiKey) {
console.log("[Auth] use system api key");
req.headers.set("Authorization", `Bearer ${systemApiKey}`);

View File

@@ -43,6 +43,10 @@ export async function requestOpenai(req: NextRequest) {
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
// this fix [Org ID] undefined in server side if not using custom point
if (serverConfig.openaiOrgId !== undefined) {
console.log("[Org ID]", serverConfig.openaiOrgId);
}
const timeoutId = setTimeout(
() => {
@@ -112,37 +116,18 @@ 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");
// 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);
newHeaders.delete("www-authenticate");
// 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() === "") {
newHeaders.delete("OpenAI-Organization");
}
// 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,

View File

@@ -13,7 +13,6 @@ const DANGER_CONFIG = {
hideBalanceQuery: serverConfig.hideBalanceQuery,
disableFastLink: serverConfig.disableFastLink,
customModels: serverConfig.customModels,
defaultModel: serverConfig.defaultModel,
};
declare global {

View File

@@ -1,93 +0,0 @@
import * as ProviderTemplates from "@/app/client/providers";
import { getServerSideConfig } from "@/app/config/server";
import { NextRequest, NextResponse } from "next/server";
import { cloneDeep } from "lodash-es";
import {
disableSystemApiKey,
makeUrlsUsable,
modelNameRequestHeader,
} from "@/app/client/common";
import { collectModelTable } from "@/app/utils/model";
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
const [providerName] = params.path;
const { headers } = req;
const serverConfig = getServerSideConfig();
const modelName = headers.get(modelNameRequestHeader);
const ProviderTemplate = Object.values(ProviderTemplates).find(
(t) => t.prototype.name === providerName,
);
if (!ProviderTemplate) {
return NextResponse.json(
{
error: true,
message: "No provider found: " + providerName,
},
{
status: 404,
},
);
}
// #1815 try to refuse gpt4 request
if (modelName && serverConfig.customModels) {
try {
const modelTable = collectModelTable([], serverConfig.customModels);
// not undefined and is false
if (modelTable[modelName]?.available === false) {
return NextResponse.json(
{
error: true,
message: `you are not allowed to use ${modelName} model`,
},
{
status: 403,
},
);
}
} catch (e) {
console.error("models filter", e);
}
}
const config = disableSystemApiKey(
makeUrlsUsable(cloneDeep(serverConfig), [
"anthropicUrl",
"azureUrl",
"googleUrl",
"baseUrl",
]),
["anthropicApiKey", "azureApiKey", "googleApiKey", "apiKey"],
serverConfig.needCode &&
ProviderTemplate !== ProviderTemplates.NextChatProvider, // if it must take a access code in the req, do not provide system-keys for Non-nextchat providers
);
const request = Object.assign({}, req, {
subpath: params.path.join("/"),
});
return new ProviderTemplate().serverSideRequestHandler(request, config);
}
export const GET = handle;
export const POST = handle;
export const PUT = handle;
export const PATCH = handle;
export const DELETE = handle;
export const OPTIONS = handle;
export const runtime = "edge";
export const preferredRegion = Array.from(
new Set(
Object.values(ProviderTemplates).reduce(
(arr, t) => [...arr, ...(t.prototype.preferredRegion ?? [])],
[] as string[],
),
),
);

View File

@@ -1,14 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
import { getServerSideConfig } from "@/app/config/server";
const config = getServerSideConfig();
const mergedAllowedWebDavEndpoints = [
...internalAllowedWebDavEndpoints,
...config.allowedWebDevEndpoints,
].filter((domain) => Boolean(domain.trim()));
import { STORAGE_KEY } from "../../../constant";
async function handle(
req: NextRequest,
{ params }: { params: { path: string[] } },
@@ -21,37 +12,17 @@ async function handle(
const requestUrl = new URL(req.url);
let endpoint = requestUrl.searchParams.get("endpoint");
// Validate the endpoint to prevent potential SSRF attacks
if (
!mergedAllowedWebDavEndpoints.some(
(allowedEndpoint) => endpoint?.startsWith(allowedEndpoint),
)
) {
return NextResponse.json(
{
error: true,
msg: "Invalid endpoint",
},
{
status: 400,
},
);
}
if (!endpoint?.endsWith("/")) {
endpoint += "/";
}
const endpointPath = params.path.join("/");
const targetPath = `${endpoint}${endpointPath}`;
// only allow MKCOL, GET, PUT
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
@@ -60,11 +31,14 @@ async function handle(
}
// for MKCOL request, only allow request ${folder}
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
if (
req.method == "MKCOL" &&
!new URL(endpointPath).pathname.endsWith(folder)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
@@ -73,11 +47,14 @@ async function handle(
}
// for GET request, only allow request ending with fileName
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
if (
req.method == "GET" &&
!new URL(endpointPath).pathname.endsWith(fileName)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
@@ -86,11 +63,14 @@ async function handle(
}
// for PUT request, only allow request ending with fileName
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
if (
req.method == "PUT" &&
!new URL(endpointPath).pathname.endsWith(fileName)
) {
return NextResponse.json(
{
error: true,
msg: "you are not allowed to request " + targetPath,
msg: "you are not allowed to request " + params.path.join("/"),
},
{
status: 403,
@@ -98,7 +78,7 @@ async function handle(
);
}
const targetUrl = targetPath;
const targetUrl = `${endpoint + endpointPath}`;
const method = req.method;
const shouldNotHaveBody = ["get", "head"].includes(
@@ -110,34 +90,22 @@ async function handle(
authorization: req.headers.get("authorization") ?? "",
},
body: shouldNotHaveBody ? null : req.body,
redirect: "manual",
method,
// @ts-ignore
duplex: "half",
};
let fetchResult;
const fetchResult = await fetch(targetUrl, fetchOptions);
try {
fetchResult = await fetch(targetUrl, fetchOptions);
} finally {
console.log(
"[Any Proxy]",
targetUrl,
{
method: req.method,
},
{
status: fetchResult?.status,
statusText: fetchResult?.statusText,
},
);
}
console.log("[Any Proxy]", targetUrl, {
status: fetchResult.status,
statusText: fetchResult.statusText,
});
return fetchResult;
}
export const PUT = handle;
export const POST = handle;
export const GET = handle;
export const OPTIONS = handle;

View File

@@ -8,7 +8,6 @@ import {
import { ChatMessage, ModelType, useAccessStore, useChatStore } from "../store";
import { ChatGPTApi } from "./platforms/openai";
import { GeminiProApi } from "./platforms/google";
import { ClaudeApi } from "./platforms/anthropic";
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
@@ -95,16 +94,11 @@ export class ClientApi {
public llm: LLMApi;
constructor(provider: ModelProvider = ModelProvider.GPT) {
switch (provider) {
case ModelProvider.GeminiPro:
this.llm = new GeminiProApi();
break;
case ModelProvider.Claude:
this.llm = new ClaudeApi();
break;
default:
this.llm = new ChatGPTApi();
if (provider === ModelProvider.GeminiPro) {
this.llm = new GeminiProApi();
return;
}
this.llm = new ChatGPTApi();
}
config() {}

View File

@@ -1,7 +0,0 @@
export * from "./types";
export * from "./locale";
export * from "./utils";
export const modelNameRequestHeader = "x-nextchat-model-name";

View File

@@ -1,19 +0,0 @@
import { Lang, getLang } from "@/app/locales";
interface PlainConfig {
[k: string]: PlainConfig | string;
}
export type LocaleMap<
TextPlainConfig extends PlainConfig,
Default extends Lang,
> = Partial<Record<Lang, TextPlainConfig>> & {
[name in Default]: TextPlainConfig;
};
export function getLocaleText<
TextPlainConfig extends PlainConfig,
DefaultLang extends Lang,
>(textMap: LocaleMap<TextPlainConfig, DefaultLang>, defaultLang: DefaultLang) {
return textMap[getLang()] || textMap[defaultLang];
}

View File

@@ -1,211 +0,0 @@
import { RequestMessage } from "../api";
import { getServerSideConfig } from "@/app/config/server";
import { NextRequest, NextResponse } from "next/server";
export { type RequestMessage };
// ===================================== LLM Types start ======================================
export interface ModelConfig {
temperature: number;
top_p: number;
presence_penalty: number;
frequency_penalty: number;
max_tokens: number;
}
export interface ModelSettings extends Omit<ModelConfig, "max_tokens"> {
global_max_tokens: number;
}
export type ModelTemplate = {
name: string; // id of model in a provider
displayName: string;
isVisionModel?: boolean;
isDefaultActive: boolean; // model is initialized to be active
isDefaultSelected?: boolean; // model is initialized to be as default used model
max_tokens?: number;
};
export interface Model extends Omit<ModelTemplate, "isDefaultActive"> {
providerTemplateName: string;
isActive: boolean;
providerName: string;
available: boolean;
customized: boolean; // Only customized model is allowed to be modified
}
export interface ModelInfo extends Pick<ModelTemplate, "name"> {
[k: string]: any;
}
// ===================================== LLM Types end ======================================
// ===================================== Chat Request Types start ======================================
export interface ChatRequestPayload {
messages: RequestMessage[];
context: {
isApp: boolean;
};
}
export interface StandChatRequestPayload extends ChatRequestPayload {
modelConfig: ModelConfig;
model: string;
}
export interface InternalChatRequestPayload<SettingKeys extends string = "">
extends StandChatRequestPayload {
providerConfig: Partial<Record<SettingKeys, string>>;
isVisionModel: Model["isVisionModel"];
stream: boolean;
}
export interface ProviderRequestPayload {
headers: Record<string, string>;
body: string;
url: string;
method: string;
}
export interface InternalChatHandlers {
onProgress: (message: string, chunk: string) => void;
onFinish: (message: string) => void;
onError: (err: Error) => void;
}
export interface ChatHandlers extends InternalChatHandlers {
onProgress: (chunk: string) => void;
onFinish: () => void;
onFlash: (message: string) => void;
}
// ===================================== Chat Request Types end ======================================
// ===================================== Chat Response Types start ======================================
export interface StandChatReponseMessage {
message: string;
}
// ===================================== Chat Request Types end ======================================
// ===================================== Provider Settings Types start ======================================
type NumberRange = [number, number];
export type Validator =
| "required"
| "number"
| "string"
| NumberRange
| NumberRange[]
| ((v: any) => Promise<string | void>);
export type CommonSettingItem<SettingKeys extends string> = {
name: SettingKeys;
title?: string;
description?: string;
validators?: Validator[];
};
export type InputSettingItem = {
type: "input";
placeholder?: string;
} & (
| {
inputType?: "password" | "normal";
defaultValue?: string;
}
| {
inputType?: "number";
defaultValue?: number;
}
);
export type SelectSettingItem = {
type: "select";
options: {
name: string;
value: "number" | "string" | "boolean";
}[];
placeholder?: string;
};
export type RangeSettingItem = {
type: "range";
range: NumberRange;
};
export type SwitchSettingItem = {
type: "switch";
};
export type SettingItem<SettingKeys extends string = ""> =
CommonSettingItem<SettingKeys> &
(
| InputSettingItem
| SelectSettingItem
| RangeSettingItem
| SwitchSettingItem
);
// ===================================== Provider Settings Types end ======================================
// ===================================== Provider Template Types start ======================================
export type ServerConfig = ReturnType<typeof getServerSideConfig>;
export interface IProviderTemplate<
SettingKeys extends string,
NAME extends string,
Meta extends Record<string, any>,
> {
readonly name: NAME;
readonly apiRouteRootName: `/api/provider/${NAME}`;
readonly allowedApiMethods: Array<
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"
>;
readonly metas: Meta;
readonly providerMeta: {
displayName: string;
settingItems: SettingItem<SettingKeys>[];
};
readonly defaultModels: ModelTemplate[];
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
): AbortController;
chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
): Promise<StandChatReponseMessage>;
getAvailableModels?(
providerConfig: InternalChatRequestPayload<SettingKeys>["providerConfig"],
): Promise<ModelInfo[]>;
readonly runtime: "edge";
readonly preferredRegion: "auto" | "global" | "home" | string | string[];
serverSideRequestHandler(
req: NextRequest & {
subpath: string;
},
serverConfig: ServerConfig,
): Promise<NextResponse>;
}
export type ProviderTemplate = IProviderTemplate<any, any, any>;
export interface Serializable<Snapshot> {
serialize(): Snapshot;
}

View File

@@ -1,88 +0,0 @@
import { NextRequest } from "next/server";
import { RequestMessage, ServerConfig } from "./types";
import { cloneDeep } from "lodash-es";
export function getMessageTextContent(message: RequestMessage) {
if (typeof message.content === "string") {
return message.content;
}
for (const c of message.content) {
if (c.type === "text") {
return c.text ?? "";
}
}
return "";
}
export function getMessageImages(message: RequestMessage): string[] {
if (typeof message.content === "string") {
return [];
}
const urls: string[] = [];
for (const c of message.content) {
if (c.type === "image_url") {
urls.push(c.image_url?.url ?? "");
}
}
return urls;
}
export function getIP(req: NextRequest) {
let ip = req.ip ?? req.headers.get("x-real-ip");
const forwardedFor = req.headers.get("x-forwarded-for");
if (!ip && forwardedFor) {
ip = forwardedFor.split(",").at(0) ?? "";
}
return ip;
}
export function formatUrl(baseUrl?: string) {
if (baseUrl && !baseUrl.startsWith("http")) {
baseUrl = `https://${baseUrl}`;
}
if (baseUrl?.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}
return baseUrl;
}
function travel(
config: ServerConfig,
keys: Array<keyof ServerConfig>,
handle: (prop: any) => any,
): ServerConfig {
const copiedConfig = cloneDeep(config);
keys.forEach((k) => {
copiedConfig[k] = handle(copiedConfig[k] as string) as never;
});
return copiedConfig;
}
export const makeUrlsUsable = (
config: ServerConfig,
keys: Array<keyof ServerConfig>,
) => travel(config, keys, formatUrl);
export const disableSystemApiKey = (
config: ServerConfig,
keys: Array<keyof ServerConfig>,
forbidden: boolean,
) =>
travel(config, keys, (p) => {
return forbidden ? undefined : p;
});
export function isSameOrigin(requestUrl: string) {
var a = document.createElement("a");
a.href = requestUrl;
// 检查协议、主机名和端口号是否与当前页面相同
return (
a.protocol === window.location.protocol &&
a.hostname === window.location.hostname &&
a.port === window.location.port
);
}

View File

@@ -1,9 +0,0 @@
export * from "./shim";
export * from "../common/types";
export * from "./providerClient";
export * from "./modelClient";
export * from "../common/locale";

View File

@@ -1,98 +0,0 @@
import {
ChatRequestPayload,
Model,
ModelSettings,
InternalChatHandlers,
} from "../common";
import { Provider, ProviderClient } from "./providerClient";
export class ModelClient {
constructor(
private model: Model,
private modelSettings: ModelSettings,
private providerClient: ProviderClient,
) {}
chat(payload: ChatRequestPayload, handlers: InternalChatHandlers) {
try {
return this.providerClient.streamChat(
{
...payload,
modelConfig: {
...this.modelSettings,
max_tokens:
this.model.max_tokens ?? this.modelSettings.global_max_tokens,
},
model: this.model.name,
},
handlers,
);
} catch (e) {
handlers.onError(e as Error);
}
}
summerize(payload: ChatRequestPayload) {
try {
return this.providerClient.chat({
...payload,
modelConfig: {
...this.modelSettings,
max_tokens:
this.model.max_tokens ?? this.modelSettings.global_max_tokens,
},
model: this.model.name,
});
} catch (e) {
return "";
}
}
}
// must generate new ModelClient during every chat
export function ModelClientFactory(
model: Model,
provider: Provider,
modelSettings: ModelSettings,
) {
const providerClient = new ProviderClient(provider);
return new ModelClient(model, modelSettings, providerClient);
}
export function getFiltertModels(
models: readonly Model[],
customModels: string,
) {
const modelTable: Record<string, Model> = {};
// default models
models.forEach((m) => {
modelTable[m.name] = m;
});
// server custom models
customModels
.split(",")
.filter((v) => !!v && v.length > 0)
.forEach((m) => {
const available = !m.startsWith("-");
const nameConfig =
m.startsWith("+") || m.startsWith("-") ? m.slice(1) : m;
const [name, displayName] = nameConfig.split("=");
// enable or disable all models
if (name === "all") {
Object.values(modelTable).forEach(
(model) => (model.available = available),
);
} else {
modelTable[name] = {
...modelTable[name],
displayName,
available,
};
}
});
return modelTable;
}

View File

@@ -1,256 +0,0 @@
import {
IProviderTemplate,
InternalChatHandlers,
Model,
ModelTemplate,
ProviderTemplate,
StandChatReponseMessage,
StandChatRequestPayload,
isSameOrigin,
modelNameRequestHeader,
} from "../common";
import * as ProviderTemplates from "@/app/client/providers";
import { nanoid } from "nanoid";
export type ProviderTemplateName =
(typeof ProviderTemplates)[keyof typeof ProviderTemplates]["prototype"]["name"];
export interface Provider<
Providerconfig extends Record<string, any> = Record<string, any>,
> {
name: string; // id of provider
isActive: boolean;
providerTemplateName: ProviderTemplateName;
providerConfig: Providerconfig;
isDefault: boolean; // Not allow to modify models of default provider
updated: boolean; // provider initial is finished
displayName: string;
models: Model[];
}
const providerTemplates = Object.values(ProviderTemplates).reduce(
(r, t) => ({
...r,
[t.prototype.name]: new t(),
}),
{} as Record<ProviderTemplateName, ProviderTemplate>,
);
export class ProviderClient {
providerTemplate: IProviderTemplate<any, any, any>;
genFetch: (modelName: string) => typeof window.fetch;
static ProviderTemplates = providerTemplates;
static getAllProviderTemplates = () => {
return Object.values(providerTemplates).reduce(
(r, t) => ({
...r,
[t.name]: t,
}),
{} as Record<ProviderTemplateName, ProviderTemplate>,
);
};
static getProviderTemplateMetaList = () => {
return Object.values(providerTemplates).map((t) => ({
...t.providerMeta,
name: t.name,
}));
};
constructor(private provider: Provider) {
const { providerTemplateName } = provider;
this.providerTemplate = this.getProviderTemplate(providerTemplateName);
this.genFetch =
(modelName: string) =>
(...args) => {
const req = new Request(...args);
const headers: Record<string, any> = {
...req.headers,
};
if (isSameOrigin(req.url)) {
headers[modelNameRequestHeader] = modelName;
}
return window.fetch(req.url, {
method: req.method,
keepalive: req.keepalive,
headers,
body: req.body,
redirect: req.redirect,
integrity: req.integrity,
signal: req.signal,
credentials: req.credentials,
mode: req.mode,
referrer: req.referrer,
referrerPolicy: req.referrerPolicy,
});
};
}
private getProviderTemplate(providerTemplateName: string) {
const providerTemplate = Object.values(providerTemplates).find(
(template) => template.name === providerTemplateName,
);
return providerTemplate || providerTemplates.openai;
}
private getModelConfig(modelName: string) {
const { models } = this.provider;
return (
models.find((m) => m.name === modelName) ||
models.find((m) => m.isDefaultSelected)
);
}
getAvailableModels() {
return Promise.resolve(
this.providerTemplate.getAvailableModels?.(this.provider.providerConfig),
)
.then((res) => {
const { defaultModels } = this.providerTemplate;
const availableModelsSet = new Set(
(res ?? defaultModels).map((o) => o.name),
);
return defaultModels.filter((m) => availableModelsSet.has(m.name));
})
.catch(() => {
return this.providerTemplate.defaultModels;
});
}
async chat(
payload: StandChatRequestPayload,
): Promise<StandChatReponseMessage> {
return this.providerTemplate.chat(
{
...payload,
stream: false,
isVisionModel: this.getModelConfig(payload.model)?.isVisionModel,
providerConfig: this.provider.providerConfig,
},
this.genFetch(payload.model),
);
}
streamChat(payload: StandChatRequestPayload, handlers: InternalChatHandlers) {
let responseText = "";
let remainText = "";
const timer = this.providerTemplate.streamChat(
{
...payload,
stream: true,
isVisionModel: this.getModelConfig(payload.model)?.isVisionModel,
providerConfig: this.provider.providerConfig,
},
{
onProgress: (chunk) => {
remainText += chunk;
},
onError: (err) => {
handlers.onError(err);
},
onFinish: () => {},
onFlash: (message: string) => {
handlers.onFinish(message);
},
},
this.genFetch(payload.model),
);
timer.signal.onabort = () => {
const message = responseText + remainText;
remainText = "";
handlers.onFinish(message);
};
const animateResponseText = () => {
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);
handlers.onProgress(responseText, fetchText);
}
requestAnimationFrame(animateResponseText);
};
// start animaion
animateResponseText();
return timer;
}
}
type Params = Omit<Provider, "providerTemplateName" | "name" | "isDefault">;
function createProvider(
provider: ProviderTemplateName,
isDefault: true,
): Provider;
function createProvider(provider: ProviderTemplate, isDefault: true): Provider;
function createProvider(
provider: ProviderTemplateName,
isDefault: false,
params: Params,
): Provider;
function createProvider(
provider: ProviderTemplate,
isDefault: false,
params: Params,
): Provider;
function createProvider(
provider: ProviderTemplate | ProviderTemplateName,
isDefault: boolean,
params?: Params,
): Provider {
let providerTemplate: ProviderTemplate;
if (typeof provider === "string") {
providerTemplate = ProviderClient.getAllProviderTemplates()[provider];
} else {
providerTemplate = provider;
}
const name = `${providerTemplate.name}__${nanoid()}`;
const {
displayName = providerTemplate.providerMeta.displayName,
models = providerTemplate.defaultModels.map((m) =>
createModelFromModelTemplate(m, providerTemplate, name),
),
providerConfig,
} = params ?? {};
return {
name,
displayName,
isActive: true,
models,
providerTemplateName: providerTemplate.name,
providerConfig: isDefault ? {} : providerConfig!,
isDefault,
updated: true,
};
}
function createModelFromModelTemplate(
m: ModelTemplate,
p: ProviderTemplate,
providerName: string,
) {
return {
...m,
providerTemplateName: p.name,
providerName,
isActive: m.isDefaultActive,
available: true,
customized: false,
};
}
export { createProvider };

View File

@@ -1,25 +0,0 @@
import { getClientConfig } from "@/app/config/client";
if (!(window.fetch as any).__hijacked__) {
let _fetch = window.fetch;
function fetch(...args: Parameters<typeof _fetch>) {
const { isApp } = getClientConfig() || {};
let fetch: typeof _fetch = _fetch;
if (isApp) {
try {
fetch = window.__TAURI__!.http.fetch;
} catch (e) {
fetch = _fetch;
}
}
return fetch(...args);
}
fetch.__hijacked__ = true;
window.fetch = fetch;
}

View File

@@ -1,3 +0,0 @@
export * from "./core";
export * from "./providers";

View File

@@ -1,415 +0,0 @@
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 {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import Locale from "../../locales";
import { prettyObject } from "@/app/utils/format";
import { getMessageTextContent, isVisionModel } from "@/app/utils";
export type MultiBlockContent = {
type: "image" | "text";
source?: {
type: string;
media_type: string;
data: string;
};
text?: string;
};
export type AnthropicMessage = {
role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
content: string | MultiBlockContent[];
};
export interface AnthropicChatRequest {
model: string; // The model that will complete your prompt.
messages: AnthropicMessage[]; // The prompt that you want Claude to complete.
max_tokens: number; // The maximum number of tokens to generate before stopping.
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
temperature?: number; // Amount of randomness injected into the response.
top_p?: number; // Use nucleus sampling.
top_k?: number; // Only sample from the top K options for each subsequent token.
metadata?: object; // An object describing metadata about the request.
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
}
export interface ChatRequest {
model: string; // The model that will complete your prompt.
prompt: string; // The prompt that you want Claude to complete.
max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping.
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
temperature?: number; // Amount of randomness injected into the response.
top_p?: number; // Use nucleus sampling.
top_k?: number; // Only sample from the top K options for each subsequent token.
metadata?: object; // An object describing metadata about the request.
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
}
export interface ChatResponse {
completion: string;
stop_reason: "stop_sequence" | "max_tokens";
model: string;
}
export type ChatStreamResponse = ChatResponse & {
stop?: string;
log_id: string;
};
const ClaudeMapper = {
assistant: "assistant",
user: "user",
system: "user",
} as const;
const keys = ["claude-2, claude-instant-1"];
export class ClaudeApi implements LLMApi {
extractMessage(res: any) {
console.log("[Response] claude response: ", res);
return res?.content?.[0]?.text;
}
async chat(options: ChatOptions): Promise<void> {
const visionModel = isVisionModel(options.config.model);
const accessStore = useAccessStore.getState();
const shouldStream = !!options.config.stream;
const modelConfig = {
...useAppConfig.getState().modelConfig,
...useChatStore.getState().currentSession().mask.modelConfig,
...{
model: options.config.model,
},
};
const messages = [...options.messages];
const keys = ["system", "user"];
// roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages
for (let i = 0; i < messages.length - 1; i++) {
const message = messages[i];
const nextMessage = messages[i + 1];
if (keys.includes(message.role) && keys.includes(nextMessage.role)) {
messages[i] = [
message,
{
role: "assistant",
content: ";",
},
] as any;
}
}
const prompt = messages
.flat()
.filter((v) => {
if (!v.content) return false;
if (typeof v.content === "string" && !v.content.trim()) return false;
return true;
})
.map((v) => {
const { role, content } = v;
const insideRole = ClaudeMapper[role] ?? "user";
if (!visionModel || typeof content === "string") {
return {
role: insideRole,
content: getMessageTextContent(v),
};
}
return {
role: insideRole,
content: content
.filter((v) => v.image_url || v.text)
.map(({ type, text, image_url }) => {
if (type === "text") {
return {
type,
text: text!,
};
}
const { url = "" } = image_url || {};
const colonIndex = url.indexOf(":");
const semicolonIndex = url.indexOf(";");
const comma = url.indexOf(",");
const mimeType = url.slice(colonIndex + 1, semicolonIndex);
const encodeType = url.slice(semicolonIndex + 1, comma);
const data = url.slice(comma + 1);
return {
type: "image" as const,
source: {
type: encodeType,
media_type: mimeType,
data,
},
};
}),
};
});
if (prompt[0]?.role === "assistant") {
prompt.unshift({
role: "user",
content: ";",
});
}
const requestBody: AnthropicChatRequest = {
messages: prompt,
stream: shouldStream,
model: modelConfig.model,
max_tokens: modelConfig.max_tokens,
temperature: modelConfig.temperature,
top_p: modelConfig.top_p,
// top_k: modelConfig.top_k,
top_k: 5,
};
const path = this.path(Anthropic.ChatPath);
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;
};
try {
chunkJson = JSON.parse(msg.data);
} catch (e) {
console.error("[Response] parse error", msg.data);
}
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);
}
} else {
try {
controller.signal.onabort = () => options.onFinish("");
const res = await fetch(path, payload);
const resJson = await res.json();
const message = this.extractMessage(resJson);
options.onFinish(message);
} catch (e) {
console.error("failed to chat", e);
options.onError?.(e as Error);
}
}
}
async usage() {
return {
used: 0,
total: 0,
};
}
async models() {
// const provider = {
// id: "anthropic",
// providerName: "Anthropic",
// providerType: "anthropic",
// };
return [
// {
// name: "claude-instant-1.2",
// available: true,
// provider,
// },
// {
// name: "claude-2.0",
// available: true,
// provider,
// },
// {
// name: "claude-2.1",
// available: true,
// provider,
// },
// {
// name: "claude-3-opus-20240229",
// available: true,
// provider,
// },
// {
// name: "claude-3-sonnet-20240229",
// available: true,
// provider,
// },
// {
// name: "claude-3-haiku-20240307",
// available: true,
// provider,
// },
];
}
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl: string = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.anthropicUrl;
}
// if endpoint is empty, use default endpoint
if (baseUrl.trim().length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp
? DEFAULT_API_HOST + "/api/proxy/anthropic"
: ApiPath.Anthropic;
}
if (!baseUrl.startsWith("http") && !baseUrl.startsWith("/api")) {
baseUrl = "https://" + baseUrl;
}
baseUrl = trimEnd(baseUrl, "/");
return `${baseUrl}/${path}`;
}
}
function trimEnd(s: string, end = " ") {
if (end.length === 0) return s;
while (s.endsWith(end)) {
s = s.slice(0, -end.length);
}
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;
}

View File

@@ -21,10 +21,11 @@ export class GeminiProApi implements LLMApi {
}
async chat(options: ChatOptions): Promise<void> {
// const apiClient = this;
const visionModel = isVisionModel(options.config.model);
let multimodal = false;
const messages = options.messages.map((v) => {
let parts: any[] = [{ text: getMessageTextContent(v) }];
if (isVisionModel(options.config.model)) {
if (visionModel) {
const images = getMessageImages(v);
if (images.length > 0) {
multimodal = true;
@@ -103,27 +104,24 @@ export class GeminiProApi implements LLMApi {
};
const accessStore = useAccessStore.getState();
let baseUrl = "";
if (accessStore.useCustomConfig) {
baseUrl = accessStore.googleUrl;
}
let baseUrl = accessStore.googleUrl;
const isApp = !!getClientConfig()?.isApp;
let shouldStream = !!options.config.stream;
const controller = new AbortController();
options.onController?.(controller);
try {
let googleChatPath = visionModel
? Google.VisionChatPath
: Google.ChatPath;
let chatPath = this.path(googleChatPath);
// let baseUrl = accessStore.googleUrl;
if (!baseUrl) {
baseUrl = isApp
? DEFAULT_API_HOST +
"/api/proxy/google/" +
Google.ChatPath(modelConfig.model)
: this.path(Google.ChatPath(modelConfig.model));
? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath
: chatPath;
}
if (isApp) {
@@ -141,7 +139,6 @@ export class GeminiProApi implements LLMApi {
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
if (shouldStream) {
let responseText = "";
let remainText = "";

View File

@@ -40,44 +40,22 @@ export interface OpenAIListModelResponse {
}>;
}
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 ChatGPTApi implements LLMApi {
private disableListModels = true;
path(path: string): string {
const accessStore = useAccessStore.getState();
let baseUrl = "";
const isAzure = accessStore.provider === ServiceProvider.Azure;
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 (isAzure && !accessStore.isValidAzure()) {
throw Error(
"incomplete azure config, please check it in your settings page",
);
}
let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
if (baseUrl.length === 0) {
const isApp = !!getClientConfig()?.isApp;
baseUrl = isApp
@@ -92,6 +70,10 @@ export class ChatGPTApi implements LLMApi {
baseUrl = "https://" + baseUrl;
}
if (isAzure) {
path = makeAzurePath(path, accessStore.azureApiVersion);
}
console.log("[Proxy Endpoint] ", baseUrl, path);
return [baseUrl, path].join("/");
@@ -116,7 +98,7 @@ export class ChatGPTApi implements LLMApi {
},
};
const requestPayload: RequestPayload = {
const requestPayload = {
messages,
stream: options.config.stream,
model: modelConfig.model,
@@ -129,8 +111,13 @@ export class ChatGPTApi implements LLMApi {
};
// add max_tokens to vision model
if (visionModel && modelConfig.model.includes("preview")) {
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
if (visionModel) {
Object.defineProperty(requestPayload, "max_tokens", {
enumerable: true,
configurable: true,
writable: true,
value: modelConfig.max_tokens,
});
}
console.log("[Request] openai payload: ", requestPayload);
@@ -164,9 +151,6 @@ export class ChatGPTApi implements LLMApi {
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;
}
@@ -241,31 +225,19 @@ export class ChatGPTApi implements LLMApi {
}
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;
const json = JSON.parse(text) as {
choices: Array<{
delta: {
content: string;
};
}>;
};
const delta = json.choices[0]?.delta?.content;
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);
console.error("[Request] parse error", text);
}
},
onclose() {

View File

@@ -1,131 +0,0 @@
import { SettingItem } from "../../common";
import Locale from "./locale";
export type SettingKeys =
| "anthropicUrl"
| "anthropicApiKey"
| "anthropicApiVersion";
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
export const AnthropicMetas = {
ChatPath: "v1/messages",
ExampleEndpoint: ANTHROPIC_BASE_URL,
Vision: "2023-06-01",
};
export const ClaudeMapper = {
assistant: "assistant",
user: "user",
system: "user",
} as const;
export const modelConfigs = [
{
name: "claude-instant-1.2",
displayName: "claude-instant-1.2",
isVision: false,
isDefaultActive: true,
isDefaultSelected: true,
},
{
name: "claude-2.0",
displayName: "claude-2.0",
isVision: false,
isDefaultActive: true,
isDefaultSelected: false,
},
{
name: "claude-2.1",
displayName: "claude-2.1",
isVision: false,
isDefaultActive: true,
isDefaultSelected: false,
},
{
name: "claude-3-sonnet-20240229",
displayName: "claude-3-sonnet-20240229",
isVision: true,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "claude-3-opus-20240229",
displayName: "claude-3-opus-20240229",
isVision: true,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "claude-3-haiku-20240307",
displayName: "claude-3-haiku-20240307",
isVision: true,
isDefaultActive: true,
isDefaultSelected: false,
},
];
export const preferredRegion: string | string[] = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
export const settingItems: (
defaultEndpoint: string,
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
{
name: "anthropicUrl",
title: Locale.Endpoint.Title,
description: Locale.Endpoint.SubTitle + AnthropicMetas.ExampleEndpoint,
placeholder: AnthropicMetas.ExampleEndpoint,
type: "input",
defaultValue: defaultEndpoint,
validators: [
"required",
async (v: any) => {
if (typeof v === "string" && !v.startsWith(defaultEndpoint)) {
try {
new URL(v);
} catch (e) {
return Locale.Endpoint.Error.IllegalURL;
}
}
if (typeof v === "string" && v.endsWith("/")) {
return Locale.Endpoint.Error.EndWithBackslash;
}
},
],
},
{
name: "anthropicApiKey",
title: Locale.ApiKey.Title,
description: Locale.ApiKey.SubTitle,
placeholder: Locale.ApiKey.Placeholder,
type: "input",
inputType: "password",
// validators: ["required"],
},
{
name: "anthropicApiVersion",
title: Locale.ApiVerion.Title,
description: Locale.ApiVerion.SubTitle,
defaultValue: AnthropicMetas.Vision,
type: "input",
// validators: ["required"],
},
];

View File

@@ -1,356 +0,0 @@
import {
ANTHROPIC_BASE_URL,
AnthropicMetas,
ClaudeMapper,
SettingKeys,
modelConfigs,
preferredRegion,
settingItems,
} from "./config";
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ServerConfig,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import Locale from "@/app/locales";
import {
prettyObject,
getTimer,
authHeaderName,
auth,
parseResp,
formatMessage,
} from "./utils";
import { cloneDeep } from "lodash-es";
import { NextRequest, NextResponse } from "next/server";
export type AnthropicProviderSettingKeys = SettingKeys;
export type MultiBlockContent = {
type: "image" | "text";
source?: {
type: string;
media_type: string;
data: string;
};
text?: string;
};
export type AnthropicMessage = {
role: (typeof ClaudeMapper)[keyof typeof ClaudeMapper];
content: string | MultiBlockContent[];
};
export interface AnthropicChatRequest {
model: string; // The model that will complete your prompt.
messages: AnthropicMessage[]; // The prompt that you want Claude to complete.
max_tokens: number; // The maximum number of tokens to generate before stopping.
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
temperature?: number; // Amount of randomness injected into the response.
top_p?: number; // Use nucleus sampling.
top_k?: number; // Only sample from the top K options for each subsequent token.
metadata?: object; // An object describing metadata about the request.
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
}
export interface ChatRequest {
model: string; // The model that will complete your prompt.
prompt: string; // The prompt that you want Claude to complete.
max_tokens_to_sample: number; // The maximum number of tokens to generate before stopping.
stop_sequences?: string[]; // Sequences that will cause the model to stop generating completion text.
temperature?: number; // Amount of randomness injected into the response.
top_p?: number; // Use nucleus sampling.
top_k?: number; // Only sample from the top K options for each subsequent token.
metadata?: object; // An object describing metadata about the request.
stream?: boolean; // Whether to incrementally stream the response using server-sent events.
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"anthropic",
typeof AnthropicMetas
>;
export default class AnthropicProvider implements ProviderTemplate {
apiRouteRootName = "/api/provider/anthropic" as const;
allowedApiMethods: ["GET", "POST"] = ["GET", "POST"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "anthropic" as const;
metas = AnthropicMetas;
providerMeta = {
displayName: "Anthropic",
settingItems: settingItems(
`${this.apiRouteRootName}//${AnthropicMetas.ChatPath}`,
),
};
defaultModels = modelConfigs;
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages: outsideMessages,
model,
stream,
modelConfig,
providerConfig,
} = payload;
const { anthropicApiKey, anthropicApiVersion, anthropicUrl } =
providerConfig;
const { temperature, top_p, max_tokens } = modelConfig;
const keys = ["system", "user"];
// roles must alternate between "user" and "assistant" in claude, so add a fake assistant message between two user messages
const messages = cloneDeep(outsideMessages);
for (let i = 0; i < messages.length - 1; i++) {
const message = messages[i];
const nextMessage = messages[i + 1];
if (keys.includes(message.role) && keys.includes(nextMessage.role)) {
messages[i] = [
message,
{
role: "assistant",
content: ";",
},
] as any;
}
}
const prompt = formatMessage(messages, payload.isVisionModel);
const requestBody: AnthropicChatRequest = {
messages: prompt,
stream,
model,
max_tokens,
temperature,
top_p,
top_k: 5,
};
return {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
[authHeaderName]: anthropicApiKey ?? "",
"anthropic-version": anthropicApiVersion ?? "",
},
body: JSON.stringify(requestBody),
method: "POST",
url: anthropicUrl!,
};
}
private async request(req: NextRequest, serverConfig: ServerConfig) {
const controller = new AbortController();
const authValue = req.headers.get(authHeaderName) ?? "";
const path = `${req.nextUrl.pathname}`.replaceAll(
this.apiRouteRootName,
"",
);
const baseUrl = serverConfig.anthropicUrl || ANTHROPIC_BASE_URL;
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",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
"anthropic-version":
req.headers.get("anthropic-version") ||
serverConfig.anthropicApiVersion ||
AnthropicMetas.Vision,
},
method: req.method,
body: req.body,
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
console.log("[Anthropic request]", fetchOptions.headers, req.method);
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 NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
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) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = [AnthropicMetas.ChatPath];
if (!ALLOWD_PATH.includes(subpath)) {
console.log("[Anthropic Route] forbidden path ", subpath);
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.request(req, config);
return response;
} catch (e) {
console.error("[Anthropic] ", e);
return NextResponse.json(prettyObject(e));
}
};
}

View File

@@ -1,134 +0,0 @@
import { getLocaleText } from "../../common";
export default getLocaleText<
{
ApiKey: {
Title: string;
SubTitle: string;
Placeholder: string;
};
Endpoint: {
Title: string;
SubTitle: string;
Error: {
EndWithBackslash: string;
IllegalURL: string;
};
};
ApiVerion: {
Title: string;
SubTitle: string;
};
},
"en"
>(
{
cn: {
ApiKey: {
Title: "接口密钥",
SubTitle: "使用自定义 Anthropic Key 绕过密码访问限制",
Placeholder: "Anthropic API Key",
},
Endpoint: {
Title: "接口地址",
SubTitle: "样例:",
Error: {
EndWithBackslash: "不能以「/」结尾",
IllegalURL: "请输入一个完整可用的url",
},
},
ApiVerion: {
Title: "接口版本 (claude api version)",
SubTitle: "选择一个特定的 API 版本输入",
},
},
en: {
ApiKey: {
Title: "Anthropic API Key",
SubTitle:
"Use a custom Anthropic Key to bypass password access restrictions",
Placeholder: "Anthropic API Key",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "Example:",
Error: {
EndWithBackslash: "Cannot end with '/'",
IllegalURL: "Please enter a complete available url",
},
},
ApiVerion: {
Title: "API Version (claude api version)",
SubTitle: "Select and input a specific API version",
},
},
pt: {
ApiKey: {
Title: "Chave API Anthropic",
SubTitle: "Verifique sua chave API do console Anthropic",
Placeholder: "Chave API Anthropic",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "Exemplo: ",
Error: {
EndWithBackslash: "Não é possível terminar com '/'",
IllegalURL: "Insira um URL completo disponível",
},
},
ApiVerion: {
Title: "Versão API (Versão api claude)",
SubTitle: "Verifique sua versão API do console Anthropic",
},
},
sk: {
ApiKey: {
Title: "API kľúč Anthropic",
SubTitle: "Skontrolujte svoj API kľúč v Anthropic konzole",
Placeholder: "API kľúč Anthropic",
},
Endpoint: {
Title: "Adresa koncového bodu",
SubTitle: "Príklad:",
Error: {
EndWithBackslash: "Nemôže končiť znakom „/“",
IllegalURL: "Zadajte úplnú dostupnú adresu URL",
},
},
ApiVerion: {
Title: "Verzia API (claude verzia API)",
SubTitle: "Vyberte špecifickú verziu časti",
},
},
tw: {
ApiKey: {
Title: "API 金鑰",
SubTitle: "從 Anthropic AI 取得您的 API 金鑰",
Placeholder: "Anthropic API Key",
},
Endpoint: {
Title: "終端地址",
SubTitle: "範例:",
Error: {
EndWithBackslash: "不能以「/」結尾",
IllegalURL: "請輸入一個完整可用的url",
},
},
ApiVerion: {
Title: "API 版本 (claude api version)",
SubTitle: "選擇一個特定的 API 版本輸入",
},
},
},
"en",
);

View File

@@ -1,151 +0,0 @@
import { NextRequest } from "next/server";
import {
RequestMessage,
ServerConfig,
getIP,
getMessageTextContent,
} from "../../common";
import { ClaudeMapper } from "./config";
export const REQUEST_TIMEOUT_MS = 60000;
export const authHeaderName = "x-api-key";
export function trimEnd(s: string, end = " ") {
if (end.length === 0) return s;
while (s.endsWith(end)) {
s = s.slice(0, -end.length);
}
return s;
}
export function bearer(value: string) {
return `Bearer ${value.trim()}`;
}
export function prettyObject(msg: any) {
const obj = msg;
if (typeof msg !== "string") {
msg = JSON.stringify(msg, null, " ");
}
if (msg === "{}") {
return obj.toString();
}
if (msg.startsWith("```json")) {
return msg;
}
return ["```json", msg, "```"].join("\n");
}
export function getTimer() {
const controller = new AbortController();
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
return {
...controller,
clear: () => {
clearTimeout(requestTimeoutId);
},
};
}
export function auth(req: NextRequest, serverConfig: ServerConfig) {
const apiKey = req.headers.get(authHeaderName);
console.log("[User IP] ", getIP(req));
console.log("[Time] ", new Date().toLocaleString());
if (serverConfig.hideUserApiKey && apiKey) {
return {
error: true,
message: "you are not allowed to access with your own api key",
};
}
if (apiKey) {
console.log("[Auth] use user api key");
return {
error: false,
};
}
// if user does not provide an api key, inject system api key
const systemApiKey = serverConfig.anthropicApiKey;
if (systemApiKey) {
console.log("[Auth] use system api key");
req.headers.set(authHeaderName, systemApiKey);
} else {
console.log("[Auth] admin did not provide an api key");
}
return {
error: false,
};
}
export function parseResp(res: any) {
return {
message: res?.content?.[0]?.text ?? "",
};
}
export function formatMessage(
messages: RequestMessage[],
isVisionModel?: boolean,
) {
return messages
.flat()
.filter((v) => {
if (!v.content) return false;
if (typeof v.content === "string" && !v.content.trim()) return false;
return true;
})
.map((v) => {
const { role, content } = v;
const insideRole = ClaudeMapper[role] ?? "user";
if (!isVisionModel || typeof content === "string") {
return {
role: insideRole,
content: getMessageTextContent(v),
};
}
return {
role: insideRole,
content: content
.filter((v) => v.image_url || v.text)
.map(({ type, text, image_url }) => {
if (type === "text") {
return {
type,
text: text!,
};
}
const { url = "" } = image_url || {};
const colonIndex = url.indexOf(":");
const semicolonIndex = url.indexOf(";");
const comma = url.indexOf(",");
const mimeType = url.slice(colonIndex + 1, semicolonIndex);
const encodeType = url.slice(semicolonIndex + 1, comma);
const data = url.slice(comma + 1);
return {
type: "image" as const,
source: {
type: encodeType,
media_type: mimeType,
data,
},
};
}),
};
});
}

View File

@@ -1,79 +0,0 @@
import Locale from "./locale";
import { SettingItem } from "../../common";
import { modelConfigs as openaiModelConfigs } from "../openai/config";
export const AzureMetas = {
ExampleEndpoint: "https://{resource-url}/openai/deployments/{deploy-id}",
ChatPath: "chat/completions",
ListModelPath: "v1/models",
};
export type SettingKeys = "azureUrl" | "azureApiKey" | "azureApiVersion";
export const preferredRegion: string | string[] = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
export const modelConfigs = openaiModelConfigs;
export const settingItems: (
defaultEndpoint: string,
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
{
name: "azureUrl",
title: Locale.Endpoint.Title,
description: Locale.Endpoint.SubTitle + AzureMetas.ExampleEndpoint,
placeholder: AzureMetas.ExampleEndpoint,
type: "input",
defaultValue: defaultEndpoint,
validators: [
async (v: any) => {
if (typeof v === "string") {
try {
new URL(v);
} catch (e) {
return Locale.Endpoint.Error.IllegalURL;
}
}
if (typeof v === "string" && v.endsWith("/")) {
return Locale.Endpoint.Error.EndWithBackslash;
}
},
"required",
],
},
{
name: "azureApiKey",
title: Locale.ApiKey.Title,
description: Locale.ApiKey.SubTitle,
placeholder: Locale.ApiKey.Placeholder,
type: "input",
inputType: "password",
validators: ["required"],
},
{
name: "azureApiVersion",
title: Locale.ApiVerion.Title,
description: Locale.ApiVerion.SubTitle,
placeholder: "2023-08-01-preview",
type: "input",
validators: ["required"],
},
];

View File

@@ -1,408 +0,0 @@
import {
settingItems,
SettingKeys,
modelConfigs,
AzureMetas,
preferredRegion,
} from "./config";
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ModelInfo,
getMessageTextContent,
ServerConfig,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import Locale from "@/app/locales";
import {
auth,
authHeaderName,
getHeaders,
getTimer,
makeAzurePath,
parseResp,
prettyObject,
} from "./utils";
import { NextRequest, NextResponse } from "next/server";
export type AzureProviderSettingKeys = SettingKeys;
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string | MultimodalContent[];
}
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;
}
interface ModelList {
object: "list";
data: Array<{
capabilities: {
fine_tune: boolean;
inference: boolean;
completion: boolean;
chat_completion: boolean;
embeddings: boolean;
};
lifecycle_status: "generally-available";
id: string;
created_at: number;
object: "model";
}>;
}
interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof AzureMetas
>;
export default class Azure implements ProviderTemplate {
apiRouteRootName: "/api/provider/azure" = "/api/provider/azure";
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["POST", "GET"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "azure" as const;
metas = AzureMetas;
defaultModels = modelConfigs;
providerMeta = {
displayName: "Azure",
settingItems: settingItems(
`${this.apiRouteRootName}/${AzureMetas.ChatPath}`,
),
};
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages,
isVisionModel,
model,
stream,
modelConfig: {
temperature,
presence_penalty,
frequency_penalty,
top_p,
max_tokens,
},
providerConfig: { azureUrl, azureApiVersion },
} = payload;
const openAiMessages = messages.map((v) => ({
role: v.role,
content: isVisionModel ? v.content : getMessageTextContent(v),
}));
const requestPayload: RequestPayload = {
messages: openAiMessages,
stream,
model,
temperature,
presence_penalty,
frequency_penalty,
top_p,
};
// add max_tokens to vision model
if (isVisionModel) {
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
return {
headers: getHeaders(payload.providerConfig.azureApiKey),
body: JSON.stringify(requestPayload),
method: "POST",
url: `${azureUrl}?api-version=${azureApiVersion!}`,
};
}
private async requestAzure(req: NextRequest, serverConfig: ServerConfig) {
const controller = new AbortController();
const authValue =
req.headers
.get("Authorization")
?.trim()
.replaceAll("Bearer ", "")
.trim() ?? "";
const { azureUrl, azureApiVersion } = serverConfig;
if (!azureUrl) {
return NextResponse.json({
error: true,
message: `missing AZURE_URL in server env vars`,
});
}
if (!azureApiVersion) {
return NextResponse.json({
error: true,
message: `missing AZURE_API_VERSION in server env vars`,
});
}
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
this.apiRouteRootName,
"",
);
path = makeAzurePath(path, azureApiVersion);
console.log("[Proxy] ", path);
console.log("[Base Url]", azureUrl);
const fetchUrl = `${azureUrl}/${path}`;
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
},
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");
// 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 NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
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) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
async getAvailableModels(
providerConfig: Record<SettingKeys, string>,
): Promise<ModelInfo[]> {
const { azureApiKey, azureUrl } = providerConfig;
const res = await fetch(`${azureUrl}/${AzureMetas.ListModelPath}`, {
headers: {
Authorization: `Bearer ${azureApiKey}`,
},
method: "GET",
});
const data: ModelList = await res.json();
return data.data.map((o) => ({
name: o.id,
}));
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = [AzureMetas.ChatPath];
if (!ALLOWD_PATH.includes(subpath)) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.requestAzure(req, config);
return response;
} catch (e) {
return NextResponse.json(prettyObject(e));
}
};
}

View File

@@ -1,133 +0,0 @@
import { getLocaleText } from "../../common";
export default getLocaleText<
{
ApiKey: {
Title: string;
SubTitle: string;
Placeholder: string;
};
Endpoint: {
Title: string;
SubTitle: string;
Error: {
EndWithBackslash: string;
IllegalURL: string;
};
};
ApiVerion: {
Title: string;
SubTitle: string;
};
},
"en"
>(
{
cn: {
ApiKey: {
Title: "接口密钥",
SubTitle: "使用自定义 Azure Key 绕过密码访问限制",
Placeholder: "Azure API Key",
},
Endpoint: {
Title: "接口地址",
SubTitle: "样例:",
Error: {
EndWithBackslash: "不能以「/」结尾",
IllegalURL: "请输入一个完整可用的url",
},
},
ApiVerion: {
Title: "接口版本 (azure api version)",
SubTitle: "选择指定的部分版本",
},
},
en: {
ApiKey: {
Title: "Azure Api Key",
SubTitle: "Check your api key from Azure console",
Placeholder: "Azure Api Key",
},
Endpoint: {
Title: "Azure Endpoint",
SubTitle: "Example: ",
Error: {
EndWithBackslash: "Cannot end with '/'",
IllegalURL: "Please enter a complete available url",
},
},
ApiVerion: {
Title: "Azure Api Version",
SubTitle: "Check your api version from azure console",
},
},
pt: {
ApiKey: {
Title: "Chave API Azure",
SubTitle: "Verifique sua chave API do console Azure",
Placeholder: "Chave API Azure",
},
Endpoint: {
Title: "Endpoint Azure",
SubTitle: "Exemplo: ",
Error: {
EndWithBackslash: "Não é possível terminar com '/'",
IllegalURL: "Insira um URL completo disponível",
},
},
ApiVerion: {
Title: "Versão API Azure",
SubTitle: "Verifique sua versão API do console Azure",
},
},
sk: {
ApiKey: {
Title: "API kľúč Azure",
SubTitle: "Skontrolujte svoj API kľúč v Azure konzole",
Placeholder: "API kľúč Azure",
},
Endpoint: {
Title: "Koncový bod Azure",
SubTitle: "Príklad: ",
Error: {
EndWithBackslash: "Nemôže končiť znakom „/“",
IllegalURL: "Zadajte úplnú dostupnú adresu URL",
},
},
ApiVerion: {
Title: "Verzia API Azure",
SubTitle: "Skontrolujte svoju verziu API v Azure konzole",
},
},
tw: {
ApiKey: {
Title: "介面金鑰",
SubTitle: "使用自定義 Azure Key 繞過密碼存取限制",
Placeholder: "Azure API Key",
},
Endpoint: {
Title: "介面(Endpoint) 地址",
SubTitle: "樣例:",
Error: {
EndWithBackslash: "不能以「/」結尾",
IllegalURL: "請輸入一個完整可用的url",
},
},
ApiVerion: {
Title: "介面版本 (azure api version)",
SubTitle: "選擇指定的部分版本",
},
},
},
"en",
);

View File

@@ -1,110 +0,0 @@
import { NextRequest } from "next/server";
import { ServerConfig, getIP } from "../../common";
export const authHeaderName = "api-key";
export const REQUEST_TIMEOUT_MS = 60000;
export function getHeaders(azureApiKey?: string) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (validString(azureApiKey)) {
headers[authHeaderName] = makeBearer(azureApiKey);
}
return headers;
}
export function parseResp(res: any) {
return {
message: res.choices?.at(0)?.message?.content ?? "",
};
}
export function makeAzurePath(path: string, apiVersion: string) {
// should add api-key to query string
path += `${path.includes("?") ? "&" : "?"}api-version=${apiVersion}`;
return path;
}
export function prettyObject(msg: any) {
const obj = msg;
if (typeof msg !== "string") {
msg = JSON.stringify(msg, null, " ");
}
if (msg === "{}") {
return obj.toString();
}
if (msg.startsWith("```json")) {
return msg;
}
return ["```json", msg, "```"].join("\n");
}
export const makeBearer = (s: string) => `Bearer ${s.trim()}`;
export const validString = (x?: string): x is string =>
Boolean(x && x.length > 0);
export function parseApiKey(bearToken: string) {
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
return {
apiKey: token,
};
}
export function getTimer() {
const controller = new AbortController();
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
return {
...controller,
clear: () => {
clearTimeout(requestTimeoutId);
},
};
}
export function auth(req: NextRequest, serverConfig: ServerConfig) {
const authToken = req.headers.get(authHeaderName) ?? "";
const { hideUserApiKey, apiKey: systemApiKey } = serverConfig;
const { apiKey } = parseApiKey(authToken);
console.log("[User IP] ", getIP(req));
console.log("[Time] ", new Date().toLocaleString());
if (hideUserApiKey && apiKey) {
return {
error: true,
message: "you are not allowed to access with your own api key",
};
}
if (apiKey) {
console.log("[Auth] use user api key");
return {
error: false,
};
}
if (systemApiKey) {
console.log("[Auth] use system api key");
req.headers.set("Authorization", `Bearer ${systemApiKey}`);
} else {
console.log("[Auth] admin did not provide an api key");
}
return {
error: false,
};
}

View File

@@ -1,95 +0,0 @@
import { SettingItem } from "../../common";
import Locale from "./locale";
export const preferredRegion: string | string[] = [
"bom1",
"cle1",
"cpt1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
export const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/";
export const GoogleMetas = {
ExampleEndpoint: GEMINI_BASE_URL,
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
};
export type SettingKeys = "googleUrl" | "googleApiKey" | "googleApiVersion";
export const modelConfigs = [
{
name: "gemini-1.0-pro",
displayName: "gemini-1.0-pro",
isVision: false,
isDefaultActive: true,
isDefaultSelected: true,
},
{
name: "gemini-1.5-pro-latest",
displayName: "gemini-1.5-pro-latest",
isVision: true,
isDefaultActive: true,
isDefaultSelected: false,
},
{
name: "gemini-pro-vision",
displayName: "gemini-pro-vision",
isVision: true,
isDefaultActive: true,
isDefaultSelected: false,
},
];
export const settingItems: (
defaultEndpoint: string,
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
{
name: "googleUrl",
title: Locale.Endpoint.Title,
description: Locale.Endpoint.SubTitle + GoogleMetas.ExampleEndpoint,
placeholder: GoogleMetas.ExampleEndpoint,
type: "input",
defaultValue: defaultEndpoint,
validators: [
async (v: any) => {
if (typeof v === "string") {
try {
new URL(v);
} catch (e) {
return Locale.Endpoint.Error.IllegalURL;
}
}
if (typeof v === "string" && v.endsWith("/")) {
return Locale.Endpoint.Error.EndWithBackslash;
}
},
"required",
],
},
{
name: "googleApiKey",
title: Locale.ApiKey.Title,
description: Locale.ApiKey.SubTitle,
placeholder: Locale.ApiKey.Placeholder,
type: "input",
inputType: "password",
// validators: ["required"],
},
{
name: "googleApiVersion",
title: Locale.ApiVersion.Title,
description: Locale.ApiVersion.SubTitle,
placeholder: "2023-08-01-preview",
type: "input",
// validators: ["required"],
},
];

View File

@@ -1,353 +0,0 @@
import {
SettingKeys,
modelConfigs,
settingItems,
GoogleMetas,
GEMINI_BASE_URL,
preferredRegion,
} from "./config";
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ModelInfo,
StandChatReponseMessage,
getMessageTextContent,
getMessageImages,
} from "../../common";
import {
auth,
ensureProperEnding,
getTimer,
parseResp,
urlParamApikeyName,
} from "./utils";
import { NextResponse } from "next/server";
export type GoogleProviderSettingKeys = SettingKeys;
interface ModelList {
models: Array<{
name: string;
baseModelId: string;
version: string;
displayName: string;
description: string;
inputTokenLimit: number; // Integer
outputTokenLimit: number; // Integer
supportedGenerationMethods: [string];
temperature: number;
topP: number;
topK: number; // Integer
}>;
nextPageToken: string;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof GoogleMetas
>;
export default class GoogleProvider
implements IProviderTemplate<SettingKeys, "google", typeof GoogleMetas>
{
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["GET", "POST"];
runtime = "edge" as const;
apiRouteRootName: "/api/provider/google" = "/api/provider/google";
preferredRegion = preferredRegion;
name = "google" as const;
metas = GoogleMetas;
providerMeta = {
displayName: "Google",
settingItems: settingItems(this.apiRouteRootName),
};
defaultModels = modelConfigs;
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages,
isVisionModel,
model,
stream,
modelConfig,
providerConfig,
} = payload;
const { googleUrl, googleApiKey } = providerConfig;
const { temperature, top_p, max_tokens } = modelConfig;
const internalMessages = messages.map((v) => {
let parts: any[] = [{ text: getMessageTextContent(v) }];
if (isVisionModel) {
const images = getMessageImages(v);
if (images.length > 0) {
parts = parts.concat(
images.map((image) => {
const imageType = image.split(";")[0].split(":")[1];
const imageData = image.split(",")[1];
return {
inline_data: {
mime_type: imageType,
data: imageData,
},
};
}),
);
}
}
return {
role: v.role.replace("assistant", "model").replace("system", "user"),
parts: parts,
};
});
// google requires that role in neighboring messages must not be the same
for (let i = 0; i < internalMessages.length - 1; ) {
// Check if current and next item both have the role "model"
if (internalMessages[i].role === internalMessages[i + 1].role) {
// Concatenate the 'parts' of the current and next item
internalMessages[i].parts = internalMessages[i].parts.concat(
internalMessages[i + 1].parts,
);
// Remove the next item
internalMessages.splice(i + 1, 1);
} else {
// Move to the next item
i++;
}
}
const requestPayload = {
contents: internalMessages,
generationConfig: {
temperature,
maxOutputTokens: max_tokens,
topP: top_p,
},
safetySettings: [
{
category: "HARM_CATEGORY_HARASSMENT",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_HATE_SPEECH",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_ONLY_HIGH",
},
],
};
const baseUrl = `${googleUrl}/${GoogleMetas.ChatPath(
model,
)}?${urlParamApikeyName}=${googleApiKey}`;
return {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(requestPayload),
method: "POST",
url: stream
? baseUrl.replace("generateContent", "streamGenerateContent")
: baseUrl,
};
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
let existingTexts: string[] = [];
fetch(requestPayload.url, {
...requestPayload,
signal: timer.signal,
})
.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) {
handlers.onError(new Error(data[0].error.message));
} else {
handlers.onError(new Error("Request failed"));
}
} catch (_) {
handlers.onError(new Error("Request failed"));
}
}
console.log("Stream complete");
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);
},
[],
);
if (textArray.length > existingTexts.length) {
const deltaArray = textArray.slice(existingTexts.length);
existingTexts = textArray;
handlers.onProgress(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);
});
return timer;
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
): Promise<StandChatReponseMessage> {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
async getAvailableModels(
providerConfig: Record<SettingKeys, string>,
): Promise<ModelInfo[]> {
const { googleApiKey, googleUrl } = providerConfig;
const res = await fetch(`${googleUrl}/v1beta/models?key=${googleApiKey}`, {
headers: {
Authorization: `Bearer ${googleApiKey}`,
},
method: "GET",
});
const data: ModelList = await res.json();
return data.models;
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, serverConfig) => {
const { googleUrl = GEMINI_BASE_URL } = serverConfig;
const controller = new AbortController();
const path = `${req.nextUrl.pathname}`.replaceAll(
this.apiRouteRootName,
"",
);
console.log("[Proxy] ", path);
console.log("[Base Url]", googleUrl);
const authResult = auth(req, serverConfig);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
const fetchUrl = `${googleUrl}/${path}?key=${authResult.apiKey}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
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");
return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
};
}

View File

@@ -1,113 +0,0 @@
import { getLocaleText } from "../../common";
export default getLocaleText<
{
ApiKey: {
Title: string;
SubTitle: string;
Placeholder: string;
};
Endpoint: {
Title: string;
SubTitle: string;
Error: {
EndWithBackslash: string;
IllegalURL: string;
};
};
ApiVersion: {
Title: string;
SubTitle: string;
};
},
"en"
>(
{
cn: {
ApiKey: {
Title: "API 密钥",
SubTitle: "从 Google AI 获取您的 API 密钥",
Placeholder: "输入您的 Google AI Studio API 密钥",
},
Endpoint: {
Title: "终端地址",
SubTitle: "示例:",
Error: {
EndWithBackslash: "不能以「/」结尾",
IllegalURL: "请输入一个完整可用的url",
},
},
ApiVersion: {
Title: "API 版本(仅适用于 gemini-pro",
SubTitle: "选择一个特定的 API 版本",
},
},
en: {
ApiKey: {
Title: "API Key",
SubTitle: "Obtain your API Key from Google AI",
Placeholder: "Enter your Google AI Studio API Key",
},
Endpoint: {
Title: "Endpoint Address",
SubTitle: "Example:",
Error: {
EndWithBackslash: "Cannot end with '/'",
IllegalURL: "Please enter a complete available url",
},
},
ApiVersion: {
Title: "API Version (specific to gemini-pro)",
SubTitle: "Select a specific API version",
},
},
sk: {
ApiKey: {
Title: "API kľúč",
SubTitle:
"Obísť obmedzenia prístupu heslom pomocou vlastného API kľúča Google AI Studio",
Placeholder: "API kľúč Google AI Studio",
},
Endpoint: {
Title: "Adresa koncového bodu",
SubTitle: "Príklad:",
Error: {
EndWithBackslash: "Nemôže končiť znakom „/“",
IllegalURL: "Zadajte úplnú dostupnú adresu URL",
},
},
ApiVersion: {
Title: "Verzia API (gemini-pro verzia API)",
SubTitle: "Vyberte špecifickú verziu časti",
},
},
tw: {
ApiKey: {
Title: "API 金鑰",
SubTitle: "從 Google AI 取得您的 API 金鑰",
Placeholder: "輸入您的 Google AI Studio API 金鑰",
},
Endpoint: {
Title: "終端地址",
SubTitle: "範例:",
Error: {
EndWithBackslash: "不能以「/」結尾",
IllegalURL: "請輸入一個完整可用的url",
},
},
ApiVersion: {
Title: "API 版本(僅適用於 gemini-pro",
SubTitle: "選擇一個特定的 API 版本",
},
},
},
"en",
);

View File

@@ -1,87 +0,0 @@
import { NextRequest } from "next/server";
import { ServerConfig, getIP } from "../../common";
export const urlParamApikeyName = "key";
export const REQUEST_TIMEOUT_MS = 60000;
export const makeBearer = (s: string) => `Bearer ${s.trim()}`;
export const validString = (x?: string): x is string =>
Boolean(x && x.length > 0);
export function ensureProperEnding(str: string) {
if (str.startsWith("[") && !str.endsWith("]")) {
return str + "]";
}
return str;
}
export function auth(req: NextRequest, serverConfig: ServerConfig) {
let apiKey = req.nextUrl.searchParams.get(urlParamApikeyName);
const { hideUserApiKey, googleApiKey } = serverConfig;
console.log("[User IP] ", getIP(req));
console.log("[Time] ", new Date().toLocaleString());
if (hideUserApiKey && apiKey) {
return {
error: true,
message: "you are not allowed to access with your own api key",
};
}
if (apiKey) {
console.log("[Auth] use user api key");
return {
error: false,
apiKey,
};
}
if (googleApiKey) {
console.log("[Auth] use system api key");
return {
error: false,
apiKey: googleApiKey,
};
}
console.log("[Auth] admin did not provide an api key");
return {
error: true,
message: `missing api key`,
};
}
export function getTimer() {
const controller = new AbortController();
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
return {
...controller,
clear: () => {
clearTimeout(requestTimeoutId);
},
};
}
export function parseResp(res: any) {
if (res?.promptFeedback?.blockReason) {
// being blocked
throw new Error(
"Message is being blocked for reason: " + res.promptFeedback.blockReason,
);
}
return {
message:
res.candidates?.at(0)?.content?.parts?.at(0)?.text ||
res.error?.message ||
"",
};
}

View File

@@ -1,20 +0,0 @@
export {
default as NextChatProvider,
type NextChatProviderSettingKeys,
} from "@/app/client/providers/nextchat";
export {
default as GoogleProvider,
type GoogleProviderSettingKeys,
} from "@/app/client/providers/google";
export {
default as OpenAIProvider,
type OpenAIProviderSettingKeys,
} from "@/app/client/providers/openai";
export {
default as AnthropicProvider,
type AnthropicProviderSettingKeys,
} from "@/app/client/providers/anthropic";
export {
default as AzureProvider,
type AzureProviderSettingKeys,
} from "@/app/client/providers/azure";

View File

@@ -1,89 +0,0 @@
import { SettingItem } from "../../common";
import { isVisionModel } from "@/app/utils";
import Locale from "@/app/locales";
export const OPENAI_BASE_URL = "https://api.openai.com";
export const NextChatMetas = {
ChatPath: "v1/chat/completions",
UsagePath: "dashboard/billing/usage",
SubsPath: "dashboard/billing/subscription",
ListModelPath: "v1/models",
};
export const preferredRegion: string | string[] = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
export type SettingKeys = "accessCode";
export const defaultModal = "gpt-3.5-turbo";
export const models = [
defaultModal,
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-0125",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-16k-0613",
"gpt-4",
"gpt-4-0314",
"gpt-4-0613",
"gpt-4-1106-preview",
"gpt-4-0125-preview",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-4-32k-0613",
"gpt-4-turbo",
"gpt-4-turbo-preview",
"gpt-4-vision-preview",
"gpt-4-turbo-2024-04-09",
"gemini-1.0-pro",
"gemini-1.5-pro-latest",
"gemini-pro-vision",
"claude-instant-1.2",
"claude-2.0",
"claude-2.1",
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
"claude-3-haiku-20240307",
];
export const modelConfigs = models.map((name) => ({
name,
displayName: name,
isVision: isVisionModel(name),
isDefaultActive: true,
isDefaultSelected: name === defaultModal,
}));
export const settingItems: SettingItem<SettingKeys>[] = [
{
name: "accessCode",
title: Locale.Auth.Title,
description: Locale.Auth.Tips,
placeholder: Locale.Auth.Input,
type: "input",
inputType: "password",
validators: ["required"],
},
];

View File

@@ -1,348 +0,0 @@
import {
modelConfigs,
settingItems,
SettingKeys,
NextChatMetas,
preferredRegion,
OPENAI_BASE_URL,
} from "./config";
import {
ChatHandlers,
getMessageTextContent,
InternalChatRequestPayload,
IProviderTemplate,
ServerConfig,
StandChatReponseMessage,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import Locale from "@/app/locales";
import { auth, authHeaderName, getHeaders, getTimer, parseResp } from "./utils";
import { NextRequest, NextResponse } from "next/server";
export type NextChatProviderSettingKeys = SettingKeys;
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string | MultimodalContent[];
}
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;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof NextChatMetas
>;
export default class NextChatProvider
implements IProviderTemplate<SettingKeys, "nextchat", typeof NextChatMetas>
{
apiRouteRootName: "/api/provider/nextchat" = "/api/provider/nextchat";
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["GET", "POST"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "nextchat" as const;
metas = NextChatMetas;
defaultModels = modelConfigs;
providerMeta = {
displayName: "NextChat",
settingItems,
};
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const { messages, isVisionModel, model, stream, modelConfig } = payload;
const {
temperature,
presence_penalty,
frequency_penalty,
top_p,
max_tokens,
} = modelConfig;
const openAiMessages = messages.map((v) => ({
role: v.role,
content: isVisionModel ? v.content : getMessageTextContent(v),
}));
const requestPayload: RequestPayload = {
messages: openAiMessages,
stream,
model,
temperature,
presence_penalty,
frequency_penalty,
top_p,
};
// add max_tokens to vision model
if (isVisionModel) {
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
return {
headers: getHeaders(payload.providerConfig.accessCode!),
body: JSON.stringify(requestPayload),
method: "POST",
url: [this.apiRouteRootName, NextChatMetas.ChatPath].join("/"),
};
}
private async requestOpenai(req: NextRequest, serverConfig: ServerConfig) {
const { baseUrl = OPENAI_BASE_URL, openaiOrgId } = serverConfig;
const controller = new AbortController();
const authValue = req.headers.get(authHeaderName) ?? "";
const path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
this.apiRouteRootName,
"",
);
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",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
...(openaiOrgId && {
"OpenAI-Organization": openaiOrgId,
}),
},
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);
// 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 (openaiOrgId && 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);
newHeaders.delete("www-authenticate");
// 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 (!openaiOrgId || openaiOrgId.trim() === "") {
newHeaders.delete("OpenAI-Organization");
}
// 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 NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
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) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
async chat(
payload: InternalChatRequestPayload<"accessCode">,
fetch: typeof window.fetch,
): Promise<StandChatReponseMessage> {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = new Set(Object.values(NextChatMetas));
if (!ALLOWD_PATH.has(subpath)) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.requestOpenai(req, config);
return response;
} catch (e) {
return NextResponse.json(prettyObject(e));
}
};
}

View File

@@ -1,112 +0,0 @@
import { NextRequest } from "next/server";
import { ServerConfig, getIP } from "../../common";
import md5 from "spark-md5";
export const ACCESS_CODE_PREFIX = "nk-";
export const REQUEST_TIMEOUT_MS = 60000;
export const authHeaderName = "Authorization";
export const makeBearer = (s: string) => `Bearer ${s.trim()}`;
export const validString = (x?: string): x is string =>
Boolean(x && x.length > 0);
export function prettyObject(msg: any) {
const obj = msg;
if (typeof msg !== "string") {
msg = JSON.stringify(msg, null, " ");
}
if (msg === "{}") {
return obj.toString();
}
if (msg.startsWith("```json")) {
return msg;
}
return ["```json", msg, "```"].join("\n");
}
export function getTimer() {
const controller = new AbortController();
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
return {
...controller,
clear: () => {
clearTimeout(requestTimeoutId);
},
};
}
export function getHeaders(accessCode: string) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
[authHeaderName]: makeBearer(ACCESS_CODE_PREFIX + accessCode),
};
return headers;
}
export function parseResp(res: { choices: { message: { content: any } }[] }) {
return {
message: res.choices?.[0]?.message?.content ?? "",
};
}
function parseApiKey(req: NextRequest) {
const authToken = req.headers.get("Authorization") ?? "";
return {
accessCode:
authToken.startsWith(ACCESS_CODE_PREFIX) &&
authToken.slice(ACCESS_CODE_PREFIX.length),
};
}
export function auth(req: NextRequest, serverConfig: ServerConfig) {
// check if it is openai api key or user token
const { accessCode } = parseApiKey(req);
const { googleApiKey, apiKey, anthropicApiKey, azureApiKey, codes } =
serverConfig;
const hashedCode = md5.hash(accessCode || "").trim();
console.log("[Auth] allowed hashed codes: ", [...codes]);
console.log("[Auth] got access code:", accessCode);
console.log("[Auth] hashed access code:", hashedCode);
console.log("[User IP] ", getIP(req));
console.log("[Time] ", new Date().toLocaleString());
if (!codes.has(hashedCode)) {
return {
error: true,
message: !accessCode ? "empty access code" : "wrong access code",
};
}
const systemApiKey = googleApiKey || apiKey || anthropicApiKey || azureApiKey;
if (systemApiKey) {
console.log("[Auth] use system api key");
return {
error: false,
accessCode,
systemApiKey,
};
}
console.log("[Auth] admin did not provide an api key");
return {
error: true,
message: `Server internal error`,
};
}

View File

@@ -1,214 +0,0 @@
import { SettingItem } from "../../common";
import Locale from "./locale";
export const OPENAI_BASE_URL = "https://api.openai.com";
export const ROLES = ["system", "user", "assistant"] as const;
export const preferredRegion: string | string[] = [
"arn1",
"bom1",
"cdg1",
"cle1",
"cpt1",
"dub1",
"fra1",
"gru1",
"hnd1",
"iad1",
"icn1",
"kix1",
"lhr1",
"pdx1",
"sfo1",
"sin1",
"syd1",
];
export const OpenaiMetas = {
ChatPath: "v1/chat/completions",
UsagePath: "dashboard/billing/usage",
SubsPath: "dashboard/billing/subscription",
ListModelPath: "v1/models",
};
export type SettingKeys = "openaiUrl" | "openaiApiKey";
export const modelConfigs = [
{
name: "gpt-4o",
displayName: "gpt-4o",
isVision: false,
isDefaultActive: true,
isDefaultSelected: true,
},
{
name: "gpt-3.5-turbo",
displayName: "gpt-3.5-turbo",
isVision: false,
isDefaultActive: true,
isDefaultSelected: false,
},
{
name: "gpt-3.5-turbo-0301",
displayName: "gpt-3.5-turbo-0301",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-3.5-turbo-0613",
displayName: "gpt-3.5-turbo-0613",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-3.5-turbo-1106",
displayName: "gpt-3.5-turbo-1106",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-3.5-turbo-0125",
displayName: "gpt-3.5-turbo-0125",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-3.5-turbo-16k",
displayName: "gpt-3.5-turbo-16k",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-3.5-turbo-16k-0613",
displayName: "gpt-3.5-turbo-16k-0613",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4",
displayName: "gpt-4",
isVision: false,
isDefaultActive: true,
isDefaultSelected: false,
},
{
name: "gpt-4-0314",
displayName: "gpt-4-0314",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-0613",
displayName: "gpt-4-0613",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-1106-preview",
displayName: "gpt-4-1106-preview",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-0125-preview",
displayName: "gpt-4-0125-preview",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-32k",
displayName: "gpt-4-32k",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-32k-0314",
displayName: "gpt-4-32k-0314",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-32k-0613",
displayName: "gpt-4-32k-0613",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-turbo",
displayName: "gpt-4-turbo",
isVision: true,
isDefaultActive: true,
isDefaultSelected: false,
},
{
name: "gpt-4-turbo-preview",
displayName: "gpt-4-turbo-preview",
isVision: false,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-vision-preview",
displayName: "gpt-4-vision-preview",
isVision: true,
isDefaultActive: false,
isDefaultSelected: false,
},
{
name: "gpt-4-turbo-2024-04-09",
displayName: "gpt-4-turbo-2024-04-09",
isVision: true,
isDefaultActive: false,
isDefaultSelected: false,
},
];
export const settingItems: (
defaultEndpoint: string,
) => SettingItem<SettingKeys>[] = (defaultEndpoint) => [
{
name: "openaiUrl",
title: Locale.Endpoint.Title,
description: Locale.Endpoint.SubTitle,
defaultValue: defaultEndpoint,
type: "input",
validators: [
"required",
async (v: any) => {
if (typeof v === "string" && v.endsWith("/")) {
return Locale.Endpoint.Error.EndWithBackslash;
}
if (
typeof v === "string" &&
!v.startsWith(defaultEndpoint) &&
!v.startsWith("http")
) {
return Locale.Endpoint.SubTitle;
}
},
],
},
{
name: "openaiApiKey",
title: Locale.ApiKey.Title,
description: Locale.ApiKey.SubTitle,
placeholder: Locale.ApiKey.Placeholder,
type: "input",
inputType: "password",
// validators: ["required"],
},
];

View File

@@ -1,381 +0,0 @@
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ModelInfo,
getMessageTextContent,
ServerConfig,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import Locale from "@/app/locales";
import {
authHeaderName,
prettyObject,
parseResp,
auth,
getTimer,
getHeaders,
} from "./utils";
import {
modelConfigs,
settingItems,
SettingKeys,
OpenaiMetas,
ROLES,
OPENAI_BASE_URL,
preferredRegion,
} from "./config";
import { NextRequest, NextResponse } from "next/server";
import { ModelList } from "./type";
export type OpenAIProviderSettingKeys = SettingKeys;
export type MessageRole = (typeof ROLES)[number];
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string | MultimodalContent[];
}
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;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof OpenaiMetas
>;
class OpenAIProvider
implements IProviderTemplate<SettingKeys, "openai", typeof OpenaiMetas>
{
apiRouteRootName: "/api/provider/openai" = "/api/provider/openai";
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["GET", "POST"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "openai" as const;
metas = OpenaiMetas;
defaultModels = modelConfigs;
providerMeta = {
displayName: "OpenAI",
settingItems: settingItems(
`${this.apiRouteRootName}/${OpenaiMetas.ChatPath}`,
),
};
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages,
isVisionModel,
model,
stream,
modelConfig: {
temperature,
presence_penalty,
frequency_penalty,
top_p,
max_tokens,
},
providerConfig: { openaiUrl },
} = payload;
const openAiMessages = messages.map((v) => ({
role: v.role,
content: isVisionModel ? v.content : getMessageTextContent(v),
}));
const requestPayload: RequestPayload = {
messages: openAiMessages,
stream,
model,
temperature,
presence_penalty,
frequency_penalty,
top_p,
};
// add max_tokens to vision model
if (isVisionModel) {
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
return {
headers: getHeaders(payload.providerConfig.openaiApiKey),
body: JSON.stringify(requestPayload),
method: "POST",
url: openaiUrl!,
};
}
private async requestOpenai(req: NextRequest, serverConfig: ServerConfig) {
const { baseUrl = OPENAI_BASE_URL, openaiOrgId } = serverConfig;
const controller = new AbortController();
const authValue = req.headers.get(authHeaderName) ?? "";
const path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
this.apiRouteRootName,
"",
);
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",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
...(openaiOrgId && {
"OpenAI-Organization": openaiOrgId,
}),
},
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);
// 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 (openaiOrgId && 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);
newHeaders.delete("www-authenticate");
// 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 (!openaiOrgId || openaiOrgId.trim() === "") {
newHeaders.delete("OpenAI-Organization");
}
// 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 NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
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) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
async getAvailableModels(
providerConfig: Record<SettingKeys, string>,
): Promise<ModelInfo[]> {
const { openaiApiKey, openaiUrl } = providerConfig;
const res = await fetch(`${openaiUrl}/v1/models`, {
headers: {
Authorization: `Bearer ${openaiApiKey}`,
},
method: "GET",
});
const data: ModelList = await res.json();
return data.data.map((o) => ({
name: o.id,
}));
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = new Set(Object.values(OpenaiMetas));
if (!ALLOWD_PATH.has(subpath)) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.requestOpenai(req, config);
return response;
} catch (e) {
return NextResponse.json(prettyObject(e));
}
};
}
export default OpenAIProvider;

View File

@@ -1,100 +0,0 @@
import { getLocaleText } from "../../common/locale";
export default getLocaleText<
{
ApiKey: {
Title: string;
SubTitle: string;
Placeholder: string;
};
Endpoint: {
Title: string;
SubTitle: string;
Error: {
EndWithBackslash: string;
};
};
},
"en"
>(
{
cn: {
ApiKey: {
Title: "API Key",
SubTitle: "使用自定义 OpenAI Key 绕过密码访问限制",
Placeholder: "OpenAI API Key",
},
Endpoint: {
Title: "接口地址",
SubTitle: "除默认地址外,必须包含 http(s)://",
Error: {
EndWithBackslash: "不能以「/」结尾",
},
},
},
en: {
ApiKey: {
Title: "OpenAI API Key",
SubTitle: "User custom OpenAI Api Key",
Placeholder: "sk-xxx",
},
Endpoint: {
Title: "OpenAI Endpoint",
SubTitle: "Must starts with http(s):// or use /api/openai as default",
Error: {
EndWithBackslash: "Cannot end with '/'",
},
},
},
pt: {
ApiKey: {
Title: "Chave API OpenAI",
SubTitle: "Usar Chave API OpenAI personalizada",
Placeholder: "sk-xxx",
},
Endpoint: {
Title: "Endpoint OpenAI",
SubTitle: "Deve começar com http(s):// ou usar /api/openai como padrão",
Error: {
EndWithBackslash: "Não é possível terminar com '/'",
},
},
},
sk: {
ApiKey: {
Title: "API kľúč OpenAI",
SubTitle: "Použiť vlastný API kľúč OpenAI",
Placeholder: "sk-xxx",
},
Endpoint: {
Title: "Koncový bod OpenAI",
SubTitle:
"Musí začínať http(s):// alebo použiť /api/openai ako predvolený",
Error: {
EndWithBackslash: "Nemôže končiť znakom „/“",
},
},
},
tw: {
ApiKey: {
Title: "API Key",
SubTitle: "使用自定義 OpenAI Key 繞過密碼存取限制",
Placeholder: "OpenAI API Key",
},
Endpoint: {
Title: "介面(Endpoint) 地址",
SubTitle: "除預設地址外,必須包含 http(s)://",
Error: {
EndWithBackslash: "不能以「/」結尾",
},
},
},
},
"en",
);

View File

@@ -1,18 +0,0 @@
export interface ModelList {
object: "list";
data: Array<{
id: string;
object: "model";
created: number;
owned_by: "system" | "openai-internal";
}>;
}
export interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}

View File

@@ -1,103 +0,0 @@
import { NextRequest } from "next/server";
import { ServerConfig, getIP } from "../../common";
export const REQUEST_TIMEOUT_MS = 60000;
export const authHeaderName = "Authorization";
const makeBearer = (s: string) => `Bearer ${s.trim()}`;
const validString = (x?: string): x is string => Boolean(x && x.length > 0);
function parseApiKey(bearToken: string) {
const token = bearToken.trim().replaceAll("Bearer ", "").trim();
return {
apiKey: token,
};
}
export function prettyObject(msg: any) {
const obj = msg;
if (typeof msg !== "string") {
msg = JSON.stringify(msg, null, " ");
}
if (msg === "{}") {
return obj.toString();
}
if (msg.startsWith("```json")) {
return msg;
}
return ["```json", msg, "```"].join("\n");
}
export function parseResp(res: { choices: { message: { content: any } }[] }) {
return {
message: res.choices?.[0]?.message?.content ?? "",
};
}
export function auth(req: NextRequest, serverConfig: ServerConfig) {
const { hideUserApiKey, apiKey: systemApiKey } = serverConfig;
const authToken = req.headers.get(authHeaderName) ?? "";
const { apiKey } = parseApiKey(authToken);
console.log("[User IP] ", getIP(req));
console.log("[Time] ", new Date().toLocaleString());
if (hideUserApiKey && apiKey) {
return {
error: true,
message: "you are not allowed to access with your own api key",
};
}
if (apiKey) {
console.log("[Auth] use user api key");
return {
error: false,
};
}
if (systemApiKey) {
console.log("[Auth] use system api key");
req.headers.set(authHeaderName, `Bearer ${systemApiKey}`);
} else {
console.log("[Auth] admin did not provide an api key");
}
return {
error: false,
};
}
export function getTimer() {
const controller = new AbortController();
// make a fetch request
const requestTimeoutId = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
return {
...controller,
clear: () => {
clearTimeout(requestTimeoutId);
},
};
}
export function getHeaders(openaiApiKey?: string) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (validString(openaiApiKey)) {
headers[authHeaderName] = makeBearer(openaiApiKey);
}
return headers;
}

View File

@@ -1,123 +0,0 @@
import { isValidElement } from "react";
type IconMap = {
active?: JSX.Element;
inactive?: JSX.Element;
mobileActive?: JSX.Element;
mobileInactive?: JSX.Element;
};
interface Action {
id: string;
title?: string;
icons: JSX.Element | IconMap;
className?: string;
onClick?: () => void;
activeClassName?: string;
}
type Groups = {
normal: string[][];
mobile: string[][];
};
export interface ActionsBarProps {
actionsSchema: Action[];
onSelect?: (id: string) => void;
selected?: string;
groups: string[][] | Groups;
className?: string;
inMobile?: boolean;
}
export default function ActionsBar(props: ActionsBarProps) {
const { actionsSchema, onSelect, selected, groups, className, inMobile } =
props;
const handlerClick =
(action: Action) => (e: { preventDefault: () => void }) => {
e.preventDefault();
if (action.onClick) {
action.onClick();
}
if (selected !== action.id) {
onSelect?.(action.id);
}
};
const internalGroup = Array.isArray(groups)
? groups
: inMobile
? groups.mobile
: groups.normal;
const content = internalGroup.reduce((res, group, ind, arr) => {
res.push(
...group.map((i) => {
const action = actionsSchema.find((a) => a.id === i);
if (!action) {
return <></>;
}
const { icons } = action;
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
if (isValidElement(icons)) {
activeIcon = icons;
inactiveIcon = icons;
mobileActiveIcon = icons;
mobileInactiveIcon = icons;
} else {
activeIcon = (icons as IconMap).active;
inactiveIcon = (icons as IconMap).inactive;
mobileActiveIcon = (icons as IconMap).mobileActive;
mobileInactiveIcon = (icons as IconMap).mobileInactive;
}
if (inMobile) {
return (
<div
key={action.id}
className={` cursor-pointer shrink-1 grow-0 basis-[${
(100 - 1) / arr.length
}%] flex flex-col items-center justify-around gap-0.5 py-1.5
${
selected === action.id
? "text-text-sidebar-tab-mobile-active"
: "text-text-sidebar-tab-mobile-inactive"
}
`}
onClick={handlerClick(action)}
>
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
{action.title || " "}
</div>
</div>
);
}
return (
<div
key={action.id}
className={`cursor-pointer p-3 ${
selected === action.id
? `!bg-actions-bar-btn-default ${action.activeClassName}`
: "bg-transparent"
} rounded-md items-center ${
action.className
} transition duration-300 ease-in-out`}
onClick={handlerClick(action)}
>
{selected === action.id ? activeIcon : inactiveIcon}
</div>
);
}),
);
if (ind < arr.length - 1) {
res.push(<div key={String(ind)} className=" flex-1"></div>);
}
return res;
}, [] as JSX.Element[]);
return <div className={`flex items-center ${className} `}>{content}</div>;
}

View File

@@ -1,78 +0,0 @@
import * as React from "react";
export type ButtonType = "primary" | "danger" | null;
export interface BtnProps {
onClick?: () => void;
icon?: JSX.Element;
prefixIcon?: JSX.Element;
type?: ButtonType;
text?: React.ReactNode;
bordered?: boolean;
shadow?: boolean;
className?: string;
title?: string;
disabled?: boolean;
tabIndex?: number;
autoFocus?: boolean;
}
export default function Btn(props: BtnProps) {
const {
onClick,
icon,
type,
text,
className,
title,
disabled,
tabIndex,
autoFocus,
prefixIcon,
} = props;
let btnClassName;
switch (type) {
case "primary":
btnClassName = `${
disabled
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
: "bg-primary-btn shadow-btn"
} text-text-btn-primary `;
break;
case "danger":
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
break;
default:
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
}
return (
<button
className={`
${className ?? ""}
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
${btnClassName}
follow-parent-svg
`}
onClick={onClick}
title={title}
disabled={disabled}
role="button"
tabIndex={tabIndex}
autoFocus={autoFocus}
>
{prefixIcon && (
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
)}
{text && (
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
{text}
</div>
)}
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
</button>
);
}

View File

@@ -1,32 +0,0 @@
import { ReactNode } from "react";
export interface CardProps {
className?: string;
children?: ReactNode;
title?: ReactNode;
}
export default function Card(props: CardProps) {
const { className, children, title } = props;
return (
<>
{title && (
<div
className={`
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
mb-3
ml-3
md:ml-4
`}
>
{title}
</div>
)}
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
{children}
</div>
</>
);
}

View File

@@ -1,18 +0,0 @@
import BotIcon from "@/app/icons/bot.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
export default function GloablLoading({
noLogo,
}: {
noLogo?: boolean;
useSkeleton?: boolean;
}) {
return (
<div
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
>
{!noLogo && <BotIcon />}
<LoadingIcon />
</div>
);
}

View File

@@ -1,39 +0,0 @@
import * as HoverCard from "@radix-ui/react-hover-card";
import { ComponentProps } from "react";
export interface PopoverProps {
content?: JSX.Element | string;
children?: JSX.Element;
arrowClassName?: string;
popoverClassName?: string;
noArrow?: boolean;
align?: ComponentProps<typeof HoverCard.Content>["align"];
openDelay?: number;
}
export default function HoverPopover(props: PopoverProps) {
const {
content,
children,
arrowClassName,
popoverClassName,
noArrow = false,
align,
openDelay = 300,
} = props;
return (
<HoverCard.Root openDelay={openDelay}>
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
className={`${popoverClassName}`}
sideOffset={5}
align={align}
>
{content}
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
}

View File

@@ -1,42 +0,0 @@
import { CSSProperties } from "react";
import { getMessageImages } from "@/app/utils";
import { RequestMessage } from "@/app/client/api";
interface ImgsProps {
message: RequestMessage;
}
export default function Imgs(props: ImgsProps) {
const { message } = props;
const imgSrcs = getMessageImages(message);
if (imgSrcs.length < 1) {
return <></>;
}
const imgVars = {
"--imgs-width": `calc(var(--max-message-width) - ${
imgSrcs.length - 1
}*0.25rem)`,
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
};
return (
<div
className={`w-[100%] mt-[0.625rem] flex gap-1`}
style={imgVars as CSSProperties}
>
{imgSrcs.map((image, index) => {
return (
<div
key={index}
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
style={{
backgroundImage: `url(${image})`,
}}
/>
);
})}
</div>
);
}

View File

@@ -1,88 +0,0 @@
import PasswordVisible from "@/app/icons/passwordVisible.svg";
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
import {
DetailedHTMLProps,
InputHTMLAttributes,
useContext,
useLayoutEffect,
useState,
} from "react";
import List, { ListContext } from "@/app/components/List";
export interface CommonInputProps
extends Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
"onChange" | "type" | "value"
> {
className?: string;
}
export interface NumberInputProps {
onChange?: (v: number) => void;
type?: "number";
value?: number;
}
export interface TextInputProps {
onChange?: (v: string) => void;
type?: "text" | "password";
value?: string;
}
export interface InputProps {
onChange?: ((v: string) => void) | ((v: number) => void);
type?: "text" | "password" | "number";
value?: string | number;
}
export default function Input(
props: CommonInputProps & NumberInputProps,
): JSX.Element;
export default function Input(
props: CommonInputProps & TextInputProps,
): JSX.Element;
export default function Input(props: CommonInputProps & InputProps) {
const { value, type = "text", onChange, className, ...rest } = props;
const [show, setShow] = useState(false);
const { inputClassName } = useContext(ListContext);
const internalType = (show && "text") || type;
const { update, handleValidate } = useContext(List.ListContext);
useLayoutEffect(() => {
update?.({ type: "input" });
}, []);
useLayoutEffect(() => {
handleValidate?.(value);
}, [value]);
return (
<div
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
>
<input
{...rest}
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
type={internalType}
value={value}
onChange={(e) => {
if (type === "number") {
const v = e.currentTarget.valueAsNumber;
(onChange as NumberInputProps["onChange"])?.(v);
} else {
const v = e.currentTarget.value;
(onChange as TextInputProps["onChange"])?.(v);
}
}}
/>
{type == "password" && (
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
{show ? <PasswordVisible /> : <PasswordInvisible />}
</div>
)}
</div>
);
}

View File

@@ -1,167 +0,0 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useState,
} from "react";
interface WidgetStyle {
selectClassName?: string;
inputClassName?: string;
rangeClassName?: string;
switchClassName?: string;
inputNextLine?: boolean;
rangeNextLine?: boolean;
}
interface ChildrenMeta {
type?: "unknown" | "input" | "range";
error?: string;
}
export interface ListProps {
className?: string;
children?: ReactNode;
id?: string;
isMobileScreen?: boolean;
widgetStyle?: WidgetStyle;
}
type Error =
| {
error: true;
message: string;
}
| {
error: false;
};
type Validate = (v: any) => Error | Promise<Error>;
export interface ListItemProps {
title: string;
subTitle?: string;
children?: JSX.Element | JSX.Element[];
className?: string;
onClick?: () => void;
nextline?: boolean;
validator?: Validate | Validate[];
}
export const ListContext = createContext<
{
isMobileScreen?: boolean;
update?: (m: ChildrenMeta) => void;
handleValidate?: (v: any) => void;
} & WidgetStyle
>({ isMobileScreen: false });
export function ListItem(props: ListItemProps) {
const {
className = "",
onClick,
title,
subTitle,
children,
nextline,
validator,
} = props;
const context = useContext(ListContext);
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
const { inputNextLine, rangeNextLine } = context;
const { type, error } = childrenMeta;
let internalNextLine;
switch (type) {
case "input":
internalNextLine = !!(nextline || inputNextLine);
break;
case "range":
internalNextLine = !!(nextline || rangeNextLine);
break;
default:
internalNextLine = false;
}
const update = useCallback((m: ChildrenMeta) => {
setMeta((pre) => ({ ...pre, ...m }));
}, []);
const handleValidate = useCallback((v: any) => {
let insideValidator;
if (!validator) {
insideValidator = () => {};
} else if (Array.isArray(validator)) {
insideValidator = (v: any) =>
Promise.race(validator.map((validate) => validate(v)));
} else {
insideValidator = validator;
}
Promise.resolve(insideValidator(v)).then((result) => {
if (result && result.error) {
return update({
error: result.message,
});
}
update({
error: undefined,
});
});
}, []);
return (
<div
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-list-item-divider ${
internalNextLine ? "" : "flex gap-3"
} justify-between items-center px-0 py-2 md:py-3 ${className}`}
onClick={onClick}
>
<div className={`flex-1 flex flex-col justify-start gap-1`}>
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1 text-text-list-title">
{title}
</div>
{subTitle && (
<div className={` text-sm text-text-list-subtitle`}>{subTitle}</div>
)}
</div>
<ListContext.Provider value={{ ...context, update, handleValidate }}>
<div
className={`${
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
} flex flex-col items-center justify-center`}
>
<div>{children}</div>
{!!error && (
<div className="text-text-btn-danger text-sm-mobile-tab mt-[0.3125rem] flex items-start w-[100%]">
<div className="">{error}</div>
</div>
)}
</div>
</ListContext.Provider>
</div>
);
}
function List(props: ListProps) {
const { className, children, id, widgetStyle } = props;
const { isMobileScreen } = useContext(ListContext);
return (
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
{children}
</div>
</ListContext.Provider>
);
}
List.ListItem = ListItem;
List.ListContext = ListContext;
export default List;

View File

@@ -1,35 +0,0 @@
import BotIcon from "@/app/icons/bot.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
import { getCSSVar } from "@/app/utils";
export default function Loading({
noLogo,
useSkeleton = true,
}: {
noLogo?: boolean;
useSkeleton?: boolean;
}) {
let theme;
if (typeof window !== "undefined") {
theme = getCSSVar("--default-container-bg");
}
return (
<div
className={`
flex flex-col justify-center items-center w-[100%]
h-[100%]
md:my-2.5
md:ml-1
md:mr-2.5
md:rounded-md
md:h-[calc(100%-1.25rem)]
`}
style={{ background: useSkeleton ? theme : "" }}
>
{!noLogo && <BotIcon />}
<LoadingIcon />
</div>
);
}

View File

@@ -1,115 +0,0 @@
import {
DEFAULT_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
Path,
} from "@/app/constant";
import useDrag from "@/app/hooks/useDrag";
import useMobileScreen from "@/app/hooks/useMobileScreen";
import { updateGlobalCSSVars } from "@/app/utils/client";
import { ComponentType, useRef, useState } from "react";
import { useAppConfig } from "@/app/store/config";
export interface MenuWrapperInspectProps {
setExternalProps?: (v: Record<string, any>) => void;
setShowPanel?: (v: boolean) => void;
showPanel?: boolean;
[k: string]: any;
}
export default function MenuLayout<
ListComponentProps extends MenuWrapperInspectProps,
PanelComponentProps extends MenuWrapperInspectProps,
>(
ListComponent: ComponentType<ListComponentProps>,
PanelComponent: ComponentType<PanelComponentProps>,
) {
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
const [showPanel, setShowPanel] = useState(false);
const [externalProps, setExternalProps] = useState({});
const config = useAppConfig();
const isMobileScreen = useMobileScreen();
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
// drag side bar
const { onDragStart } = useDrag({
customToggle: () => {
config.update((config) => {
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
});
},
customDragMove: (nextWidth: number) => {
const { menuWidth } = updateGlobalCSSVars(nextWidth);
document.documentElement.style.setProperty(
"--menu-width",
`${menuWidth}px`,
);
config.update((config) => {
config.sidebarWidth = nextWidth;
});
},
customLimit: (x: number) =>
Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
),
});
return (
<div
className={`
w-[100%] relative bg-center
max-md:h-[100%]
md:flex md:my-2.5
`}
>
<div
className={`
flex flex-col px-6
h-[100%]
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
`}
>
<ListComponent
{...props}
setShowPanel={setShowPanel}
setExternalProps={setExternalProps}
showPanel={showPanel}
/>
</div>
{!isMobileScreen && (
<div
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
onPointerDown={(e) => {
startDragWidth.current = config.sidebarWidth;
onDragStart(e as any);
}}
>
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
&nbsp;
</div>
</div>
)}
<div
className={`
md:flex-1 md:h-[100%] md:w-page
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
} max-md:z-10
`}
>
<PanelComponent
{...props}
{...externalProps}
setShowPanel={setShowPanel}
setExternalProps={setExternalProps}
showPanel={showPanel}
/>
</div>
</div>
);
};
}

View File

@@ -1,352 +0,0 @@
import React, { useLayoutEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import * as AlertDialog from "@radix-ui/react-alert-dialog";
import Btn, { BtnProps } from "@/app/components/Btn";
import Warning from "@/app/icons/warning.svg";
import Close from "@/app/icons/closeIcon.svg";
export interface ModalProps {
onOk?: () => void;
onCancel?: () => void;
okText?: string;
cancelText?: string;
okBtnProps?: BtnProps;
cancelBtnProps?: BtnProps;
content?:
| React.ReactNode
| ((handlers: { close: () => void }) => JSX.Element);
title?: React.ReactNode;
visible?: boolean;
noFooter?: boolean;
noHeader?: boolean;
isMobile?: boolean;
closeble?: boolean;
type?: "modal" | "bottom-drawer";
headerBordered?: boolean;
modelClassName?: string;
onOpen?: (v: boolean) => void;
maskCloseble?: boolean;
}
export interface WarnProps
extends Omit<
ModalProps,
| "closeble"
| "isMobile"
| "noHeader"
| "noFooter"
| "onOk"
| "okBtnProps"
| "cancelBtnProps"
| "content"
> {
onOk?: () => Promise<void> | void;
content?: React.ReactNode;
}
export interface TriggerProps
extends Omit<ModalProps, "visible" | "onOk" | "onCancel"> {
children: JSX.Element;
className?: string;
}
const baseZIndex = 150;
const Modal = (props: ModalProps) => {
const {
onOk,
onCancel,
okText,
cancelText,
content,
title,
visible,
noFooter,
noHeader,
closeble = true,
okBtnProps,
cancelBtnProps,
type = "modal",
headerBordered,
modelClassName,
onOpen,
maskCloseble = true,
} = props;
const [open, setOpen] = useState(!!visible);
const mergeOpen = visible ?? open;
const handleClose = () => {
setOpen(false);
onCancel?.();
};
const handleOk = () => {
setOpen(false);
onOk?.();
};
useLayoutEffect(() => {
onOpen?.(mergeOpen);
}, [mergeOpen]);
let layoutClassName = "";
let panelClassName = "";
let titleClassName = "";
let footerClassName = "";
switch (type) {
case "bottom-drawer":
layoutClassName = "fixed inset-0 flex flex-col w-[100%] bottom-0";
panelClassName =
"rounded-t-chat-model-select overflow-y-auto overflow-x-hidden";
titleClassName = "px-4 py-3";
footerClassName = "absolute w-[100%]";
break;
case "modal":
default:
layoutClassName =
"fixed inset-0 flex flex-col item-start top-0 left-[50vw] translate-x-[-50%] max-sm:w-modal-modal-type-mobile";
panelClassName = "rounded-lg px-6 sm:w-modal-modal-type";
titleClassName = "py-6 max-sm:pb-3";
footerClassName = "py-6";
}
const btnCommonClass = "px-4 py-2.5 rounded-md max-sm:flex-1";
const { className: okBtnClass } = okBtnProps || {};
const { className: cancelBtnClass } = cancelBtnProps || {};
return (
<AlertDialog.Root open={mergeOpen} onOpenChange={setOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay
className="bg-modal-mask fixed inset-0 animate-mask "
style={{ zIndex: baseZIndex - 1 }}
onClick={() => {
if (maskCloseble) {
handleClose();
}
}}
/>
<AlertDialog.Content
className={`
${layoutClassName}
`}
style={{ zIndex: baseZIndex - 1 }}
>
<div
className="flex-1"
onClick={() => {
if (maskCloseble) {
handleClose();
}
}}
>
&nbsp;
</div>
<div
className={`flex flex-col flex-0
bg-moda-panel text-modal-panel
${modelClassName}
${panelClassName}
`}
>
{!noHeader && (
<AlertDialog.Title
className={`
flex items-center justify-between gap-3 font-common
md:text-chat-header-title md:font-bold md:leading-5
${
headerBordered
? " border-b border-modal-header-bottom"
: ""
}
${titleClassName}
`}
>
<div className="flex gap-3 justify-start flex-1 items-center text-text-modal-title text-chat-header-title">
{title}
</div>
{closeble && (
<div
className="items-center"
onClick={() => {
handleClose();
}}
>
<Close />
</div>
)}
</AlertDialog.Title>
)}
<div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
{typeof content === "function"
? content({
close: () => {
handleClose();
},
})
: content}
</div>
{!noFooter && (
<div
className={`
flex gap-3 sm:justify-end max-sm:justify-between
${footerClassName}
`}
>
<AlertDialog.Cancel asChild>
<Btn
{...cancelBtnProps}
onClick={() => handleClose()}
text={cancelText}
className={`${btnCommonClass} ${cancelBtnClass}`}
/>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Btn
{...okBtnProps}
onClick={handleOk}
text={okText}
className={`${btnCommonClass} ${okBtnClass}`}
/>
</AlertDialog.Action>
</div>
)}
</div>
{type === "modal" && (
<div
className="flex-1"
onClick={() => {
if (maskCloseble) {
handleClose();
}
}}
>
&nbsp;
</div>
)}
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
};
export const Warn = ({
title,
onOk,
visible,
content,
...props
}: WarnProps) => {
const [internalVisible, setVisible] = useState(visible);
return (
<Modal
{...props}
title={
<>
<Warning />
{title}
</>
}
content={
<AlertDialog.Description
className={`
font-common font-normal
md:text-sm-title md:leading-[158%]
`}
>
{content}
</AlertDialog.Description>
}
closeble={false}
onOk={() => {
const toDo = onOk?.();
if (toDo instanceof Promise) {
toDo.then(() => {
setVisible(false);
});
} else {
setVisible(false);
}
}}
visible={internalVisible}
okBtnProps={{
className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
}}
cancelBtnProps={{
className: `bg-delete-chat-cancel-btn border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
}}
/>
);
};
const div = document.createElement("div");
div.id = "confirm-root";
div.style.height = "0px";
document.body.appendChild(div);
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
const root = createRoot(div);
const closeModal = () => {
root.unmount();
};
return new Promise<boolean>((resolve) => {
root.render(
<Warn
{...props}
visible={true}
onCancel={() => {
closeModal();
resolve(false);
}}
onOk={() => {
closeModal();
resolve(true);
}}
/>,
);
});
};
export const Trigger = (props: TriggerProps) => {
const { children, className, content, ...rest } = props;
const [internalVisible, setVisible] = useState(false);
return (
<>
<div
className={className}
onClick={() => {
setVisible(true);
}}
>
{children}
</div>
<Modal
{...rest}
visible={internalVisible}
onCancel={() => {
setVisible(false);
}}
content={
typeof content === "function"
? content({
close: () => {
setVisible(false);
},
})
: content
}
/>
</>
);
};
Modal.Trigger = Trigger;
export default Modal;

View File

@@ -1,352 +0,0 @@
import useRelativePosition from "@/app/hooks/useRelativePosition";
import {
RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
const ArrowIcon = ({ sibling }: { sibling: RefObject<HTMLDivElement> }) => {
const [color, setColor] = useState<string>("");
useEffect(() => {
if (sibling.current) {
const { backgroundColor } = window.getComputedStyle(sibling.current);
setColor(backgroundColor);
}
}, []);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="6"
viewBox="0 0 16 6"
fill="none"
>
<path
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
fill={color}
/>
</svg>
);
};
const baseZIndex = 100;
const popoverRootName = "popoverRoot";
let popoverRoot = document.querySelector(
`#${popoverRootName}`,
) as HTMLDivElement;
if (!popoverRoot) {
popoverRoot = document.createElement("div");
document.body.appendChild(popoverRoot);
popoverRoot.style.height = "0px";
popoverRoot.style.width = "100%";
popoverRoot.style.position = "fixed";
popoverRoot.style.bottom = "0";
popoverRoot.style.zIndex = "10000";
popoverRoot.id = "popover-root";
}
export interface PopoverProps {
content?: JSX.Element | string;
children?: JSX.Element;
show?: boolean;
onShow?: (v: boolean) => void;
className?: string;
popoverClassName?: string;
trigger?: "hover" | "click";
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b" | "l" | "r";
noArrow?: boolean;
delayClose?: number;
useGlobalRoot?: boolean;
getPopoverPanelRef?: (ref: RefObject<HTMLDivElement>) => void;
}
export default function Popover(props: PopoverProps) {
const {
content,
children,
show,
onShow,
className,
popoverClassName,
trigger = "hover",
placement = "t",
noArrow = false,
delayClose = 0,
useGlobalRoot,
getPopoverPanelRef,
} = props;
const [internalShow, setShow] = useState(false);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
const popoverCommonClass = `absolute p-2 box-border`;
const mergedShow = show ?? internalShow;
const { arrowClassName, placementStyle, placementClassName } = useMemo(() => {
const arrowCommonClassName = `${
noArrow ? "hidden" : ""
} absolute z-10 left-[50%] translate-x-[calc(-50%)]`;
let defaultTopPlacement = true; // when users dont config 't' or 'b'
const {
distanceToBottomBoundary = 0,
distanceToLeftBoundary = 0,
distanceToRightBoundary = -10000,
distanceToTopBoundary = 0,
targetH = 0,
targetW = 0,
} = position?.poi || {};
if (distanceToBottomBoundary > distanceToTopBoundary) {
defaultTopPlacement = false;
}
const placements = {
lt: {
placementStyle: {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
placementClassName: "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]",
},
lb: {
placementStyle: {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
placementClassName: "top-[calc(100%+0.5rem)] left-[calc(-2%)]",
},
rt: {
placementStyle: {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
placementClassName: "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]",
},
rb: {
placementStyle: {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
},
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
placementClassName: "top-[calc(100%+0.5rem)] right-[calc(-2%)]",
},
t: {
placementStyle: {
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
transform: "translateX(-50%)",
},
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
placementClassName:
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
},
b: {
placementStyle: {
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
transform: "translateX(-50%)",
},
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
placementClassName:
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
},
};
const getStyle = () => {
if (["l", "r"].includes(placement)) {
return placements[
`${placement}${defaultTopPlacement ? "t" : "b"}` as
| "lt"
| "lb"
| "rb"
| "rt"
];
}
return placements[placement as Exclude<typeof placement, "l" | "r">];
};
return getStyle();
}, [Object.values(position?.poi || {})]);
const popoverRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<number>(0);
useLayoutEffect(() => {
getPopoverPanelRef?.(popoverRef);
onShow?.(internalShow);
}, [internalShow]);
if (trigger === "click") {
const handleOpen = (e: { currentTarget: any }) => {
clearTimeout(closeTimer.current);
setShow(true);
getRelativePosition(e.currentTarget, "");
window.document.documentElement.style.overflow = "hidden";
};
const handleClose = () => {
if (delayClose) {
closeTimer.current = window.setTimeout(() => {
setShow(false);
}, delayClose);
} else {
setShow(false);
}
window.document.documentElement.style.overflow = "auto";
};
return (
<div
className={`relative ${className}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!mergedShow) {
handleOpen(e);
} else {
handleClose();
}
}}
>
{children}
{mergedShow && (
<>
{!noArrow && (
<div className={`${arrowClassName}`}>
<ArrowIcon sibling={popoverRef} />
</div>
)}
{createPortal(
<div
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
ref={popoverRef}
>
{content}
</div>,
popoverRoot,
)}
{createPortal(
<div
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
style={{ zIndex: baseZIndex }}
onClick={(e) => {
e.preventDefault();
handleClose();
}}
>
&nbsp;
</div>,
popoverRoot,
)}
</>
)}
</div>
);
}
if (useGlobalRoot) {
return (
<div
className={`relative ${className}`}
onPointerEnter={(e) => {
e.preventDefault();
clearTimeout(closeTimer.current);
onShow?.(true);
setShow(true);
getRelativePosition(e.currentTarget, "");
window.document.documentElement.style.overflow = "hidden";
}}
onPointerLeave={(e) => {
e.preventDefault();
if (delayClose) {
closeTimer.current = window.setTimeout(() => {
onShow?.(false);
setShow(false);
}, delayClose);
} else {
onShow?.(false);
setShow(false);
}
window.document.documentElement.style.overflow = "auto";
}}
>
{children}
{mergedShow && (
<>
<div
className={`${
noArrow ? "opacity-0" : ""
} bg-inherit ${arrowClassName}`}
style={{ zIndex: baseZIndex + 1 }}
>
<ArrowIcon sibling={popoverRef} />
</div>
{createPortal(
<div
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
ref={popoverRef}
>
{content}
</div>,
popoverRoot,
)}
</>
)}
</div>
);
}
return (
<div
className={`group/popover relative ${className}`}
onPointerEnter={(e) => {
getRelativePosition(e.currentTarget, "");
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{children}
<div
className={`
hidden group-hover/popover:block
${noArrow ? "opacity-0" : ""}
bg-inherit
${arrowClassName}
`}
style={{ zIndex: baseZIndex + 1 }}
>
<ArrowIcon sibling={popoverRef} />
</div>
<div
className={`
hidden group-hover/popover:block whitespace-nowrap
${popoverCommonClass}
${placementClassName}
${popoverClassName}
`}
ref={popoverRef}
style={{ zIndex: baseZIndex + 1 }}
>
{content}
</div>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import { useLocation } from "react-router-dom";
import { useMemo, ReactNode } from "react";
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
import { getLang } from "@/app/locales";
import useMobileScreen from "@/app/hooks/useMobileScreen";
import { isIOS } from "@/app/utils";
import useListenWinResize from "@/app/hooks/useListenWinResize";
interface ScreenProps {
children: ReactNode;
noAuth: ReactNode;
sidebar: ReactNode;
}
export default function Screen(props: ScreenProps) {
const location = useLocation();
const isAuth = location.pathname === Path.Auth;
const isMobileScreen = useMobileScreen();
const isIOSMobile = useMemo(
() => isIOS() && isMobileScreen,
[isMobileScreen],
);
useListenWinResize();
return (
<div
className={`
flex h-[100%] w-[100%] bg-center
max-md:relative max-md:flex-col-reverse max-md:bg-global-mobile
md:overflow-hidden md:bg-global
`}
style={{
direction: getLang() === "ar" ? "rtl" : "ltr",
}}
>
{isAuth ? (
props.noAuth
) : (
<>
<div
className={`
max-md:absolute max-md:w-[100%] max-md:bottom-0 max-md:z-10
md:flex-0 md:overflow-hidden
`}
id={SIDEBAR_ID}
>
{props.sidebar}
</div>
<div
className={`
h-[100%]
max-md:w-[100%]
md:flex-1 md:min-w-0 md:overflow-hidden md:flex
`}
id={SlotID.AppBody}
style={{
// #3016 disable transition on ios mobile screen
transition: isIOSMobile ? "none" : undefined,
}}
>
{props.children}
</div>
</>
)}
</div>
);
}

View File

@@ -1,24 +0,0 @@
.search {
display: flex;
max-width: 460px;
height: 50px;
padding: 16px;
align-items: center;
gap: 8px;
flex-shrink: 0;
border-radius: 16px;
border: 1px solid var(--Light-Text-Black, #18182A);
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
.icon {
height: 20px;
width: 20px;
flex: 0 0;
}
.input {
height: 18px;
flex: 1 1;
}
}

View File

@@ -1,30 +0,0 @@
import styles from "./index.module.scss";
import SearchIcon from "@/app/icons/search.svg";
export interface SearchProps {
value?: string;
onSearch?: (v: string) => void;
placeholder?: string;
}
const Search = (props: SearchProps) => {
const { placeholder = "", value, onSearch } = props;
return (
<div className={styles["search"]}>
<div className={styles["icon"]}>
<SearchIcon />
</div>
<input
className={styles["input"]}
placeholder={placeholder}
value={value}
onChange={(e) => {
e.preventDefault();
onSearch?.(e.target.value);
}}
/>
</div>
);
};
export default Search;

View File

@@ -1,118 +0,0 @@
import SelectIcon from "@/app/icons/downArrowIcon.svg";
import Popover from "@/app/components/Popover";
import React, { useContext, useMemo, useRef } from "react";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import List from "@/app/components/List";
import Selected from "@/app/icons/selectedIcon.svg";
export type Option<Value> = {
value: Value;
label: string;
icon?: React.ReactNode;
};
export interface SearchProps<Value> {
value?: string;
onSelect?: (v: Value) => void;
options?: Option<Value>[];
inMobile?: boolean;
}
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
const { value, onSelect, options = [], inMobile } = props;
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
const optionsRef = useRef<Option<Value>[]>([]);
optionsRef.current = options;
const selectedOption = useMemo(
() => optionsRef.current.find((o) => o.value === value),
[value],
);
const contentRef = useRef<HTMLDivElement>(null);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
let headerH = 100;
let baseH = position?.poi.distanceToBottomBoundary || 0;
if (isMobileScreen) {
headerH = 60;
}
if (position?.poi.relativePosition[1] === Orientation.bottom) {
baseH = position?.poi.distanceToTopBoundary;
}
const maxHeight = `${baseH - headerH}px`;
const content = (
<div
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
style={{ maxHeight }}
>
{options?.map((o) => (
<div
key={o.value}
className={`
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
`}
onClick={() => {
onSelect?.(o.value);
}}
>
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
</div>
<div
className={
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
}
>
<Selected />
</div>
</div>
))}
</div>
);
return (
<Popover
content={content}
trigger="click"
noArrow
placement={
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
}
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
onShow={(e) => {
getRelativePosition(contentRef.current!, "");
}}
className={selectClassName}
>
<div
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
ref={contentRef}
>
<div
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
>
{!!selectedOption?.icon && (
<div className={``}>{selectedOption?.icon}</div>
)}
<div className={`flex-1`}>{selectedOption?.label}</div>
</div>
<div className={``}>
<SelectIcon />
</div>
</div>
</Popover>
);
};
export default Select;

View File

@@ -1,99 +0,0 @@
import { useContext, useEffect, useRef } from "react";
import { ListContext } from "@/app/components/List";
import { useResizeObserver } from "usehooks-ts";
interface SlideRangeProps {
className?: string;
description?: string;
range?: {
start?: number;
stroke?: number;
};
onSlide?: (v: number) => void;
value?: number;
step?: number;
}
const margin = 15;
export default function SlideRange(props: SlideRangeProps) {
const {
className = "",
description = "",
range = {},
value,
onSlide,
step,
} = props;
const { start = 0, stroke = 1 } = range;
const { rangeClassName, update } = useContext(ListContext);
const slideRef = useRef<HTMLDivElement>(null);
useResizeObserver({
ref: slideRef,
onResize: () => {
setProperty(value);
},
});
const transformToWidth = (x: number = start) => {
const abs = x - start;
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
const result = (abs / stroke) * maxWidth;
return result;
};
const setProperty = (value?: number) => {
const initWidth = transformToWidth(value);
slideRef.current?.style.setProperty(
"--slide-value-size",
`${initWidth + margin}px`,
);
};
useEffect(() => {
update?.({ type: "range" });
}, []);
return (
<div
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
>
{!!description && (
<div className=" text-common text-sm ">{description}</div>
)}
<div
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
ref={slideRef}
>
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
&nbsp;
</div>
<div
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
// onPointerDown={onPointerDown}
>
{value}
</div>
<input
type="range"
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
value={value}
min={start}
max={start + stroke}
step={step}
onChange={(e) => {
setProperty(e.target.valueAsNumber);
onSlide?.(e.target.valueAsNumber);
}}
style={{
marginLeft: margin,
marginRight: margin,
}}
/>
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import * as RadixSwitch from "@radix-ui/react-switch";
import { useContext } from "react";
import List from "../List";
interface SwitchProps {
value: boolean;
onChange: (v: boolean) => void;
}
export default function Switch(props: SwitchProps) {
const { value, onChange } = props;
const { switchClassName = "" } = useContext(List.ListContext);
return (
<RadixSwitch.Root
checked={value}
onCheckedChange={onChange}
className={`
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
${switchClassName}
${
value
? "bg-switch-checked justify-end"
: "bg-switch-unchecked justify-start"
}
`}
>
<RadixSwitch.Thumb
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
/>
</RadixSwitch.Root>
);
}

View File

@@ -1,27 +0,0 @@
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
export interface ThumbnailProps {
image: string;
deleteImage: () => void;
}
export default function Thumbnail(props: ThumbnailProps) {
const { image, deleteImage } = props;
return (
<div
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
style={{ backgroundImage: `url("${image}")` }}
>
<div
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
>
<div
className={`cursor-pointer flex items-center justify-center float-right`}
onClick={deleteImage}
>
<ImgDeleteIcon />
</div>
</div>
</div>
);
}

View File

@@ -6,8 +6,6 @@
width: 100%;
flex-direction: column;
background-color: var(--white);
.auth-logo {
transform: scale(1.4);
}
@@ -35,18 +33,4 @@
margin-bottom: 10px;
}
}
input[type="number"],
input[type="text"],
input[type="password"] {
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;
}
}

View File

@@ -12,7 +12,7 @@ import {
import { useChatStore } from "../store";
import Locale from "../locales";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { Path } from "../constant";
import { MaskAvatar } from "./mask";
import { Mask } from "../store/mask";
@@ -40,16 +40,12 @@ export function ChatItem(props: {
});
}
}, [props.selected]);
const { pathname: currentPath } = useLocation();
return (
<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"]
props.selected && styles["chat-item-selected"]
}`}
onClick={props.onClick}
ref={(ele) => {

View File

@@ -58,7 +58,7 @@
box-shadow: var(--card-shadow);
transition: width ease 0.3s;
align-items: center;
height: 16px;
height: 24px;
width: var(--icon-width);
overflow: hidden;
@@ -68,7 +68,6 @@
.text {
white-space: nowrap;
padding-left: 5px;
opacity: 0;
transform: translateX(-5px);
transition: all ease 0.3s;
@@ -610,10 +609,6 @@
.chat-input-send {
background-color: var(--primary);
color: white;
position: absolute;
right: 30px;
bottom: 32px;
}
@media only screen and (max-width: 600px) {

View File

@@ -59,10 +59,9 @@ import {
getMessageTextContent,
getMessageImages,
isVisionModel,
compressImage,
} from "../utils";
import { compressImage } from "@/app/utils/chat";
import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
@@ -98,7 +97,7 @@ import { ExportMessageModal } from "./exporter";
import { getClientConfig } from "../config/client";
import { useAllModels } from "../utils/hooks";
import { MultimodalContent } from "../client/api";
import SpeechRecorder from "./chat/speechRecorder";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
@@ -348,7 +347,7 @@ function ChatAction(props: {
full: 16,
icon: 16,
});
const [isActive, setIsActive] = useState(false);
function updateWidth() {
if (!iconRef.current || !textRef.current) return;
const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width;
@@ -362,25 +361,22 @@ function ChatAction(props: {
return (
<div
className={`${styles["chat-input-action"]} clickable`}
className={`${styles["chat-input-action"]} clickable group`}
onClick={() => {
props.onClick();
setTimeout(updateWidth, 1);
}}
onMouseEnter={updateWidth}
onTouchStart={updateWidth}
style={
{
"--icon-width": `${width.icon}px`,
"--full-width": `${width.full}px`,
} as React.CSSProperties
}
>
<div ref={iconRef} className={styles["icon"]}>
{props.icon}
</div>
<div className={styles["text"]} ref={textRef}>
{props.text}
<div className="flex">
<div ref={iconRef} className={styles["icon"]}>
{props.icon}
</div>
<div
className={`${styles["text"]} transition-all duration-1000 w-0 group-hover:w-[60px]`}
ref={textRef}
>
{props.text}
</div>
</div>
</div>
);
@@ -425,6 +421,7 @@ export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
setUserInput: (text: string) => void;
hitBottom: boolean;
uploading: boolean;
}) {
@@ -449,20 +446,10 @@ export function ChatActions(props: {
// switch model
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
const defaultModel = filteredModels.find((m) => m.isDefault);
if (defaultModel) {
const arr = [
defaultModel,
...filteredModels.filter((m) => m !== defaultModel),
];
return arr;
} else {
return filteredModels;
}
}, [allModels]);
const models = useMemo(
() => allModels.filter((m) => m.available),
[allModels],
);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);
@@ -478,10 +465,7 @@ export function ChatActions(props: {
// switch to first available model
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
if (isUnavaliableModel && models.length > 0) {
// show next model to default model if exist
let nextModel: ModelType = (
models.find((model) => model.isDefault) || models[0]
).name;
const nextModel = models[0].name as ModelType;
chatStore.updateCurrentSession(
(session) => (session.mask.modelConfig.model = nextModel),
);
@@ -1089,7 +1073,6 @@ function _Chat() {
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
}
accessStore.update((access) => (access.useCustomConfig = true));
});
}
} catch {
@@ -1117,13 +1100,11 @@ function _Chat() {
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handlePaste = useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const currentModel = chatStore.currentSession().mask.modelConfig.model;
if (!isVisionModel(currentModel)) {
return;
}
if(!isVisionModel(currentModel)){return;}
const items = (event.clipboardData || window.clipboardData).items;
for (const item of items) {
if (item.kind === "file" && item.type.startsWith("image/")) {
@@ -1479,6 +1460,7 @@ function _Chat() {
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
setUserInput={setUserInput}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
@@ -1539,13 +1521,19 @@ function _Chat() {
})}
</div>
)}
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
<div className="flex gap-2 absolute left-[30px] bottom-[32px]">
<SpeechRecorder textUpdater={setUserInput}></SpeechRecorder>
</div>
<div className="flex gap-2 absolute right-[30px] bottom-[32px]">
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</div>
</label>
</div>

View File

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

View File

@@ -2,9 +2,6 @@
&-body {
margin-top: 20px;
}
div:not(.no-dark) > svg {
filter: invert(0.5);
}
}
.export-content {

View File

@@ -40,7 +40,6 @@ import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { getMessageTextContent } from "../utils";
import { identifyDefaultClaudeModel } from "../utils/checkers";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
@@ -316,8 +315,6 @@ export function PreviewActions(props: {
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);
}

View File

@@ -29,7 +29,6 @@ import { AuthPage } from "./auth";
import { getClientConfig } from "../config/client";
import { ClientApi } from "../client/api";
import { useAccessStore } from "../store";
import { identifyDefaultClaudeModel } from "../utils/checkers";
export function Loading(props: { noLogo?: boolean }) {
return (
@@ -174,8 +173,6 @@ export function useLoadData() {
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);
}

View File

@@ -116,28 +116,11 @@ function escapeDollarNumber(text: string) {
return escapedText;
}
function escapeBrackets(text: string) {
const pattern =
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
return text.replace(
pattern,
(match, codeBlock, squareBracket, roundBracket) => {
if (codeBlock) {
return codeBlock;
} else if (squareBracket) {
return `$$${squareBracket}$$`;
} else if (roundBracket) {
return `$${roundBracket}$`;
}
return match;
},
);
}
function _MarkDownContent(props: { content: string }) {
const escapedContent = useMemo(() => {
return escapeBrackets(escapeDollarNumber(props.content));
}, [props.content]);
const escapedContent = useMemo(
() => escapeDollarNumber(props.content),
[props.content],
);
return (
<ReactMarkdown
@@ -177,14 +160,13 @@ export function Markdown(
fontSize?: number;
parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean;
className?: string;
} & React.DOMAttributes<HTMLDivElement>,
) {
const mdRef = useRef<HTMLDivElement>(null);
return (
<div
className={`markdown-body ${props.className}`}
className="markdown-body"
style={{
fontSize: `${props.fontSize ?? 14}px`,
}}

View File

@@ -4,10 +4,6 @@
display: flex;
flex-direction: column;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
.mask-page-body {
padding: 20px;
overflow-y: auto;

View File

@@ -1,4 +1,5 @@
import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss";
@@ -55,7 +56,6 @@ import {
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { getMessageTextContent } from "../utils";
import useMobileScreen from "@/app/hooks/useMobileScreen";
// drag and drop helper function
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
@@ -398,22 +398,13 @@ export function ContextPrompts(props: {
);
}
export function MaskPage(props: { className?: string }) {
export function MaskPage() {
const navigate = useNavigate();
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, setFilterLang] = useState<Lang>();
const allMasks = maskStore
.getAll()
@@ -466,13 +457,8 @@ export function MaskPage(props: { className?: string }) {
};
return (
<>
<div
className={`
${styles["mask-page"]}
${props.className}
`}
>
<ErrorBoundary>
<div className={styles["mask-page"]}>
<div className="window-header">
<div className="window-header-title">
<div className="window-header-main-title">
@@ -650,6 +636,6 @@ export function MaskPage(props: { className?: string }) {
</Modal>
</div>
)}
</>
</ErrorBoundary>
);
}

View File

@@ -227,7 +227,7 @@ export function MessageSelector(props: {
</div>
<div className={styles["checkbox"]}>
<input type="checkbox" checked={isSelected} readOnly></input>
<input type="checkbox" checked={isSelected}></input>
</div>
</div>
);

View File

@@ -8,10 +8,6 @@
justify-content: center;
flex-direction: column;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
.mask-header {
display: flex;
justify-content: space-between;

View File

@@ -16,7 +16,6 @@ import { MaskAvatar } from "./mask";
import { useCommand } from "../command";
import { showConfirm } from "./ui-lib";
import { BUILTIN_MASK_STORE } from "../masks";
import useMobileScreen from "@/app/hooks/useMobileScreen";
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
return (
@@ -72,7 +71,7 @@ function useMaskGroup(masks: Mask[]) {
return groups;
}
export function NewChat(props: { className?: string }) {
export function NewChat() {
const chatStore = useChatStore();
const maskStore = useMaskStore();
@@ -111,15 +110,8 @@ export function NewChat(props: { className?: string }) {
}
}, [groups]);
const isMobileScreen = useMobileScreen();
return (
<div
className={`
${styles["new-chat"]}
${props.className}
`}
>
<div className={styles["new-chat"]}>
<div className={styles["mask-header"]}>
<IconButton
icon={<LeftIcon />}

View File

@@ -51,7 +51,6 @@ import Locale, {
import { copyToClipboard } from "../utils";
import Link from "next/link";
import {
Anthropic,
Azure,
Google,
OPENAI_BASE_URL,
@@ -964,7 +963,7 @@ export function Settings() {
</Select>
</ListItem>
{accessStore.provider === ServiceProvider.OpenAI && (
{accessStore.provider === "OpenAI" ? (
<>
<ListItem
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
@@ -1003,8 +1002,7 @@ export function Settings() {
/>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Azure && (
) : accessStore.provider === "Azure" ? (
<>
<ListItem
title={Locale.Settings.Access.Azure.Endpoint.Title}
@@ -1063,8 +1061,7 @@ export function Settings() {
></input>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Google && (
) : accessStore.provider === "Google" ? (
<>
<ListItem
title={Locale.Settings.Access.Google.Endpoint.Title}
@@ -1123,70 +1120,7 @@ export function Settings() {
></input>
</ListItem>
</>
)}
{accessStore.provider === ServiceProvider.Anthropic && (
<>
<ListItem
title={Locale.Settings.Access.Anthropic.Endpoint.Title}
subTitle={
Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
Anthropic.ExampleEndpoint
}
>
<input
type="text"
value={accessStore.anthropicUrl}
placeholder={Anthropic.ExampleEndpoint}
onChange={(e) =>
accessStore.update(
(access) =>
(access.anthropicUrl = e.currentTarget.value),
)
}
></input>
</ListItem>
<ListItem
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
subTitle={
Locale.Settings.Access.Anthropic.ApiKey.SubTitle
}
>
<PasswordInput
value={accessStore.anthropicApiKey}
type="text"
placeholder={
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
}
onChange={(e) => {
accessStore.update(
(access) =>
(access.anthropicApiKey =
e.currentTarget.value),
);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
subTitle={
Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
}
>
<input
type="text"
value={accessStore.anthropicApiVersion}
placeholder={Anthropic.Vision}
onChange={(e) =>
accessStore.update(
(access) =>
(access.anthropicApiVersion =
e.currentTarget.value),
)
}
></input>
</ListItem>
</>
)}
) : null}
</>
)}
</>

View File

@@ -101,7 +101,6 @@ interface ModalProps {
defaultMax?: boolean;
footer?: React.ReactNode;
onClose?: () => void;
className?: string;
}
export function Modal(props: ModalProps) {
useEffect(() => {
@@ -123,14 +122,14 @@ export function Modal(props: ModalProps) {
return (
<div
className={`${styles["modal-container"]} ${
isMax && styles["modal-container-max"]
} ${props.className ?? ""}`}
className={
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
}
>
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
<div className={`${styles["modal-title"]}`}>{props.title}</div>
<div className={styles["modal-header"]}>
<div className={styles["modal-title"]}>{props.title}</div>
<div className={`${styles["modal-header-actions"]}`}>
<div className={styles["modal-header-actions"]}>
<div
className={styles["modal-header-action"]}
onClick={() => setMax(!isMax)}
@@ -148,11 +147,11 @@ export function Modal(props: ModalProps) {
<div className={styles["modal-content"]}>{props.children}</div>
<div className={`${styles["modal-footer"]} new-footer`}>
<div className={styles["modal-footer"]}>
{props.footer}
<div className={styles["modal-actions"]}>
{props.actions?.map((action, i) => (
<div key={i} className={`${styles["modal-action"]} new-btn`}>
<div key={i} className={styles["modal-action"]}>
{action}
</div>
))}

View File

@@ -1,5 +1,4 @@
import tauriConfig from "../../src-tauri/tauri.conf.json";
import { DEFAULT_INPUT_TEMPLATE } from "../constant";
export const getBuildConfig = () => {
if (typeof process === "undefined") {
@@ -39,7 +38,6 @@ export const getBuildConfig = () => {
...commitInfo,
buildMode,
isApp,
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
};
};

View File

@@ -21,7 +21,6 @@ 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
// azure only
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
@@ -34,9 +33,6 @@ declare global {
// google tag manager
GTM_ID?: string;
// custom template for preprocessing user input
DEFAULT_INPUT_TEMPLATE?: string;
}
}
}
@@ -54,25 +50,6 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
}
})();
function getApiKey(keys?: string) {
if (!keys) {
return;
}
const apiKeyEnvVar = keys;
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
const randomIndex = Math.floor(Math.random() * apiKeys.length);
const apiKey = apiKeys[randomIndex];
if (apiKey) {
console.log(
`[Server Config] using ${randomIndex + 1} of ${
apiKeys.length
} api key - ${apiKey}`,
);
}
return apiKey;
}
export const getServerSideConfig = () => {
if (typeof process === "undefined") {
throw Error(
@@ -82,51 +59,39 @@ export const getServerSideConfig = () => {
const disableGPT4 = !!process.env.DISABLE_GPT4;
let customModels = process.env.CUSTOM_MODELS ?? "";
let defaultModel = process.env.DEFAULT_MODEL ?? "";
if (disableGPT4) {
if (customModels) customModels += ",";
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
.map((m) => "-" + m.name)
.join(",");
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
}
const isAzure = !!process.env.AZURE_URL;
const isGoogle = !!process.env.GOOGLE_API_KEY;
const isAnthropic = !!process.env.ANTHROPIC_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);
// const apiKey = apiKeys[randomIndex];
// console.log(
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
// );
const allowedWebDevEndpoints = (
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
).split(",");
const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
const randomIndex = Math.floor(Math.random() * apiKeys.length);
const apiKey = apiKeys[randomIndex];
console.log(
`[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
);
return {
baseUrl: process.env.BASE_URL,
apiKey: getApiKey(process.env.OPENAI_API_KEY),
apiKey,
openaiOrgId: process.env.OPENAI_ORG_ID,
isAzure,
azureUrl: process.env.AZURE_URL,
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
azureApiKey: process.env.AZURE_API_KEY,
azureApiVersion: process.env.AZURE_API_VERSION,
isGoogle,
googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
googleApiKey: process.env.GOOGLE_API_KEY,
googleUrl: process.env.GOOGLE_URL,
isAnthropic,
anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
anthropicUrl: process.env.ANTHROPIC_URL,
gtmId: process.env.GTM_ID,
needCode: ACCESS_CODES.size > 0,
@@ -141,7 +106,5 @@ export const getServerSideConfig = () => {
hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
disableFastLink: !!process.env.DISABLE_FAST_LINK,
customModels,
defaultModel,
allowedWebDevEndpoints,
};
};

View File

@@ -10,7 +10,6 @@ export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
export const DEFAULT_API_HOST = "https://api.nextchat.dev";
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/";
@@ -26,7 +25,6 @@ export enum Path {
export enum ApiPath {
Cors = "",
OpenAI = "/api/openai",
Anthropic = "/api/anthropic",
}
export enum SlotID {
@@ -47,20 +45,12 @@ export enum StoreKey {
Prompt = "prompt-store",
Update = "chat-update",
Sync = "sync",
Provider = "provider",
}
export const NARROW_SIDEBAR_WIDTH = 100;
export const DEFAULT_SIDEBAR_WIDTH = 340;
export const MAX_SIDEBAR_WIDTH = 440;
export const DEFAULT_SIDEBAR_WIDTH = 300;
export const MAX_SIDEBAR_WIDTH = 500;
export const MIN_SIDEBAR_WIDTH = 230;
export const WINDOW_WIDTH_SM = 480;
export const WINDOW_WIDTH_MD = 768;
export const WINDOW_WIDTH_LG = 1120;
export const WINDOW_WIDTH_XL = 1440;
export const WINDOW_WIDTH_2XL = 1980;
export const NARROW_SIDEBAR_WIDTH = 100;
export const ACCESS_CODE_PREFIX = "nk-";
@@ -77,22 +67,13 @@ export enum ServiceProvider {
OpenAI = "OpenAI",
Azure = "Azure",
Google = "Google",
Anthropic = "Anthropic",
}
export enum ModelProvider {
GPT = "GPT",
GeminiPro = "GeminiPro",
Claude = "Claude",
}
export const Anthropic = {
ChatPath: "v1/messages",
ChatPath1: "v1/complete",
ExampleEndpoint: "https://api.anthropic.com",
Vision: "2023-06-01",
};
export const OpenaiPath = {
ChatPath: "v1/chat/completions",
UsagePath: "dashboard/billing/usage",
@@ -106,24 +87,19 @@ export const Azure = {
export const Google = {
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
ChatPath: "v1beta/models/gemini-pro:generateContent",
VisionChatPath: "v1beta/models/gemini-pro-vision:generateContent",
// /api/openai/v1/chat/completions
};
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
// export const DEFAULT_SYSTEM_TEMPLATE = `
// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
// Knowledge cutoff: {{cutoff}}
// Current model: {{model}}
// Current time: {{time}}
// Latex inline: $x^2$
// Latex block: $$e=mc^2$$
// `;
export const DEFAULT_SYSTEM_TEMPLATE = `
You are ChatGPT, a large language model trained by {{ServiceProvider}}.
Knowledge cutoff: {{cutoff}}
Current model: {{model}}
Current time: {{time}}
Latex inline: \\(x^2\\)
Latex inline: $x^2$
Latex block: $$e=mc^2$$
`;
@@ -132,93 +108,188 @@ export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
export const KnowledgeCutOffDate: Record<string, string> = {
default: "2021-09",
"gpt-4-turbo": "2023-12",
"gpt-4-turbo-2024-04-09": "2023-12",
"gpt-4-turbo-preview": "2023-12",
"gpt-4o": "2023-10",
"gpt-4o-2024-05-13": "2023-10",
"gpt-4-1106-preview": "2023-04",
"gpt-4-0125-preview": "2023-12",
"gpt-4-vision-preview": "2023-04",
// After improvements,
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
"gemini-pro": "2023-12",
"gemini-pro-vision": "2023-12",
};
const openaiModels = [
"gpt-3.5-turbo",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-0125",
"gpt-4",
"gpt-4-0613",
"gpt-4-32k",
"gpt-4-32k-0613",
"gpt-4-turbo",
"gpt-4-turbo-preview",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4-vision-preview",
"gpt-4-turbo-2024-04-09",
];
const googleModels = [
"gemini-1.0-pro",
"gemini-1.5-pro-latest",
"gemini-1.5-flash-latest",
"gemini-pro-vision",
];
const anthropicModels = [
"claude-instant-1.2",
"claude-2.0",
"claude-2.1",
"claude-3-sonnet-20240229",
"claude-3-opus-20240229",
"claude-3-haiku-20240307",
];
export const DEFAULT_MODELS = [
...openaiModels.map((name) => ({
name,
{
name: "gpt-4",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
})),
...googleModels.map((name) => ({
name,
},
{
name: "gpt-4-0314",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-0613",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-32k",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-32k-0314",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-32k-0613",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-turbo-preview",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-1106-preview",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-0125-preview",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-4-vision-preview",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo-0125",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo-0301",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo-0613",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo-1106",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo-16k",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gpt-3.5-turbo-16k-0613",
available: true,
provider: {
id: "openai",
providerName: "OpenAI",
providerType: "openai",
},
},
{
name: "gemini-pro",
available: true,
provider: {
id: "google",
providerName: "Google",
providerType: "google",
},
})),
...anthropicModels.map((name) => ({
name,
},
{
name: "gemini-pro-vision",
available: true,
provider: {
id: "anthropic",
providerName: "Anthropic",
providerType: "anthropic",
id: "google",
providerName: "Google",
providerType: "google",
},
})),
},
] as const;
export const CHAT_PAGE_SIZE = 15;
export const MAX_RENDER_MSG_COUNT = 45;
// some famous webdav endpoints
export const internalAllowedWebDavEndpoints = [
"https://dav.jianguoyun.com/dav/",
"https://dav.dropdav.com/",
"https://dav.box.com/dav",
"https://nanao.teracloud.jp/dav/",
"https://webdav.4shared.com/",
"https://dav.idrivesync.com",
"https://webdav.yandex.com",
"https://app.koofr.net/dav/Koofr",
];
export const SIDEBAR_ID = "sidebar";

View File

@@ -1,300 +0,0 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import {
useChatStore,
BOT_HELLO,
createMessage,
useAccessStore,
useAppConfig,
ModelType,
} from "@/app/store";
import Locale from "@/app/locales";
import { showConfirm } from "@/app/components/ui-lib";
import {
CHAT_PAGE_SIZE,
REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT,
} from "@/app/constant";
import { useCommand } from "@/app/command";
import { prettyObject } from "@/app/utils/format";
import { ExportMessageModal } from "@/app/components/exporter";
import PromptToast from "./components/PromptToast";
import { EditMessageModal } from "./components/EditMessageModal";
import ChatHeader from "./components/ChatHeader";
import ChatInputPanel, {
ChatInputPanelInstance,
} from "./components/ChatInputPanel";
import ChatMessagePanel, { RenderMessage } from "./components/ChatMessagePanel";
import useRows from "@/app/hooks/useRows";
import SessionConfigModel from "./components/SessionConfigModal";
import useScrollToBottom from "@/app/hooks/useScrollToBottom";
function _Chat() {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const { isMobileScreen } = config;
const [showExport, setShowExport] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [userInput, setUserInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const chatInputPanelRef = useRef<ChatInputPanelInstance | null>(null);
const [hitBottom, setHitBottom] = useState(true);
const [attachImages, setAttachImages] = useState<string[]>([]);
// auto grow input
const { measure, inputRows } = useRows({
inputRef,
});
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(measure, [userInput]);
useEffect(() => {
chatStore.updateCurrentSession((session) => {
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
session.messages.forEach((m) => {
// check if should stop all stale messages
if (m.isError || new Date(m.date).getTime() < stopTiming) {
if (m.streaming) {
m.streaming = false;
}
if (m.content.length === 0) {
m.isError = true;
m.content = prettyObject({
error: true,
message: "empty response",
});
}
}
});
// auto sync mask config from global config
if (session.mask.syncGlobalConfig) {
console.log("[Mask] syncing from global, name = ", session.mask.name);
session.mask.modelConfig = { ...config.modelConfig };
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const context: RenderMessage[] = useMemo(() => {
return session.mask.hideContext ? [] : session.mask.context.slice();
}, [session.mask.context, session.mask.hideContext]);
const accessStore = useAccessStore();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
const copiedHello = Object.assign({}, BOT_HELLO);
if (!accessStore.isAuthorized()) {
copiedHello.content = Locale.Error.Unauthorized;
}
context.push(copiedHello);
}
// preview messages
const renderMessages = useMemo(() => {
return context
.concat(session.messages as RenderMessage[])
.concat(
isLoading
? [
{
...createMessage({
role: "assistant",
content: "……",
}),
preview: true,
},
]
: [],
)
.concat(
userInput.length > 0 && config.sendPreviewBubble
? [
{
...createMessage(
{
role: "user",
content: userInput,
},
{
customId: "typing",
},
),
preview: true,
},
]
: [],
);
}, [
config.sendPreviewBubble,
context,
isLoading,
session.messages,
userInput,
]);
const [msgRenderIndex, _setMsgRenderIndex] = useState(
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
);
const [showPromptModal, setShowPromptModal] = useState(false);
useCommand({
fill: setUserInput,
submit: (text) => {
chatInputPanelRef.current?.doSubmit(text);
},
code: (text) => {
if (accessStore.disableFastLink) return;
console.log("[Command] got code from url: ", text);
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
if (res) {
accessStore.update((access) => (access.accessCode = text));
}
});
},
settings: (text) => {
if (accessStore.disableFastLink) return;
try {
const payload = JSON.parse(text) as {
key?: string;
url?: string;
};
console.log("[Command] got settings from url: ", payload);
if (payload.key || payload.url) {
showConfirm(
Locale.URLCommand.Settings +
`\n${JSON.stringify(payload, null, 4)}`,
).then((res) => {
if (!res) return;
if (payload.key) {
accessStore.update(
(access) => (access.openaiApiKey = payload.key!),
);
}
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
}
});
}
} catch {
console.error("[Command] failed to get settings from url: ", text);
}
},
});
// edit / insert message modal
const [isEditingMessage, setIsEditingMessage] = useState(false);
// remember unfinished input
useEffect(() => {
// try to load from local storage
const key = UNFINISHED_INPUT(session.id);
const mayBeUnfinishedInput = localStorage.getItem(key);
if (mayBeUnfinishedInput && userInput.length === 0) {
setUserInput(mayBeUnfinishedInput);
localStorage.removeItem(key);
}
const dom = inputRef.current;
return () => {
localStorage.setItem(key, dom?.value ?? "");
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const chatinputPanelProps = {
inputRef,
isMobileScreen,
renderMessages,
attachImages,
userInput,
hitBottom,
inputRows,
setAttachImages,
setUserInput,
setIsLoading,
showChatSetting: setShowPromptModal,
_setMsgRenderIndex,
scrollDomToBottom,
setAutoScroll,
};
const chatMessagePanelProps = {
scrollRef,
inputRef,
isMobileScreen,
msgRenderIndex,
userInput,
context,
renderMessages,
setAutoScroll,
setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex,
setHitBottom,
setUserInput,
setIsLoading,
setShowPromptModal,
scrollDomToBottom,
};
return (
<div
className={`
relative flex flex-col overflow-hidden bg-chat-panel
max-md:absolute max-md:h-[100vh] max-md:w-[100%]
md:h-[100%] md:mr-2.5 md:rounded-md
`}
key={session.id}
>
<ChatHeader
setIsEditingMessage={setIsEditingMessage}
setShowExport={setShowExport}
isMobileScreen={isMobileScreen}
/>
<ChatMessagePanel {...chatMessagePanelProps} />
<ChatInputPanel ref={chatInputPanelRef} {...chatinputPanelProps} />
{showExport && (
<ExportMessageModal onClose={() => setShowExport(false)} />
)}
{isEditingMessage && (
<EditMessageModal
onClose={() => {
setIsEditingMessage(false);
}}
/>
)}
<PromptToast showToast={!hitBottom} setShowModal={setShowPromptModal} />
{showPromptModal && (
<SessionConfigModel onClose={() => setShowPromptModal(false)} />
)}
</div>
);
}
export default function Chat() {
const chatStore = useChatStore();
const sessionIndex = chatStore.currentSessionIndex;
return <_Chat key={sessionIndex}></_Chat>;
}

View File

@@ -1,277 +0,0 @@
import { useNavigate } from "react-router-dom";
import { ModelType, Theme, useAppConfig } from "@/app/store/config";
import { useChatStore } from "@/app/store/chat";
import { ChatControllerPool } from "@/app/client/controller";
import { useAllModels } from "@/app/utils/hooks";
import { useEffect, useMemo, useState } from "react";
import { isVisionModel } from "@/app/utils";
import { showToast } from "@/app/components/ui-lib";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import BottomIcon from "@/app/icons/bottom.svg";
import StopIcon from "@/app/icons/pause.svg";
import LoadingButtonIcon from "@/app/icons/loading.svg";
import PromptIcon from "@/app/icons/comandIcon.svg";
import MaskIcon from "@/app/icons/maskIcon.svg";
import BreakIcon from "@/app/icons/eraserIcon.svg";
import SettingsIcon from "@/app/icons/configIcon.svg";
import ImageIcon from "@/app/icons/uploadImgIcon.svg";
import AddCircleIcon from "@/app/icons/addCircle.svg";
import Popover from "@/app/components/Popover";
import ModelSelect from "./ModelSelect";
export interface Action {
onClick?: () => void;
text: string;
isShow: boolean;
render?: (key: string) => JSX.Element;
icon?: JSX.Element;
placement: "left" | "right";
className?: string;
}
export function ChatActions(props: {
uploadImage: () => void;
setAttachImages: (images: string[]) => void;
setUploading: (uploading: boolean) => void;
showChatSetting: () => void;
scrollToBottom: () => void;
showPromptHints: () => void;
hitBottom: boolean;
uploading: boolean;
isMobileScreen: boolean;
className?: string;
}) {
const config = useAppConfig();
const navigate = useNavigate();
const chatStore = useChatStore();
// switch themes
const theme = config.theme;
function nextTheme() {
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
const themeIndex = themes.indexOf(theme);
const nextIndex = (themeIndex + 1) % themes.length;
const nextTheme = themes[nextIndex];
config.update((config) => (config.theme = nextTheme));
}
// stop all responses
const couldStop = ChatControllerPool.hasPending();
const stopAll = () => ChatControllerPool.stopAll();
// switch model
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(
() => allModels.filter((m) => m.available),
[allModels],
);
const [showUploadImage, setShowUploadImage] = useState(false);
useEffect(() => {
const show = isVisionModel(currentModel);
setShowUploadImage(show);
if (!show) {
props.setAttachImages([]);
props.setUploading(false);
}
// if current model is not available
// switch to first available model
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
if (isUnavaliableModel && models.length > 0) {
const nextModel = models[0].name as ModelType;
chatStore.updateCurrentSession(
(session) => (session.mask.modelConfig.model = nextModel),
);
showToast(nextModel);
}
}, [chatStore, currentModel, models]);
const actions: Action[] = [
{
onClick: stopAll,
text: Locale.Chat.InputActions.Stop,
isShow: couldStop,
icon: <StopIcon />,
placement: "left",
},
{
text: currentModel,
isShow: !props.isMobileScreen,
render: (key: string) => <ModelSelect key={key} />,
placement: "left",
},
{
onClick: props.scrollToBottom,
text: Locale.Chat.InputActions.ToBottom,
isShow: !props.hitBottom,
icon: <BottomIcon />,
placement: "left",
},
{
onClick: props.uploadImage,
text: Locale.Chat.InputActions.UploadImage,
isShow: showUploadImage,
icon: props.uploading ? <LoadingButtonIcon /> : <ImageIcon />,
placement: "left",
},
// {
// onClick: nextTheme,
// text: Locale.Chat.InputActions.Theme[theme],
// isShow: true,
// icon: (
// <>
// {theme === Theme.Auto ? (
// <AutoIcon />
// ) : theme === Theme.Light ? (
// <LightIcon />
// ) : theme === Theme.Dark ? (
// <DarkIcon />
// ) : null}
// </>
// ),
// placement: "left",
// },
{
onClick: props.showPromptHints,
text: Locale.Chat.InputActions.Prompt,
isShow: true,
icon: <PromptIcon />,
placement: "left",
},
{
onClick: () => {
navigate(Path.Masks);
},
text: Locale.Chat.InputActions.Masks,
isShow: true,
icon: <MaskIcon />,
placement: "left",
},
{
onClick: () => {
chatStore.updateCurrentSession((session) => {
if (session.clearContextIndex === session.messages.length) {
session.clearContextIndex = undefined;
} else {
session.clearContextIndex = session.messages.length;
session.memoryPrompt = ""; // will clear memory
}
});
},
text: Locale.Chat.InputActions.Clear,
isShow: true,
icon: <BreakIcon />,
placement: "right",
},
{
onClick: props.showChatSetting,
text: Locale.Chat.InputActions.Settings,
isShow: true,
icon: <SettingsIcon />,
placement: "right",
},
];
if (props.isMobileScreen) {
const content = (
<div className="w-[100%]">
{actions
.filter((v) => v.isShow && v.icon)
.map((act) => {
return (
<div
key={act.text}
className={`flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer follow-parent-svg default-icon-color`}
onClick={act.onClick}
>
{act.icon}
<div className="flex-1 font-common text-actions-popover-menu-item">
{act.text}
</div>
</div>
);
})}
</div>
);
return (
<Popover
content={content}
trigger="click"
placement="rt"
noArrow
popoverClassName="border border-chat-actions-popover-mobile rounded-md shadow-chat-actions-popover-mobile w-actions-popover bg-chat-actions-popover-panel-mobile "
className=" cursor-pointer follow-parent-svg default-icon-color"
>
<AddCircleIcon />
</Popover>
);
}
const popoverClassName = `bg-chat-actions-btn-popover px-3 py-2.5 text-text-chat-actions-btn-popover text-sm-title rounded-md`;
return (
<div className={`flex gap-2 item-center ${props.className}`}>
{actions
.filter((v) => v.placement === "left" && v.isShow)
.map((act, ind) => {
if (act.render) {
return (
<div className={`${act.className ?? ""}`} key={act.text}>
{act.render(act.text)}
</div>
);
}
return (
<Popover
key={act.text}
content={act.text}
popoverClassName={`${popoverClassName}`}
placement={ind ? "t" : "lt"}
className={`${act.className ?? ""}`}
>
<div
className={`
cursor-pointer h-[32px] w-[32px] flex items-center justify-center transition duration-300 ease-in-out
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
follow-parent-svg default-icon-color
`}
onClick={act.onClick}
>
{act.icon}
</div>
</Popover>
);
})}
<div className="flex-1"></div>
{actions
.filter((v) => v.placement === "right" && v.isShow)
.map((act, ind, arr) => {
return (
<Popover
key={act.text}
content={act.text}
popoverClassName={`${popoverClassName}`}
placement={ind === arr.length - 1 ? "rt" : "t"}
>
<div
className={`
cursor-pointer h-[32px] w-[32px] flex items-center transition duration-300 ease-in-out justify-center
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
follow-parent-svg default-icon-color
`}
onClick={act.onClick}
>
{act.icon}
</div>
</Popover>
);
})}
</div>
);
}

View File

@@ -1,91 +0,0 @@
import { useNavigate } from "react-router-dom";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import { DEFAULT_TOPIC, useChatStore } from "@/app/store/chat";
import LogIcon from "@/app/icons/logIcon.svg";
import GobackIcon from "@/app/icons/goback.svg";
import ShareIcon from "@/app/icons/shareIcon.svg";
import ModelSelect from "./ModelSelect";
export interface ChatHeaderProps {
isMobileScreen: boolean;
setIsEditingMessage: (v: boolean) => void;
setShowExport: (v: boolean) => void;
}
export default function ChatHeader(props: ChatHeaderProps) {
const { isMobileScreen, setIsEditingMessage, setShowExport } = props;
const navigate = useNavigate();
const chatStore = useChatStore();
const session = chatStore.currentSession();
return (
<div
className={`
absolute w-[100%] backdrop-blur-[30px] z-20 flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap
sm:border-b sm:border-chat-header-bottom
max-md:h-menu-title-mobile
`}
data-tauri-drag-region
>
<div
className={`absolute z-[-1] top-0 left-0 w-[100%] h-[100%] opacity-85 backdrop-blur-[20px] sm:bg-chat-panel-header-mask bg-chat-panel-header-mobile flex flex-0 justify-between items-center gap-chat-header-gap`}
>
{" "}
</div>
{isMobileScreen ? (
<div
className=" cursor-pointer follow-parent-svg default-icon-color"
onClick={() => navigate(Path.Home)}
>
<GobackIcon />
</div>
) : (
<LogIcon />
)}
<div
className={`
flex-1
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
md:mr-4
`}
>
<div
className={`
line-clamp-1 cursor-pointer text-text-chat-header-title text-chat-header-title font-common
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5
`}
onClickCapture={() => setIsEditingMessage(true)}
>
{!session.topic ? DEFAULT_TOPIC : session.topic}
</div>
<div
className={`
text-text-chat-header-subtitle text-sm
max-md:text-sm-mobile-tab max-md:leading-4
`}
>
{isMobileScreen ? (
<ModelSelect />
) : (
Locale.Chat.SubTitle(session.messages.length)
)}
</div>
</div>
<div
className=" cursor-pointer hover:bg-hovered-btn p-1.5 rounded-action-btn follow-parent-svg default-icon-color"
onClick={() => {
setShowExport(true);
}}
>
<ShareIcon />
</div>
</div>
);
}

View File

@@ -1,322 +0,0 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
import useUploadImage from "@/app/hooks/useUploadImage";
import Locale from "@/app/locales";
import useSubmitHandler from "@/app/hooks/useSubmitHandler";
import { CHAT_PAGE_SIZE, LAST_INPUT_KEY, Path } from "@/app/constant";
import { ChatCommandPrefix, useChatCommand } from "@/app/command";
import { useChatStore } from "@/app/store/chat";
import { usePromptStore } from "@/app/store/prompt";
import { useAppConfig } from "@/app/store/config";
import usePaste from "@/app/hooks/usePaste";
import { ChatActions } from "./ChatActions";
import PromptHints, { RenderPompt } from "./PromptHint";
// import CEIcon from "@/app/icons/command&enterIcon.svg";
// import EnterIcon from "@/app/icons/enterIcon.svg";
import SendIcon from "@/app/icons/sendIcon.svg";
import Btn from "@/app/components/Btn";
import Thumbnail from "@/app/components/ThumbnailImg";
export interface ChatInputPanelProps {
inputRef: React.RefObject<HTMLTextAreaElement>;
isMobileScreen: boolean;
renderMessages: any[];
attachImages: string[];
userInput: string;
hitBottom: boolean;
inputRows: number;
setAttachImages: (imgs: string[]) => void;
setUserInput: (v: string) => void;
setIsLoading: (value: boolean) => void;
showChatSetting: (value: boolean) => void;
_setMsgRenderIndex: (value: number) => void;
setAutoScroll: (value: boolean) => void;
scrollDomToBottom: () => void;
}
export interface ChatInputPanelInstance {
setUploading: (v: boolean) => void;
doSubmit: (userInput: string) => void;
setMsgRenderIndex: (v: number) => void;
}
// only search prompts when user input is short
const SEARCH_TEXT_LIMIT = 30;
export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
function ChatInputPanel(props, ref) {
const {
attachImages,
inputRef,
setAttachImages,
userInput,
isMobileScreen,
setUserInput,
setIsLoading,
showChatSetting,
renderMessages,
_setMsgRenderIndex,
hitBottom,
inputRows,
setAutoScroll,
scrollDomToBottom,
} = props;
const [uploading, setUploading] = useState(false);
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
const chatStore = useChatStore();
const navigate = useNavigate();
const config = useAppConfig();
const { uploadImage } = useUploadImage(attachImages, {
emitImages: setAttachImages,
setUploading,
});
const { submitKey, shouldSubmit } = useSubmitHandler();
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
// chat commands shortcuts
const chatCommands = useChatCommand({
new: () => chatStore.newSession(),
newm: () => navigate(Path.NewChat),
prev: () => chatStore.nextSession(-1),
next: () => chatStore.nextSession(1),
clear: () =>
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = session.messages.length),
),
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
});
// prompt hints
const promptStore = usePromptStore();
const onSearch = useDebouncedCallback(
(text: string) => {
const matchedPrompts = promptStore.search(text);
setPromptHints(matchedPrompts);
},
100,
{ leading: true, trailing: true },
);
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// if ArrowUp and no userInput, fill with last input
if (
e.key === "ArrowUp" &&
userInput.length <= 0 &&
!(e.metaKey || e.altKey || e.ctrlKey)
) {
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
e.preventDefault();
return;
}
if (shouldSubmit(e) && promptHints.length === 0) {
doSubmit(userInput);
e.preventDefault();
}
};
const onPromptSelect = (prompt: RenderPompt) => {
setTimeout(() => {
setPromptHints([]);
const matchedChatCommand = chatCommands.match(prompt.content);
if (matchedChatCommand.matched) {
// if user is selecting a chat command, just trigger it
matchedChatCommand.invoke();
setUserInput("");
} else {
// or fill the prompt
setUserInput(prompt.content);
}
inputRef.current?.focus();
}, 30);
};
const doSubmit = (userInput: string) => {
if (userInput.trim() === "") return;
const matchCommand = chatCommands.match(userInput);
if (matchCommand.matched) {
setUserInput("");
setPromptHints([]);
matchCommand.invoke();
return;
}
setIsLoading(true);
chatStore
.onUserInput(userInput, attachImages)
.then(() => setIsLoading(false));
setAttachImages([]);
localStorage.setItem(LAST_INPUT_KEY, userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen) inputRef.current?.focus();
setAutoScroll(true);
};
useImperativeHandle(ref, () => ({
setUploading,
doSubmit,
setMsgRenderIndex,
}));
function scrollToBottom() {
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
scrollDomToBottom();
}
const onInput = (text: string) => {
setUserInput(text);
const n = text.trim().length;
// clear search results
if (n === 0) {
setPromptHints([]);
} else if (text.startsWith(ChatCommandPrefix)) {
setPromptHints(chatCommands.search(text));
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/")) {
let searchText = text.slice(1);
onSearch(searchText);
}
}
};
function setMsgRenderIndex(newIndex: number) {
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
newIndex = Math.max(0, newIndex);
_setMsgRenderIndex(newIndex);
}
const { handlePaste } = usePaste(attachImages, {
emitImages: setAttachImages,
setUploading,
});
return (
<div
className={`
relative w-[100%] box-border
max-md:rounded-tl-md max-md:rounded-tr-md
md:border-t md:border-chat-input-top
`}
>
<PromptHints
prompts={promptHints}
onPromptSelect={onPromptSelect}
className=" border-chat-input-top"
/>
<div
className={`
flex
max-md:flex-row-reverse max-md:items-center max-md:gap-2 max-md:p-3
md:flex-col md:px-5 md:pb-5
`}
>
<ChatActions
uploadImage={uploadImage}
setAttachImages={setAttachImages}
setUploading={setUploading}
showChatSetting={() => showChatSetting(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
uploading={uploading}
showPromptHints={() => {
// Click again to close
if (promptHints.length > 0) {
setPromptHints([]);
return;
}
inputRef.current?.focus();
setUserInput("/");
onSearch("");
}}
className={`
md:py-2.5
`}
isMobileScreen={isMobileScreen}
/>
<label
className={`
cursor-text flex flex-col bg-chat-panel-input-hood border border-chat-input-hood
focus-within:border-chat-input-hood-focus sm:focus-within:shadow-chat-input-hood-focus-shadow
rounded-chat-input p-3 gap-3 max-md:flex-1
md:rounded-md md:p-4 md:gap-4
`}
htmlFor="chat-input"
>
{attachImages.length != 0 && (
<div className={`flex gap-2`}>
{attachImages.map((image, index) => {
return (
<Thumbnail
key={index}
deleteImage={() => {
setAttachImages(
attachImages.filter((_, i) => i !== index),
);
}}
image={image}
/>
);
})}
</div>
)}
<textarea
id="chat-input"
ref={inputRef}
className={`
leading-[19px] flex-1 focus:outline-none focus:shadow-none focus:border-none resize-none bg-inherit text-text-input
max-md:h-chat-input-mobile
md:min-h-chat-input
`}
placeholder={
isMobileScreen
? Locale.Chat.Input(submitKey, isMobileScreen)
: undefined
}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}
onFocus={scrollToBottom}
onClick={scrollToBottom}
onPaste={handlePaste}
rows={inputRows}
autoFocus={autoFocus}
style={{
fontSize: config.fontSize,
}}
/>
{!isMobileScreen && (
<div className="flex items-center justify-center text-sm gap-3">
<div className="flex-1">&nbsp;</div>
<div className="text-text-chat-input-placeholder font-common line-clamp-1">
{Locale.Chat.Input(submitKey)}
</div>
<Btn
className="min-w-[77px]"
icon={<SendIcon />}
text={Locale.Chat.Send}
disabled={!userInput.length}
type="primary"
onClick={() => doSubmit(userInput)}
/>
</div>
)}
</label>
</div>
</div>
);
},
);

View File

@@ -1,246 +0,0 @@
import { Fragment, useMemo } from "react";
import { ChatMessage, useChatStore } from "@/app/store/chat";
import { CHAT_PAGE_SIZE } from "@/app/constant";
import Locale from "@/app/locales";
import { getMessageTextContent, selectOrCopy } from "@/app/utils";
import LoadingIcon from "@/app/icons/three-dots.svg";
import { Avatar } from "@/app/components/emoji";
import { MaskAvatar } from "@/app/components/mask";
import { useAppConfig } from "@/app/store/config";
import ClearContextDivider from "./ClearContextDivider";
import dynamic from "next/dynamic";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import MessageActions, { RenderMessage } from "./MessageActions";
import Imgs from "@/app/components/Imgs";
export type { RenderMessage };
export interface ChatMessagePanelProps {
scrollRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLTextAreaElement>;
isMobileScreen: boolean;
msgRenderIndex: number;
userInput: string;
context: any[];
renderMessages: RenderMessage[];
scrollDomToBottom: () => void;
setAutoScroll?: (value: boolean) => void;
setMsgRenderIndex?: (newIndex: number) => void;
setHitBottom?: (value: boolean) => void;
setUserInput?: (v: string) => void;
setIsLoading?: (value: boolean) => void;
setShowPromptModal?: (value: boolean) => void;
}
let MarkdownLoadedCallback: () => void;
const Markdown = dynamic(
async () => {
const bundle = await import("@/app/components/markdown");
if (MarkdownLoadedCallback) {
MarkdownLoadedCallback();
}
return bundle.Markdown;
},
{
loading: () => <LoadingIcon />,
},
);
export default function ChatMessagePanel(props: ChatMessagePanelProps) {
const {
scrollRef,
inputRef,
setAutoScroll,
setMsgRenderIndex,
isMobileScreen,
msgRenderIndex,
setHitBottom,
setUserInput,
userInput,
context,
renderMessages,
setIsLoading,
setShowPromptModal,
scrollDomToBottom,
} = props;
const chatStore = useChatStore();
const session = chatStore.currentSession();
const config = useAppConfig();
const fontSize = config.fontSize;
const { position, getRelativePosition } = useRelativePosition({
containerRef: scrollRef,
delay: 0,
offsetDistance: 20,
});
// clear context index = context length + index in messages
const clearContextIndex =
(session.clearContextIndex ?? -1) >= 0
? session.clearContextIndex! + context.length - msgRenderIndex
: -1;
if (!MarkdownLoadedCallback) {
MarkdownLoadedCallback = () => {
window.setTimeout(scrollDomToBottom, 100);
};
}
const messages = useMemo(() => {
const endRenderIndex = Math.min(
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
renderMessages.length,
);
return renderMessages.slice(msgRenderIndex, endRenderIndex);
}, [msgRenderIndex, renderMessages]);
const onChatBodyScroll = (e: HTMLElement) => {
const bottomHeight = e.scrollTop + e.clientHeight;
const edgeThreshold = e.clientHeight;
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
const isHitBottom =
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
if (isTouchTopEdge && !isTouchBottomEdge) {
setMsgRenderIndex?.(prevPageMsgIndex);
} else if (isTouchBottomEdge) {
setMsgRenderIndex?.(nextPageMsgIndex);
}
setHitBottom?.(isHitBottom);
setAutoScroll?.(isHitBottom);
};
const onRightClick = (e: any, message: ChatMessage) => {
// copy to clipboard
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
if (userInput.length === 0) {
setUserInput?.(getMessageTextContent(message));
}
e.preventDefault();
}
};
return (
<div
className={`pt-[80px] relative flex-1 overscroll-y-none overflow-y-auto overflow-x-hidden px-3 pb-6 md:bg-chat-panel-message bg-chat-panel-message-mobile`}
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
onMouseDown={() => inputRef.current?.blur()}
onTouchStart={() => {
inputRef.current?.blur();
setAutoScroll?.(false);
}}
>
{messages.map((message, i) => {
const isUser = message.role === "user";
const isContext = i < context.length;
const shouldShowClearContextDivider = i === clearContextIndex - 1;
const actionsBarPosition =
position?.id === message.id &&
position?.poi.overlapPositions[Orientation.bottom]
? "bottom-[calc(100%-0.25rem)]"
: "top-[calc(100%-0.25rem)]";
return (
<Fragment key={message.id}>
<div
className={`flex mt-6 gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
<div className={`relative flex-0`}>
{isUser ? (
<Avatar avatar={config.avatar} />
) : (
<>
{["system"].includes(message.role) ? (
<Avatar avatar="2699-fe0f" />
) : (
<MaskAvatar
avatar={session.mask.avatar}
model={message.model || session.mask.modelConfig.model}
/>
)}
</>
)}
</div>
<div
className={`group relative flex ${
isUser ? "flex-row-reverse" : ""
}`}
>
<div
className={` pointer-events-none text-text-chat-message-date text-right font-common whitespace-nowrap transition-all duration-500 text-sm absolute z-1 ${
isUser ? "right-0" : "left-0"
} bottom-[100%] hidden group-hover:block`}
>
{isContext
? Locale.Chat.IsContext
: message.date.toLocaleString()}
</div>
<div
className={`transition-all duration-300 select-text break-words font-common text-sm-title ${
isUser
? "rounded-user-message bg-chat-panel-message-user"
: "rounded-bot-message bg-chat-panel-message-bot"
} box-border peer py-2 px-3`}
onPointerMoveCapture={(e) =>
getRelativePosition(e.currentTarget, message.id)
}
>
<Markdown
content={getMessageTextContent(message)}
loading={
(message.preview || message.streaming) &&
message.content.length === 0 &&
!isUser
}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen) return;
setUserInput?.(getMessageTextContent(message));
}}
fontSize={fontSize}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
className={`leading-6 max-w-message-width ${
isUser
? " text-text-chat-message-markdown-user"
: "text-text-chat-message-markdown-bot"
}`}
/>
<Imgs message={message} />
</div>
<MessageActions
className={actionsBarPosition}
message={message}
inputRef={inputRef}
isUser={isUser}
isContext={isContext}
setIsLoading={setIsLoading}
setShowPromptModal={setShowPromptModal}
/>
</div>
</div>
{shouldShowClearContextDivider && <ClearContextDivider />}
</Fragment>
);
})}
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { useChatStore } from "@/app/store/chat";
import Locale from "@/app/locales";
import { useAppConfig } from "@/app/store";
export default function ClearContextDivider() {
const chatStore = useChatStore();
const { isMobileScreen } = useAppConfig();
return (
<div
className={`mt-6 mb-8 flex items-center justify-center gap-2.5 max-md:cursor-pointer`}
onClick={() => {
if (!isMobileScreen) {
return;
}
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = undefined),
);
}}
>
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
<div className="flex items-center justify-between gap-1 text-sm">
<div className={`text-text-chat-panel-message-clear`}>
{Locale.Context.Clear}
</div>
<div
className={`
text-text-chat-panel-message-clear-revert underline font-common
md:cursor-pointer
`}
onClick={() => {
if (isMobileScreen) {
return;
}
chatStore.updateCurrentSession(
(session) => (session.clearContextIndex = undefined),
);
}}
>
{Locale.Context.Revert}
</div>
</div>
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { useState } from "react";
import { useChatStore } from "@/app/store/chat";
import { List, ListItem, Modal } from "@/app/components/ui-lib";
import Locale from "@/app/locales";
import { IconButton } from "@/app/components/button";
import { ContextPrompts } from "@/app/components/mask";
import CancelIcon from "@/app/icons/cancel.svg";
import ConfirmIcon from "@/app/icons/confirm.svg";
import Input from "@/app/components/Input";
export function EditMessageModal(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const [messages, setMessages] = useState(session.messages.slice());
return (
<div className="modal-mask">
<Modal
title={Locale.Chat.EditMessage.Title}
onClose={props.onClose}
actions={[
<IconButton
text={Locale.UI.Cancel}
icon={<CancelIcon />}
key="cancel"
onClick={() => {
props.onClose();
}}
/>,
<IconButton
type="primary"
text={Locale.UI.Confirm}
icon={<ConfirmIcon />}
key="ok"
onClick={() => {
chatStore.updateCurrentSession(
(session) => (session.messages = messages),
);
props.onClose();
}}
/>,
]}
// className="!bg-modal-mask"
>
<List>
<ListItem
title={Locale.Chat.EditMessage.Topic.Title}
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
>
<Input
type="text"
value={session.topic}
onChange={(e) =>
chatStore.updateCurrentSession(
(session) => (session.topic = e || ""),
)
}
className=" text-center"
></Input>
</ListItem>
</List>
<ContextPrompts
context={messages}
updateContext={(updater) => {
const newMessages = messages.slice();
updater(newMessages);
setMessages(newMessages);
}}
/>
</Modal>
</div>
);
}

View File

@@ -1,295 +0,0 @@
import Locale from "@/app/locales";
import StopIcon from "@/app/icons/pause.svg";
import DeleteRequestIcon from "@/app/icons/deleteRequestIcon.svg";
import RetryRequestIcon from "@/app/icons/retryRequestIcon.svg";
import CopyRequestIcon from "@/app/icons/copyRequestIcon.svg";
import EditRequestIcon from "@/app/icons/editRequestIcon.svg";
import PinRequestIcon from "@/app/icons/pinRequestIcon.svg";
import { showPrompt, showToast } from "@/app/components/ui-lib";
import {
copyToClipboard,
getMessageImages,
getMessageTextContent,
} from "@/app/utils";
import { MultimodalContent } from "@/app/client/api";
import { ChatMessage, useChatStore } from "@/app/store/chat";
import ActionsBar from "@/app/components/ActionsBar";
import { ChatControllerPool } from "@/app/client/controller";
import { RefObject } from "react";
export type RenderMessage = ChatMessage & { preview?: boolean };
export interface MessageActionsProps {
message: RenderMessage;
isUser: boolean;
isContext: boolean;
showActions?: boolean;
inputRef: RefObject<HTMLTextAreaElement>;
className?: string;
setIsLoading?: (value: boolean) => void;
setShowPromptModal?: (value: boolean) => void;
}
const genActionsSchema = (
message: RenderMessage,
{
onEdit,
onCopy,
onPinMessage,
onDelete,
onResend,
onUserStop,
}: Record<
| "onEdit"
| "onCopy"
| "onPinMessage"
| "onDelete"
| "onResend"
| "onUserStop",
(message: RenderMessage) => void
>,
) => {
const className =
" !p-1 hover:bg-chat-message-actions-btn-hovered !rounded-actions-bar-btn ";
return [
{
id: "Edit",
icons: <EditRequestIcon />,
title: "Edit",
className,
onClick: () => onEdit(message),
},
{
id: Locale.Chat.Actions.Copy,
icons: <CopyRequestIcon />,
title: Locale.Chat.Actions.Copy,
className,
onClick: () => onCopy(message),
},
{
id: Locale.Chat.Actions.Pin,
icons: <PinRequestIcon />,
title: Locale.Chat.Actions.Pin,
className,
onClick: () => onPinMessage(message),
},
{
id: Locale.Chat.Actions.Delete,
icons: <DeleteRequestIcon />,
title: Locale.Chat.Actions.Delete,
className,
onClick: () => onDelete(message),
},
{
id: Locale.Chat.Actions.Retry,
icons: <RetryRequestIcon />,
title: Locale.Chat.Actions.Retry,
className,
onClick: () => onResend(message),
},
{
id: Locale.Chat.Actions.Stop,
icons: <StopIcon />,
title: Locale.Chat.Actions.Stop,
className,
onClick: () => onUserStop(message),
},
];
};
enum GroupType {
"streaming" = "streaming",
"isContext" = "isContext",
"normal" = "normal",
}
const groupsTypes = {
[GroupType.streaming]: [[Locale.Chat.Actions.Stop]],
[GroupType.isContext]: [["Edit"]],
[GroupType.normal]: [
[
Locale.Chat.Actions.Retry,
"Edit",
Locale.Chat.Actions.Copy,
Locale.Chat.Actions.Pin,
Locale.Chat.Actions.Delete,
],
],
};
export default function MessageActions(props: MessageActionsProps) {
const {
className,
message,
isUser,
isContext,
showActions = true,
setIsLoading,
inputRef,
setShowPromptModal,
} = props;
const chatStore = useChatStore();
const session = chatStore.currentSession();
const deleteMessage = (msgId?: string) => {
chatStore.updateCurrentSession(
(session) =>
(session.messages = session.messages.filter((m) => m.id !== msgId)),
);
};
const onDelete = (message: ChatMessage) => {
deleteMessage(message.id);
};
const onResend = (message: ChatMessage) => {
// when it is resending a message
// 1. for a user's message, find the next bot response
// 2. for a bot's message, find the last user's input
// 3. delete original user input and bot's message
// 4. resend the user's input
const resendingIndex = session.messages.findIndex(
(m) => m.id === message.id,
);
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
console.error("[Chat] failed to find resending message", message);
return;
}
let userMessage: ChatMessage | undefined;
let botMessage: ChatMessage | undefined;
if (message.role === "assistant") {
// if it is resending a bot's message, find the user input for it
botMessage = message;
for (let i = resendingIndex; i >= 0; i -= 1) {
if (session.messages[i].role === "user") {
userMessage = session.messages[i];
break;
}
}
} else if (message.role === "user") {
// if it is resending a user's input, find the bot's response
userMessage = message;
for (let i = resendingIndex; i < session.messages.length; i += 1) {
if (session.messages[i].role === "assistant") {
botMessage = session.messages[i];
break;
}
}
}
if (userMessage === undefined) {
console.error("[Chat] failed to resend", message);
return;
}
// delete the original messages
deleteMessage(userMessage.id);
deleteMessage(botMessage?.id);
// resend the message
setIsLoading?.(true);
const textContent = getMessageTextContent(userMessage);
const images = getMessageImages(userMessage);
chatStore
.onUserInput(textContent, images)
.then(() => setIsLoading?.(false));
inputRef.current?.focus();
};
const onPinMessage = (message: ChatMessage) => {
chatStore.updateCurrentSession((session) =>
session.mask.context.push(message),
);
showToast(Locale.Chat.Actions.PinToastContent, {
text: Locale.Chat.Actions.PinToastAction,
onClick: () => {
setShowPromptModal?.(true);
},
});
};
// stop response
const onUserStop = (message: ChatMessage) => {
ChatControllerPool.stop(session.id, message.id);
};
const onEdit = async () => {
const newMessage = await showPrompt(
Locale.Chat.Actions.Edit,
getMessageTextContent(message),
10,
);
let newContent: string | MultimodalContent[] = newMessage;
const images = getMessageImages(message);
if (images.length > 0) {
newContent = [{ type: "text", text: newMessage }];
for (let i = 0; i < images.length; i++) {
newContent.push({
type: "image_url",
image_url: {
url: images[i],
},
});
}
}
chatStore.updateCurrentSession((session) => {
const m = session.mask.context
.concat(session.messages)
.find((m) => m.id === message.id);
if (m) {
m.content = newContent;
}
});
};
const onCopy = () => copyToClipboard(getMessageTextContent(message));
const groupsType = [
message.streaming && GroupType.streaming,
isContext && GroupType.isContext,
GroupType.normal,
].find((i) => i) as GroupType;
return (
showActions && (
<div
className={`
absolute z-10 w-[100%]
${isUser ? "right-0" : "left-0"}
transition-all duration-300
opacity-0
pointer-events-none
group-hover:opacity-100
group-hover:pointer-events-auto
${className}
`}
>
<ActionsBar
actionsSchema={genActionsSchema(message, {
onCopy,
onDelete,
onPinMessage,
onEdit,
onResend,
onUserStop,
})}
groups={groupsTypes[groupsType]}
className={`
float-right flex flex-row gap-1 p-1
bg-chat-message-actions
rounded-md
shadow-message-actions-bar
dark:bg-none
`}
/>
</div>
)
);
}

View File

@@ -1,159 +0,0 @@
import Popover from "@/app/components/Popover";
import React, { useMemo, useRef } from "react";
import useRelativePosition, {
Orientation,
} from "@/app/hooks/useRelativePosition";
import Locale from "@/app/locales";
import { useChatStore } from "@/app/store/chat";
import { useAllModels } from "@/app/utils/hooks";
import { ModelType, useAppConfig } from "@/app/store/config";
import { showToast } from "@/app/components/ui-lib";
import BottomArrow from "@/app/icons/downArrowLgIcon.svg";
import BottomArrowMobile from "@/app/icons/bottomArrow.svg";
import Modal, { TriggerProps } from "@/app/components/Modal";
import Selected from "@/app/icons/selectedIcon.svg";
const ModelSelect = () => {
const config = useAppConfig();
const { isMobileScreen } = config;
const chatStore = useChatStore();
const currentModel = chatStore.currentSession().mask.modelConfig.model;
const allModels = useAllModels();
const models = useMemo(() => {
const filteredModels = allModels.filter((m) => m.available);
const defaultModel = filteredModels.find((m) => m.isDefault);
if (defaultModel) {
const arr = [
defaultModel,
...filteredModels.filter((m) => m !== defaultModel),
];
return arr;
} else {
return filteredModels;
}
}, [allModels]);
const rootRef = useRef<HTMLDivElement>(null);
const { position, getRelativePosition } = useRelativePosition({
delay: 0,
});
const contentRef = useMemo<{ current: HTMLDivElement | null }>(() => {
return {
current: null,
};
}, []);
const selectedItemRef = useRef<HTMLDivElement>(null);
const autoScrollToSelectedModal = () => {
window.setTimeout(() => {
const distanceToParent = selectedItemRef.current?.offsetTop || 0;
const childHeight = selectedItemRef.current?.offsetHeight || 0;
const parentHeight = contentRef.current?.offsetHeight || 0;
const distanceToParentCenter =
distanceToParent + childHeight / 2 - parentHeight / 2;
if (distanceToParentCenter > 0 && contentRef.current) {
contentRef.current.scrollTop = distanceToParentCenter;
}
});
};
const content: TriggerProps["content"] = ({ close }) => (
<div
className={`flex flex-col gap-1 overflow-x-hidden relative text-sm-title`}
>
{models?.map((o) => (
<div
key={o.displayName}
className={`flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer`}
onClick={() => {
close();
chatStore.updateCurrentSession((session) => {
session.mask.modelConfig.model = o.name as ModelType;
session.mask.syncGlobalConfig = false;
});
showToast(o.name);
}}
ref={currentModel === o.name ? selectedItemRef : undefined}
>
<div className={`flex-1 text-text-select`}>{o.name}</div>
<div
className={currentModel === o.name ? "opacity-100" : "opacity-0"}
>
<Selected />
</div>
</div>
))}
</div>
);
if (isMobileScreen) {
return (
<Modal.Trigger
content={(e) => (
<div className="h-[100%] overflow-y-auto" ref={contentRef}>
{content(e)}
</div>
)}
type="bottom-drawer"
onOpen={(e) => {
if (e) {
autoScrollToSelectedModal();
getRelativePosition(rootRef.current!, "");
}
}}
title={Locale.Chat.SelectModel}
headerBordered
noFooter
modelClassName="h-model-bottom-drawer"
>
<div
className="flex items-center gap-1 cursor-pointer text-text-modal-select"
ref={rootRef}
>
{currentModel}
<BottomArrowMobile />
</div>
</Modal.Trigger>
);
}
return (
<Popover
content={
<div className="max-h-chat-actions-select-model-popover overflow-y-auto">
{content({ close: () => {} })}
</div>
}
trigger="click"
noArrow
placement={
position?.poi.relativePosition[1] !== Orientation.bottom ? "lb" : "lt"
}
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-model-select-popover-panel w-[280px]"
onShow={(e) => {
if (e) {
autoScrollToSelectedModal();
getRelativePosition(rootRef.current!, "");
}
}}
getPopoverPanelRef={(ref) => (contentRef.current = ref.current)}
>
<div
className="flex items-center justify-center gap-1 cursor-pointer rounded-chat-model-select pl-3 pr-2.5 py-2 font-common leading-4 bg-chat-actions-select-model hover:bg-chat-actions-select-model-hover"
ref={rootRef}
>
<div className="line-clamp-1 max-w-chat-actions-select-model text-sm-title text-text-modal-select">
{currentModel}
</div>
<BottomArrow />
</div>
</Popover>
);
};
export default ModelSelect;

View File

@@ -1,96 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Prompt } from "@/app/store/prompt";
import styles from "../index.module.scss";
import useShowPromptHint from "@/app/hooks/useShowPromptHint";
export type RenderPompt = Pick<Prompt, "title" | "content">;
export default function PromptHints(props: {
prompts: RenderPompt[];
onPromptSelect: (prompt: RenderPompt) => void;
className?: string;
}) {
const noPrompts = props.prompts.length === 0;
const [selectIndex, setSelectIndex] = useState(0);
const selectedRef = useRef<HTMLDivElement>(null);
const { internalPrompts, notShowPrompt } = useShowPromptHint({ ...props });
useEffect(() => {
setSelectIndex(0);
}, [props.prompts.length]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
return;
}
// arrow up / down to select prompt
const changeIndex = (delta: number) => {
e.stopPropagation();
e.preventDefault();
const nextIndex = Math.max(
0,
Math.min(props.prompts.length - 1, selectIndex + delta),
);
setSelectIndex(nextIndex);
selectedRef.current?.scrollIntoView({
block: "center",
});
};
if (e.key === "ArrowUp") {
changeIndex(1);
} else if (e.key === "ArrowDown") {
changeIndex(-1);
} else if (e.key === "Enter") {
const selectedPrompt = props.prompts.at(selectIndex);
if (selectedPrompt) {
props.onPromptSelect(selectedPrompt);
}
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prompts.length, selectIndex]);
if (!internalPrompts.length) {
return null;
}
return (
<div
className={`
transition-all duration-300 shadow-prompt-hint-container rounded-none flex flex-col-reverse overflow-x-hidden
${
notShowPrompt
? "max-h-[0vh] border-none"
: "border-b pt-2.5 max-h-[50vh]"
}
${props.className}
`}
>
{internalPrompts.map((prompt, i) => (
<div
ref={i === selectIndex ? selectedRef : null}
className={
styles["prompt-hint"] +
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
}
key={prompt.title + i.toString()}
onClick={() => props.onPromptSelect(prompt)}
onMouseEnter={() => setSelectIndex(i)}
>
<div className={styles["hint-title"]}>{prompt.title}</div>
<div className={styles["hint-content"]}>{prompt.content}</div>
</div>
))}
</div>
);
}

View File

@@ -1,32 +0,0 @@
import { useChatStore } from "@/app/store/chat";
import Locale from "@/app/locales";
import BrainIcon from "@/app/icons/brain.svg";
import styles from "../index.module.scss";
export default function PromptToast(props: {
showToast?: boolean;
setShowModal: (_: boolean) => void;
}) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const context = session.mask.context;
return (
<div className={styles["prompt-toast"]} key="prompt-toast">
{props.showToast && (
<div
className={styles["prompt-toast-inner"] + " clickable"}
role="button"
onClick={() => props.setShowModal(true)}
>
<BrainIcon />
<span className={styles["prompt-toast-content"]}>
{Locale.Context.Toast(context.length)}
</span>
</div>
)}
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { Modal, showConfirm } from "@/app/components/ui-lib";
import { useChatStore } from "@/app/store/chat";
import { useMaskStore } from "@/app/store/mask";
import { useNavigate } from "react-router-dom";
import Locale from "@/app/locales";
import { IconButton } from "@/app/components/button";
import { Path } from "@/app/constant";
import ResetIcon from "@/app/icons/reload.svg";
import CopyIcon from "@/app/icons/copy.svg";
import MaskConfig from "@/app/containers/Settings/components/MaskConfig";
import { ListItem } from "@/app/components/List";
export default function SessionConfigModel(props: { onClose: () => void }) {
const chatStore = useChatStore();
const session = chatStore.currentSession();
const maskStore = useMaskStore();
const navigate = useNavigate();
return (
<div className="modal-mask">
<Modal
title={Locale.Context.Edit}
onClose={() => props.onClose()}
actions={[
<IconButton
key="reset"
icon={<ResetIcon />}
bordered
text={Locale.Chat.Config.Reset}
onClick={async () => {
if (await showConfirm(Locale.Memory.ResetConfirm)) {
chatStore.updateCurrentSession(
(session) => (session.memoryPrompt = ""),
);
}
}}
/>,
<IconButton
key="copy"
icon={<CopyIcon />}
bordered
text={Locale.Chat.Config.SaveAs}
onClick={() => {
navigate(Path.Masks);
setTimeout(() => {
maskStore.create(session.mask);
}, 500);
}}
/>,
]}
// className="!bg-modal-mask"
>
<MaskConfig
mask={session.mask}
updateMask={(updater) => {
const mask = { ...session.mask };
updater(mask);
chatStore.updateCurrentSession((session) => (session.mask = mask));
}}
shouldSyncFromGlobal
extraListItems={
session.mask.modelConfig.sendMemory ? (
<ListItem
className="copyable"
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
></ListItem>
) : (
<></>
)
}
></MaskConfig>
</Modal>
</div>
);
}

View File

@@ -1,184 +0,0 @@
import { Draggable } from "@hello-pangea/dnd";
import Locale from "@/app/locales";
import { useLocation } from "react-router-dom";
import { Path } from "@/app/constant";
import { Mask } from "@/app/store/mask";
import { useRef, useEffect } from "react";
import DeleteChatIcon from "@/app/icons/deleteChatIcon.svg";
import { getTime } from "@/app/utils";
import DeleteIcon from "@/app/icons/deleteIcon.svg";
import LogIcon from "@/app/icons/logIcon.svg";
import HoverPopover from "@/app/components/HoverPopover";
import Popover from "@/app/components/Popover";
export default function SessionItem(props: {
onClick?: () => void;
onDelete?: () => void;
title: string;
count: number;
time: string;
selected: boolean;
id: string;
index: number;
narrow?: boolean;
mask: Mask;
isMobileScreen: boolean;
}) {
const draggableRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (props.selected && draggableRef.current) {
draggableRef.current?.scrollIntoView({
block: "center",
});
}
}, [props.selected]);
const { pathname: currentPath } = useLocation();
return (
<Draggable draggableId={`${props.id}`} index={props.index}>
{(provided) => (
<div
className={`
group/chat-menu-list relative flex p-3 items-center gap-2 self-stretch rounded-md mb-2
border
transition-colors duration-300 ease-in-out
bg-chat-menu-session-unselected-mobile border-chat-menu-session-unselected-mobile
md:bg-chat-menu-session-unselected md:border-chat-menu-session-unselected
${
props.selected &&
(currentPath === Path.Chat || currentPath === Path.Home)
? `
md:!bg-chat-menu-session-selected md:!border-chat-menu-session-selected
!bg-chat-menu-session-selected-mobile !border-chat-menu-session-selected-mobile
`
: `md:hover:bg-chat-menu-session-hovered md:hover:chat-menu-session-hovered`
}
`}
onClick={props.onClick}
ref={(ele) => {
draggableRef.current = ele;
provided.innerRef(ele);
}}
{...provided.draggableProps}
{...provided.dragHandleProps}
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
props.count,
)}`}
>
<div className=" flex-shrink-0">
<LogIcon />
</div>
<div className="flex flex-col flex-1">
<div className={`flex justify-between items-center`}>
<div
className={` text-text-chat-menu-item-title text-sm-title line-clamp-1 flex-1`}
>
{props.title}
</div>
<div
className={`text-text-chat-menu-item-time text-sm group-hover/chat-menu-list:opacity-0 pl-3 hidden md:block`}
>
{getTime(props.time)}
</div>
</div>
<div className={`text-text-chat-menu-item-description text-sm`}>
{Locale.ChatItem.ChatItemCount(props.count)}
</div>
</div>
<div
className={`text-text-chat-menu-item-time text-sm pl-3 block md:hidden`}
>
{getTime(props.time)}
</div>
{props.isMobileScreen ? (
<Popover
content={
<div
className={`
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
follow-parent-svg
fill-none
text-text-chat-menu-item-delete
`}
onClickCapture={(e) => {
props.onDelete?.();
}}
>
<DeleteChatIcon />
<div className="flex-1 font-common text-actions-popover-menu-item ">
{Locale.Chat.Actions.Delete}
</div>
</div>
}
popoverClassName={`
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
`}
noArrow
placement="r"
>
<div
className={`
cursor-pointer rounded-chat-img
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
md:group-hover/chat-menu-list:pointer-events-auto
md:group-hover/chat-menu-list:opacity-100
md:hover:bg-select-hover
follow-parent-svg
fill-none
text-text-chat-menu-item-time
`}
>
<DeleteIcon />
</div>
</Popover>
) : (
<HoverPopover
content={
<div
className={`
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
follow-parent-svg
fill-none
text-text-chat-menu-item-delete
`}
onClickCapture={(e) => {
props.onDelete?.();
e.preventDefault();
e.stopPropagation();
}}
>
<DeleteChatIcon />
<div className="flex-1 font-common text-actions-popover-menu-item text-text-chat-menu-item-delete">
{Locale.Chat.Actions.Delete}
</div>
</div>
}
popoverClassName={`
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
`}
noArrow
align="start"
>
<div
className={`
cursor-pointer rounded-chat-img
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
md:group-hover/chat-menu-list:pointer-events-auto
md:group-hover/chat-menu-list:opacity-100
md:hover:bg-select-hover
`}
>
<DeleteIcon />
</div>
</HoverPopover>
)}
</div>
)}
</Draggable>
);
}

View File

@@ -1,609 +0,0 @@
@import "~@/app/styles/animation.scss";
.attach-images {
position: absolute;
left: 30px;
bottom: 32px;
display: flex;
}
.attach-image {
cursor: default;
width: 64px;
height: 64px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
border-radius: 5px;
margin-right: 10px;
background-size: cover;
background-position: center;
background-color: var(--white);
.attach-image-mask {
width: 100%;
height: 100%;
opacity: 0;
transition: all ease 0.2s;
}
.attach-image-mask:hover {
opacity: 1;
}
.delete-image {
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
float: right;
background-color: var(--white);
}
}
.chat-input-actions {
display: flex;
flex-wrap: wrap;
.chat-input-action {
display: inline-flex;
border-radius: 20px;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
padding: 4px 10px;
animation: slide-in ease 0.3s;
box-shadow: var(--card-shadow);
transition: width ease 0.3s;
align-items: center;
height: 16px;
width: var(--icon-width);
overflow: hidden;
&:not(:last-child) {
margin-right: 5px;
}
.text {
white-space: nowrap;
padding-left: 5px;
opacity: 0;
transform: translateX(-5px);
transition: all ease 0.3s;
pointer-events: none;
}
&:hover {
--delay: 0.5s;
width: var(--full-width);
transition-delay: var(--delay);
.text {
transition-delay: var(--delay);
opacity: 1;
transform: translate(0);
}
}
.text,
.icon {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.prompt-toast {
position: absolute;
bottom: -50px;
z-index: 999;
display: flex;
justify-content: center;
width: calc(100% - 40px);
.prompt-toast-inner {
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
box-shadow: var(--card-shadow);
padding: 10px 20px;
border-radius: 100px;
animation: slide-in-from-top ease 0.3s;
.prompt-toast-content {
margin-left: 10px;
}
}
}
.section-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.section-title-action {
display: flex;
align-items: center;
}
}
.context-prompt {
.context-prompt-insert {
display: flex;
justify-content: center;
padding: 4px;
opacity: 0.2;
transition: all ease 0.3s;
background-color: rgba(0, 0, 0, 0);
cursor: pointer;
border-radius: 4px;
margin-top: 4px;
margin-bottom: 4px;
&:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
}
.context-prompt-row {
display: flex;
justify-content: center;
width: 100%;
&:hover {
.context-drag {
opacity: 1;
}
}
.context-drag {
display: flex;
align-items: center;
opacity: 0.5;
transition: all ease 0.3s;
}
.context-role {
margin-right: 10px;
}
.context-content {
flex: 1;
max-width: 100%;
text-align: left;
}
.context-delete-button {
margin-left: 10px;
}
}
.context-prompt-button {
flex: 1;
}
}
.memory-prompt {
margin: 20px 0;
.memory-prompt-content {
background-color: var(--white);
color: var(--black);
border: var(--border-in-light);
border-radius: 10px;
padding: 10px;
font-size: 12px;
user-select: text;
}
}
.clear-context {
margin: 20px 0 0 0;
padding: 4px 0;
border-top: var(--border-in-light);
border-bottom: var(--border-in-light);
box-shadow: var(--card-shadow) inset;
display: flex;
justify-content: center;
align-items: center;
color: var(--black);
transition: all ease 0.3s;
cursor: pointer;
overflow: hidden;
position: relative;
font-size: 12px;
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));
mask-image: $linear;
@mixin show {
transform: translateY(0);
position: relative;
transition: all ease 0.3s;
opacity: 1;
}
@mixin hide {
transform: translateY(-50%);
position: absolute;
transition: all ease 0.1s;
opacity: 0;
}
&-tips {
@include show;
opacity: 0.5;
}
&-revert-btn {
color: var(--primary);
@include hide;
}
&:hover {
opacity: 1;
border-color: var(--primary);
.clear-context-tips {
@include hide;
}
.clear-context-revert-btn {
@include show;
}
}
}
.chat {
display: flex;
flex-direction: column;
position: relative;
// height: 100%;
}
.chat-body {
flex: 1;
overflow: auto;
overflow-x: hidden;
padding: 20px;
padding-bottom: 40px;
position: relative;
overscroll-behavior: none;
}
.chat-body-main-title {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
@media only screen and (max-width: 600px) {
.chat-body-title {
text-align: center;
}
}
.chat-message {
display: flex;
flex-direction: row;
&:last-child {
animation: slide-in ease 0.3s;
}
}
.chat-message-user {
display: flex;
flex-direction: row-reverse;
.chat-message-header {
flex-direction: row-reverse;
}
}
.chat-message-header {
margin-top: 20px;
display: flex;
align-items: center;
.chat-message-actions {
display: flex;
box-sizing: border-box;
font-size: 12px;
align-items: flex-end;
justify-content: space-between;
transition: all ease 0.3s;
transform: scale(0.9) translateY(5px);
margin: 0 10px;
opacity: 0;
pointer-events: none;
.chat-input-actions {
display: flex;
flex-wrap: nowrap;
}
}
}
.chat-message-container {
max-width: var(--message-max-width);
display: flex;
flex-direction: column;
align-items: flex-start;
&:hover {
.chat-message-edit {
opacity: 0.9;
}
.chat-message-actions {
opacity: 1;
pointer-events: all;
transform: scale(1) translateY(0);
}
}
}
.chat-message-user>.chat-message-container {
align-items: flex-end;
}
.chat-message-avatar {
position: relative;
.chat-message-edit {
position: absolute;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all ease 0.3s;
button {
padding: 7px;
}
}
/* Specific styles for iOS devices */
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
@supports (-webkit-touch-callout: none) {
.chat-message-edit {
top: -8%;
}
}
}
}
.chat-message-status {
font-size: 12px;
color: #aaa;
line-height: 1.5;
margin-top: 5px;
}
.chat-message-item {
// box-sizing: border-box;
// max-width: 100%;
// margin-top: 10px;
// border-radius: 10px;
// background-color: rgba(0, 0, 0, 0.05);
// padding: 10px;
// font-size: 14px;
// user-select: text;
// word-break: break-word;
// border: var(--border-in-light);
// position: relative;
transition: all ease 0.3s;
}
.chat-message-item-image {
width: 100%;
margin-top: 10px;
}
.chat-message-item-images {
width: 100%;
display: grid;
justify-content: left;
grid-gap: 10px;
grid-template-columns: repeat(var(--image-count), auto);
margin-top: 10px;
}
.chat-message-item-image-multi {
object-fit: cover;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.chat-message-item-image,
.chat-message-item-image-multi {
box-sizing: border-box;
border-radius: 10px;
border: rgba($color: #888, $alpha: 0.2) 1px solid;
}
@media only screen and (max-width: 600px) {
$calc-image-width: calc(100vw/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $calc-image-width;
height: $calc-image-width;
}
.chat-message-item-image {
max-width: calc(100vw/3*2);
}
}
@media screen and (min-width: 600px) {
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
.chat-message-item-image-multi {
width: $image-width;
height: $image-width;
max-width: $max-image-width;
max-height: $max-image-width;
}
.chat-message-item-image {
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
}
}
// .chat-message-action-date {
// // font-size: 12px;
// // opacity: 0.2;
// // white-space: nowrap;
// // transition: all ease 0.6s;
// // color: var(--black);
// // text-align: right;
// // width: 100%;
// // box-sizing: border-box;
// // padding-right: 10px;
// // pointer-events: none;
// // z-index: 1;
// }
.chat-message-user>.chat-message-container>.chat-message-item {
background-color: var(--second);
&:hover {
min-width: 0;
}
}
.chat-input-panel {
// position: relative;
// width: 100%;
// padding: 20px;
// padding-top: 10px;
// box-sizing: border-box;
// flex-direction: column;
// border-top: var(--border-in-light);
// box-shadow: var(--card-shadow);
.chat-input-actions {
.chat-input-action {
margin-bottom: 10px;
}
}
}
@mixin single-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt-hint {
color:var(--btn-default-text);
padding: 6px 10px;
border: transparent 1px solid;
margin: 4px;
border-radius: 8px;
&:not(:last-child) {
margin-top: 0;
}
.hint-title {
font-size: 12px;
font-weight: bolder;
@include single-line();
}
.hint-content {
font-size: 12px;
@include single-line();
}
&-selected,
&:hover {
border-color: var(--primary);
}
}
// .chat-input-panel-inner {
// cursor: text;
// display: flex;
// flex: 1;
// border-radius: 10px;
// border: var(--border-in-light);
// }
.chat-input-panel-inner-attach {
padding-bottom: 80px;
}
.chat-input-panel-inner:has(.chat-input:focus) {
border: 1px solid var(--primary);
}
.chat-input {
height: 100%;
width: 100%;
border-radius: 10px;
border: none;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
background-color: var(--white);
color: var(--black);
font-family: inherit;
padding: 10px 90px 10px 14px;
resize: none;
outline: none;
box-sizing: border-box;
min-height: 68px;
}
.chat-input:focus {}
.chat-input-send {
background-color: var(--primary);
color: white;
position: absolute;
right: 30px;
bottom: 32px;
}
@media only screen and (max-width: 600px) {
.chat-input {
font-size: 16px;
}
.chat-input-send {
bottom: 30px;
}
}

View File

@@ -1,146 +0,0 @@
import {
DragDropContext,
Droppable,
OnDragEndResponder,
} from "@hello-pangea/dnd";
import { useAppConfig, useChatStore } from "@/app/store";
import Locale from "@/app/locales";
import { useLocation, useNavigate } from "react-router-dom";
import { Path } from "@/app/constant";
import { useEffect } from "react";
import AddIcon from "@/app/icons/addIcon.svg";
import NextChatTitle from "@/app/icons/nextchatTitle.svg";
import MenuLayout from "@/app/components/MenuLayout";
import Panel from "./ChatPanel";
import Modal from "@/app/components/Modal";
import SessionItem from "./components/SessionItem";
export default MenuLayout(function SessionList(props) {
const { setShowPanel } = props;
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
(state) => [
state.sessions,
state.currentSessionIndex,
state.selectSession,
state.moveSession,
],
);
const navigate = useNavigate();
const config = useAppConfig();
const { isMobileScreen } = config;
const chatStore = useChatStore();
const { pathname: currentPath } = useLocation();
useEffect(() => {
setShowPanel?.(currentPath === Path.Chat);
}, [currentPath]);
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
moveSession(source.index, destination.index);
};
return (
<div
className={`
h-[100%] flex flex-col
md:px-0
`}
>
<div data-tauri-drag-region>
<div
className={`
flex items-center justify-between
py-6 max-md:box-content max-md:h-0
md:py-7
`}
data-tauri-drag-region
>
<div className="">
<NextChatTitle />
</div>
<div
className=" cursor-pointer"
onClick={() => {
if (config.dontShowMaskSplashScreen) {
chatStore.newSession();
navigate(Path.Chat);
} else {
navigate(Path.NewChat);
}
}}
>
<AddIcon />
</div>
</div>
<div
className={`pb-3 text-sm sm:text-sm-mobile text-text-chat-header-subtitle`}
>
Build your own AI assistant.
</div>
</div>
<div className={`flex-1 overflow-y-auto max-md:pb-chat-panel-mobile `}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="chat-list">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`w-[100%]`}
>
{sessions.map((item, i) => (
<SessionItem
title={item.topic}
time={new Date(item.lastUpdate).toLocaleString()}
count={item.messages.length}
key={item.id}
id={item.id}
index={i}
selected={i === selectedIndex}
onClick={() => {
navigate(Path.Chat);
selectSession(i);
}}
onDelete={async () => {
if (
await Modal.warn({
okText: Locale.ChatItem.DeleteOkBtn,
cancelText: Locale.ChatItem.DeleteCancelBtn,
title: Locale.ChatItem.DeleteTitle,
content: Locale.ChatItem.DeleteContent,
})
) {
chatStore.deleteSession(i);
}
}}
mask={item.mask}
isMobileScreen={isMobileScreen}
/>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</div>
);
}, Panel);

View File

@@ -1,137 +0,0 @@
import { useEffect, useMemo } from "react";
import { useAccessStore, useAppConfig } from "@/app/store";
import Locale from "@/app/locales";
import { Path } from "@/app/constant";
import List from "@/app/components/List";
import { useNavigate } from "react-router-dom";
import { getClientConfig } from "@/app/config/client";
import Card from "@/app/components/Card";
import SettingHeader from "./components/SettingHeader";
import { MenuWrapperInspectProps } from "@/app/components/MenuLayout";
import SyncItems from "./components/SyncItems";
import DangerItems from "./components/DangerItems";
import AppSetting from "./components/AppSetting";
import MaskSetting from "./components/MaskSetting";
import PromptSetting from "./components/PromptSetting";
import ProviderSetting from "./components/ProviderSetting";
import ModelConfigList from "./components/ModelSetting";
export default function Settings(props: MenuWrapperInspectProps) {
const { setShowPanel, id } = props;
const navigate = useNavigate();
const accessStore = useAccessStore();
const config = useAppConfig();
const { isMobileScreen } = config;
useEffect(() => {
const keydownEvent = (e: KeyboardEvent) => {
if (e.key === "Escape") {
navigate(Path.Home);
}
};
if (clientConfig?.isApp) {
// Force to set custom endpoint to true if it's app
accessStore.update((state) => {
state.useCustomConfig = true;
});
}
document.addEventListener("keydown", keydownEvent);
return () => {
document.removeEventListener("keydown", keydownEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const clientConfig = useMemo(() => getClientConfig(), []);
const cardClassName = "mb-6 md:mb-8 last:mb-0";
const itemMap = {
[Locale.Settings.GeneralSettings]: (
<>
<Card className={cardClassName} title={Locale.Settings.Basic.Title}>
<AppSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Mask.Title}>
<MaskSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Prompt.Title}>
<PromptSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Provider.Title}>
<ProviderSetting />
</Card>
<Card className={cardClassName} title={Locale.Settings.Danger.Title}>
<DangerItems />
</Card>
</>
),
[Locale.Settings.ModelSettings]: (
<Card className={cardClassName} title={Locale.Settings.Models.Title}>
<List
widgetStyle={{
// selectClassName: "min-w-select-mobile-lg",
selectClassName: "min-w-select-mobile md:min-w-select",
inputClassName: "md:min-w-select",
rangeClassName: "md:min-w-select",
rangeNextLine: isMobileScreen,
}}
>
<ModelConfigList
modelConfig={config.modelConfig}
updateConfig={(updater) => {
const modelConfig = { ...config.modelConfig };
updater(modelConfig);
config.update((config) => (config.modelConfig = modelConfig));
}}
/>
</List>
</Card>
),
[Locale.Settings.DataSettings]: (
<Card className={cardClassName} title={Locale.Settings.Sync.Title}>
<SyncItems />
</Card>
),
};
return (
<div
className={`
flex flex-col overflow-hidden bg-settings-panel
h-setting-panel-mobile
md:h-[100%] md:mr-2.5 md:rounded-md
`}
>
<SettingHeader
isMobileScreen={isMobileScreen}
goback={() => setShowPanel?.(false)}
/>
<div
className={`
max-md:w-[100%]
px-4 py-5
md:px-6 md:py-8
flex items-start justify-center
overflow-y-auto
`}
>
<div
className={`
w-full
max-w-screen-md
!overflow-x-hidden
overflow-y-auto
`}
>
{itemMap[id] || null}
</div>
</div>
</div>
);
}

View File

@@ -1,200 +0,0 @@
import LoadingIcon from "@/app/icons/three-dots.svg";
import ResetIcon from "@/app/icons/reload.svg";
import styles from "../index.module.scss";
import { useEffect, useState } from "react";
import { Avatar, AvatarPicker } from "@/app/components/emoji";
import { Popover } from "@/app/components/ui-lib";
import Locale, {
ALL_LANG_OPTIONS,
AllLangs,
changeLang,
getLang,
} from "@/app/locales";
import Link from "next/link";
import { IconButton } from "@/app/components/button";
import { useUpdateStore } from "@/app/store/update";
import {
SubmitKey,
Theme,
ThemeConfig,
useAppConfig,
} from "@/app/store/config";
import { getClientConfig } from "@/app/config/client";
import { RELEASE_URL, UPDATE_URL } from "@/app/constant";
import List, { ListItem } from "@/app/components/List";
import Select from "@/app/components/Select";
import SlideRange from "@/app/components/SlideRange";
import Switch from "@/app/components/Switch";
export interface AppSettingProps {}
export default function AppSetting(props: AppSettingProps) {
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const updateStore = useUpdateStore();
const config = useAppConfig();
const { update: updateConfig, isMobileScreen } = config;
const currentVersion = updateStore.formatVersion(updateStore.version);
const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
const hasNewVersion = currentVersion !== remoteId;
const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
function checkUpdate(force = false) {
setCheckingUpdate(true);
updateStore.getLatestVersion(force).then(() => {
setCheckingUpdate(false);
});
console.log("[Update] local version ", updateStore.version);
console.log("[Update] remote version ", updateStore.remoteVersion);
}
useEffect(() => {
// checks per minutes
checkUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<List
widgetStyle={{
selectClassName: "min-w-select-mobile md:min-w-select",
inputClassName: "md:min-w-select",
rangeClassName: "md:min-w-select",
rangeNextLine: isMobileScreen,
}}
>
<ListItem title={Locale.Settings.Avatar}>
<Popover
onClose={() => setShowEmojiPicker(false)}
content={
<AvatarPicker
onEmojiClick={(avatar: string) => {
updateConfig((config) => (config.avatar = avatar));
setShowEmojiPicker(false);
}}
/>
}
open={showEmojiPicker}
>
<div
className={styles.avatar}
onClick={() => {
setShowEmojiPicker(!showEmojiPicker);
}}
>
<Avatar avatar={config.avatar} />
</div>
</Popover>
</ListItem>
<ListItem
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
subTitle={
checkingUpdate
? Locale.Settings.Update.IsChecking
: hasNewVersion
? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
: Locale.Settings.Update.IsLatest
}
>
{checkingUpdate ? (
<LoadingIcon />
) : hasNewVersion ? (
<Link href={updateUrl} target="_blank" className="link">
{Locale.Settings.Update.GoToUpdate}
</Link>
) : (
<IconButton
icon={<ResetIcon />}
text={Locale.Settings.Update.CheckUpdate}
onClick={() => checkUpdate(true)}
/>
)}
</ListItem>
<ListItem title={Locale.Settings.SendKey}>
<Select
value={config.submitKey}
options={Object.values(SubmitKey).map((v) => ({
value: v,
label: v,
}))}
onSelect={(v) => {
updateConfig((config) => (config.submitKey = v));
}}
/>
</ListItem>
<ListItem title={Locale.Settings.Theme}>
<Select
value={config.theme}
options={Object.entries(ThemeConfig).map(([k, t]) => ({
value: k as Theme,
label: t.title,
icon: <t.icon />,
}))}
onSelect={(e) => {
updateConfig((config) => (config.theme = e));
}}
/>
</ListItem>
<ListItem title={Locale.Settings.Lang.Name}>
<Select
value={getLang()}
options={AllLangs.map((lang) => ({
value: lang,
label: ALL_LANG_OPTIONS[lang],
}))}
onSelect={(e) => {
changeLang(e);
}}
/>
</ListItem>
<ListItem
title={Locale.Settings.FontSize.Title}
subTitle={Locale.Settings.FontSize.SubTitle}
>
<SlideRange
value={config.fontSize}
range={{
start: 12,
stroke: 28,
}}
step={1}
onSlide={(e) => updateConfig((config) => (config.fontSize = e))}
></SlideRange>
</ListItem>
<ListItem
title={Locale.Settings.AutoGenerateTitle.Title}
subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
>
<Switch
value={config.enableAutoGenerateTitle}
onChange={(e) =>
updateConfig((config) => (config.enableAutoGenerateTitle = e))
}
/>
</ListItem>
<ListItem
title={Locale.Settings.SendPreviewBubble.Title}
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
>
<Switch
value={config.sendPreviewBubble}
onChange={(e) =>
updateConfig((config) => (config.sendPreviewBubble = e))
}
/>
</ListItem>
</List>
);
}

Some files were not shown because too many files have changed in this diff Show More