mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-10-26 20:03:43 +08:00
Compare commits
1 Commits
feat-redes
...
feat/deeps
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ac18d9d7 |
@@ -54,10 +54,11 @@ ANTHROPIC_API_KEY=
|
|||||||
### anthropic claude Api version. (optional)
|
### anthropic claude Api version. (optional)
|
||||||
ANTHROPIC_API_VERSION=
|
ANTHROPIC_API_VERSION=
|
||||||
|
|
||||||
|
# deepseek api key (optional)
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
|
||||||
### anthropic claude Api url (optional)
|
### anthropic claude Api url (optional)
|
||||||
ANTHROPIC_URL=
|
ANTHROPIC_URL=
|
||||||
|
|
||||||
### (optional)
|
### (optional)
|
||||||
WHITE_WEBDEV_ENDPOINTS=
|
WEBDEV_ENDPOINTS_WHITELIST=
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"plugins": [
|
"plugins": ["prettier"]
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaFeatures": {
|
|
||||||
"legacyDecorators": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["globals.css"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -212,6 +212,10 @@ anthropic claude Api version.
|
|||||||
|
|
||||||
anthropic claude Api Url.
|
anthropic claude Api Url.
|
||||||
|
|
||||||
|
### `DEEPSEEK_API_KEY` (optional)
|
||||||
|
|
||||||
|
deepseek Api Key.
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (optional)
|
### `HIDE_USER_API_KEY` (optional)
|
||||||
|
|
||||||
> Default: Empty
|
> Default: Empty
|
||||||
@@ -245,17 +249,14 @@ 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.
|
User `-all` to disable all default models, `+all` to enable all default models.
|
||||||
|
|
||||||
### `WHITE_WEBDEV_ENDPOINTS` (optional)
|
### `WEBDEV_ENDPOINTS_WHITELIST` (可选)
|
||||||
|
|
||||||
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format:
|
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format:
|
||||||
|
|
||||||
- Each address must be a complete endpoint
|
- Each address must be a complete endpoint
|
||||||
> `https://xxxx/yyy`
|
> `https://xxxx/yyy`
|
||||||
- Multiple addresses are connected by ', '
|
- 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
|
## Requirements
|
||||||
|
|
||||||
NodeJS >= 18, Docker >= 20
|
NodeJS >= 18, Docker >= 20
|
||||||
|
|||||||
10
README_CN.md
10
README_CN.md
@@ -126,6 +126,10 @@ anthropic claude Api version.
|
|||||||
|
|
||||||
anthropic claude Api Url.
|
anthropic claude Api Url.
|
||||||
|
|
||||||
|
### `DEEPSEEK_API_KEY` (optional)
|
||||||
|
|
||||||
|
deepseek Api Key.
|
||||||
|
|
||||||
### `HIDE_USER_API_KEY` (可选)
|
### `HIDE_USER_API_KEY` (可选)
|
||||||
|
|
||||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||||
@@ -142,9 +146,10 @@ anthropic claude Api Url.
|
|||||||
|
|
||||||
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
||||||
|
|
||||||
### `WHITE_WEBDEV_ENDPOINTS` (可选)
|
### `WEBDEV_ENDPOINTS_WHITELIST` (可选)
|
||||||
|
|
||||||
如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求:
|
如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求:
|
||||||
|
|
||||||
- 每一个地址必须是一个完整的 endpoint
|
- 每一个地址必须是一个完整的 endpoint
|
||||||
> `https://xxxx/xxx`
|
> `https://xxxx/xxx`
|
||||||
- 多个地址以`,`相连
|
- 多个地址以`,`相连
|
||||||
@@ -156,9 +161,6 @@ anthropic claude Api Url.
|
|||||||
|
|
||||||
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
|
||||||
|
|
||||||
### `DEFAULT_INPUT_TEMPLATE` (可选)
|
|
||||||
自定义默认的 template,用于初始化『设置』中的『用户输入预处理』配置项
|
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
点击下方按钮,开始二次开发:
|
点击下方按钮,开始二次开发:
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export function auth(req: NextRequest, modelProvider: ModelProvider) {
|
|||||||
case ModelProvider.Claude:
|
case ModelProvider.Claude:
|
||||||
systemApiKey = serverConfig.anthropicApiKey;
|
systemApiKey = serverConfig.anthropicApiKey;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case ModelProvider.Deepseek:
|
||||||
|
systemApiKey = serverConfig.deepseekApiKey;
|
||||||
|
break;
|
||||||
case ModelProvider.GPT:
|
case ModelProvider.GPT:
|
||||||
default:
|
default:
|
||||||
if (serverConfig.isAzure) {
|
if (serverConfig.isAzure) {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
DEFAULT_MODELS,
|
DEFAULT_MODELS,
|
||||||
serverConfig.customModels,
|
serverConfig.customModels,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// check if deepseek model
|
||||||
const clonedBody = await req.text();
|
const clonedBody = await req.text();
|
||||||
fetchOptions.body = clonedBody;
|
fetchOptions.body = clonedBody;
|
||||||
|
|
||||||
@@ -129,7 +131,6 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
// to disable nginx buffering
|
// to disable nginx buffering
|
||||||
newHeaders.set("X-Accel-Buffering", "no");
|
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)
|
// 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
|
// Also, this is to prevent the header from being sent to the client
|
||||||
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
|
if (!serverConfig.openaiOrgId || serverConfig.openaiOrgId.trim() === "") {
|
||||||
@@ -142,7 +143,6 @@ export async function requestOpenai(req: NextRequest) {
|
|||||||
// The browser will try to decode the response with brotli and fail
|
// The browser will try to decode the response with brotli and fail
|
||||||
newHeaders.delete("content-encoding");
|
newHeaders.delete("content-encoding");
|
||||||
|
|
||||||
|
|
||||||
return new Response(res.body, {
|
return new Response(res.body, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
|
|||||||
@@ -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[],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -70,7 +70,7 @@ export abstract class LLMApi {
|
|||||||
abstract models(): Promise<LLMModel[]>;
|
abstract models(): Promise<LLMModel[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderName = "openai" | "azure" | "claude" | "palm";
|
type ProviderName = "openai" | "azure" | "claude" | "palm" | "deepseek";
|
||||||
|
|
||||||
interface Model {
|
interface Model {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -162,6 +162,7 @@ export function getHeaders() {
|
|||||||
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;
|
const modelConfig = useChatStore.getState().currentSession().mask.modelConfig;
|
||||||
const isGoogle = modelConfig.model.startsWith("gemini");
|
const isGoogle = modelConfig.model.startsWith("gemini");
|
||||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||||
|
const isDeepSeek = accessStore.provider === ServiceProvider.DeepSeek;
|
||||||
const authHeader = isAzure ? "api-key" : "Authorization";
|
const authHeader = isAzure ? "api-key" : "Authorization";
|
||||||
const apiKey = isGoogle
|
const apiKey = isGoogle
|
||||||
? accessStore.googleApiKey
|
? accessStore.googleApiKey
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from "./types";
|
|
||||||
|
|
||||||
export * from "./locale";
|
|
||||||
|
|
||||||
export * from "./utils";
|
|
||||||
|
|
||||||
export const modelNameRequestHeader = "x-nextchat-model-name";
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export * from "./shim";
|
|
||||||
|
|
||||||
export * from "../common/types";
|
|
||||||
|
|
||||||
export * from "./providerClient";
|
|
||||||
|
|
||||||
export * from "./modelClient";
|
|
||||||
|
|
||||||
export * from "../common/locale";
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./core";
|
|
||||||
|
|
||||||
export * from "./providers";
|
|
||||||
@@ -120,9 +120,7 @@ export class GeminiProApi implements LLMApi {
|
|||||||
|
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
baseUrl = isApp
|
baseUrl = isApp
|
||||||
? DEFAULT_API_HOST +
|
? DEFAULT_API_HOST + "/api/proxy/google/" + Google.ChatPath(modelConfig.model)
|
||||||
"/api/proxy/google/" +
|
|
||||||
Google.ChatPath(modelConfig.model)
|
|
||||||
: this.path(Google.ChatPath(modelConfig.model));
|
: this.path(Google.ChatPath(modelConfig.model));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
@@ -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 ||
|
|
||||||
"",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
@@ -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;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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]">
|
|
||||||
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
</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();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
</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;
|
|
||||||
@@ -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();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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">
|
|
||||||
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
background-color: var(--white);
|
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
}
|
}
|
||||||
@@ -35,18 +33,4 @@
|
|||||||
margin-bottom: 10px;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,9 @@ import {
|
|||||||
getMessageTextContent,
|
getMessageTextContent,
|
||||||
getMessageImages,
|
getMessageImages,
|
||||||
isVisionModel,
|
isVisionModel,
|
||||||
|
compressImage,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { compressImage } from "@/app/utils/chat";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import { ChatControllerPool } from "../client/controller";
|
import { ChatControllerPool } from "../client/controller";
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
&-body {
|
&-body {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
div:not(.no-dark) > svg {
|
|
||||||
filter: invert(0.5);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-content {
|
.export-content {
|
||||||
|
|||||||
@@ -177,14 +177,13 @@ export function Markdown(
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
parentRef?: RefObject<HTMLDivElement>;
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
defaultShow?: boolean;
|
defaultShow?: boolean;
|
||||||
className?: string;
|
|
||||||
} & React.DOMAttributes<HTMLDivElement>,
|
} & React.DOMAttributes<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
const mdRef = useRef<HTMLDivElement>(null);
|
const mdRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`markdown-body ${props.className}`}
|
className="markdown-body"
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
div:not(.no-dark) > svg {
|
|
||||||
filter: invert(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mask-page-body {
|
.mask-page-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
|
import { ErrorBoundary } from "./error";
|
||||||
|
|
||||||
import styles from "./mask.module.scss";
|
import styles from "./mask.module.scss";
|
||||||
|
|
||||||
@@ -55,7 +56,6 @@ import {
|
|||||||
OnDragEndResponder,
|
OnDragEndResponder,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
|
||||||
|
|
||||||
// drag and drop helper function
|
// drag and drop helper function
|
||||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||||
@@ -398,7 +398,7 @@ export function ContextPrompts(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaskPage(props: { className?: string }) {
|
export function MaskPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
@@ -466,13 +466,8 @@ export function MaskPage(props: { className?: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ErrorBoundary>
|
||||||
<div
|
<div className={styles["mask-page"]}>
|
||||||
className={`
|
|
||||||
${styles["mask-page"]}
|
|
||||||
${props.className}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="window-header">
|
<div className="window-header">
|
||||||
<div className="window-header-title">
|
<div className="window-header-title">
|
||||||
<div className="window-header-main-title">
|
<div className="window-header-main-title">
|
||||||
@@ -650,6 +645,6 @@ export function MaskPage(props: { className?: string }) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
div:not(.no-dark) > svg {
|
|
||||||
filter: invert(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mask-header {
|
.mask-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { MaskAvatar } from "./mask";
|
|||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { BUILTIN_MASK_STORE } from "../masks";
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
|
||||||
|
|
||||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -72,7 +71,7 @@ function useMaskGroup(masks: Mask[]) {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewChat(props: { className?: string }) {
|
export function NewChat() {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
|
|
||||||
@@ -111,15 +110,8 @@ export function NewChat(props: { className?: string }) {
|
|||||||
}
|
}
|
||||||
}, [groups]);
|
}, [groups]);
|
||||||
|
|
||||||
const isMobileScreen = useMobileScreen();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={styles["new-chat"]}>
|
||||||
className={`
|
|
||||||
${styles["new-chat"]}
|
|
||||||
${props.className}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className={styles["mask-header"]}>
|
<div className={styles["mask-header"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<LeftIcon />}
|
icon={<LeftIcon />}
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ interface ModalProps {
|
|||||||
defaultMax?: boolean;
|
defaultMax?: boolean;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
export function Modal(props: ModalProps) {
|
export function Modal(props: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,14 +122,14 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles["modal-container"]} ${
|
className={
|
||||||
isMax && styles["modal-container-max"]
|
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||||||
} ${props.className ?? ""}`}
|
}
|
||||||
>
|
>
|
||||||
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
|
<div className={styles["modal-header"]}>
|
||||||
<div className={`${styles["modal-title"]}`}>{props.title}</div>
|
<div className={styles["modal-title"]}>{props.title}</div>
|
||||||
|
|
||||||
<div className={`${styles["modal-header-actions"]}`}>
|
<div className={styles["modal-header-actions"]}>
|
||||||
<div
|
<div
|
||||||
className={styles["modal-header-action"]}
|
className={styles["modal-header-action"]}
|
||||||
onClick={() => setMax(!isMax)}
|
onClick={() => setMax(!isMax)}
|
||||||
@@ -148,11 +147,11 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
<div className={styles["modal-content"]}>{props.children}</div>
|
<div className={styles["modal-content"]}>{props.children}</div>
|
||||||
|
|
||||||
<div className={`${styles["modal-footer"]} new-footer`}>
|
<div className={styles["modal-footer"]}>
|
||||||
{props.footer}
|
{props.footer}
|
||||||
<div className={styles["modal-actions"]}>
|
<div className={styles["modal-actions"]}>
|
||||||
{props.actions?.map((action, i) => (
|
{props.actions?.map((action, i) => (
|
||||||
<div key={i} className={`${styles["modal-action"]} new-btn`}>
|
<div key={i} className={styles["modal-action"]}>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import tauriConfig from "../../src-tauri/tauri.conf.json";
|
import tauriConfig from "../../src-tauri/tauri.conf.json";
|
||||||
import { DEFAULT_INPUT_TEMPLATE } from "../constant";
|
|
||||||
|
|
||||||
export const getBuildConfig = () => {
|
export const getBuildConfig = () => {
|
||||||
if (typeof process === "undefined") {
|
if (typeof process === "undefined") {
|
||||||
@@ -39,7 +38,6 @@ export const getBuildConfig = () => {
|
|||||||
...commitInfo,
|
...commitInfo,
|
||||||
buildMode,
|
buildMode,
|
||||||
isApp,
|
isApp,
|
||||||
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ declare global {
|
|||||||
|
|
||||||
// google tag manager
|
// google tag manager
|
||||||
GTM_ID?: string;
|
GTM_ID?: string;
|
||||||
|
|
||||||
// custom template for preprocessing user input
|
|
||||||
DEFAULT_INPUT_TEMPLATE?: string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,10 +52,7 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function getApiKey(keys?: string) {
|
function getApiKey(keys?: string) {
|
||||||
if (!keys) {
|
const apiKeyEnvVar = keys ?? "";
|
||||||
return;
|
|
||||||
}
|
|
||||||
const apiKeyEnvVar = keys;
|
|
||||||
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||||
const apiKey = apiKeys[randomIndex];
|
const apiKey = apiKeys[randomIndex];
|
||||||
@@ -95,6 +89,7 @@ export const getServerSideConfig = () => {
|
|||||||
const isAzure = !!process.env.AZURE_URL;
|
const isAzure = !!process.env.AZURE_URL;
|
||||||
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||||
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
const isDeepSeek = !!process.env.DEEPSEEK_API_KEY;
|
||||||
|
|
||||||
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||||
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
@@ -105,7 +100,7 @@ export const getServerSideConfig = () => {
|
|||||||
// );
|
// );
|
||||||
|
|
||||||
const allowedWebDevEndpoints = (
|
const allowedWebDevEndpoints = (
|
||||||
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
|
process.env.WEBDEV_ENDPOINTS_WHITELIST ?? ""
|
||||||
).split(",");
|
).split(",");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -127,6 +122,8 @@ export const getServerSideConfig = () => {
|
|||||||
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
||||||
anthropicUrl: process.env.ANTHROPIC_URL,
|
anthropicUrl: process.env.ANTHROPIC_URL,
|
||||||
|
|
||||||
|
deepseekApiKey: getApiKey(process.env.DEEPSEEK_API_KEY),
|
||||||
|
|
||||||
gtmId: process.env.GTM_ID,
|
gtmId: process.env.GTM_ID,
|
||||||
|
|
||||||
needCode: ACCESS_CODES.size > 0,
|
needCode: ACCESS_CODES.size > 0,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Chat } from "./components/chat";
|
||||||
|
|
||||||
export const OWNER = "Yidadaa";
|
export const OWNER = "Yidadaa";
|
||||||
export const REPO = "ChatGPT-Next-Web";
|
export const REPO = "ChatGPT-Next-Web";
|
||||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
|
||||||
@@ -47,20 +49,12 @@ export enum StoreKey {
|
|||||||
Prompt = "prompt-store",
|
Prompt = "prompt-store",
|
||||||
Update = "chat-update",
|
Update = "chat-update",
|
||||||
Sync = "sync",
|
Sync = "sync",
|
||||||
Provider = "provider",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NARROW_SIDEBAR_WIDTH = 100;
|
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||||
|
export const MAX_SIDEBAR_WIDTH = 500;
|
||||||
export const DEFAULT_SIDEBAR_WIDTH = 340;
|
|
||||||
export const MAX_SIDEBAR_WIDTH = 440;
|
|
||||||
export const MIN_SIDEBAR_WIDTH = 230;
|
export const MIN_SIDEBAR_WIDTH = 230;
|
||||||
|
export const NARROW_SIDEBAR_WIDTH = 100;
|
||||||
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 ACCESS_CODE_PREFIX = "nk-";
|
export const ACCESS_CODE_PREFIX = "nk-";
|
||||||
|
|
||||||
@@ -78,12 +72,14 @@ export enum ServiceProvider {
|
|||||||
Azure = "Azure",
|
Azure = "Azure",
|
||||||
Google = "Google",
|
Google = "Google",
|
||||||
Anthropic = "Anthropic",
|
Anthropic = "Anthropic",
|
||||||
|
DeepSeek = "DeepSeek",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ModelProvider {
|
export enum ModelProvider {
|
||||||
GPT = "GPT",
|
GPT = "GPT",
|
||||||
GeminiPro = "GeminiPro",
|
GeminiPro = "GeminiPro",
|
||||||
Claude = "Claude",
|
Claude = "Claude",
|
||||||
|
Deepseek = "DeepSeek",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Anthropic = {
|
export const Anthropic = {
|
||||||
@@ -135,8 +131,6 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
"gpt-4-turbo": "2023-12",
|
"gpt-4-turbo": "2023-12",
|
||||||
"gpt-4-turbo-2024-04-09": "2023-12",
|
"gpt-4-turbo-2024-04-09": "2023-12",
|
||||||
"gpt-4-turbo-preview": "2023-12",
|
"gpt-4-turbo-preview": "2023-12",
|
||||||
"gpt-4o": "2023-10",
|
|
||||||
"gpt-4o-2024-05-13": "2023-10",
|
|
||||||
"gpt-4-vision-preview": "2023-04",
|
"gpt-4-vision-preview": "2023-04",
|
||||||
// After improvements,
|
// After improvements,
|
||||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||||
@@ -146,24 +140,16 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
|
|
||||||
const openaiModels = [
|
const openaiModels = [
|
||||||
"gpt-3.5-turbo",
|
"gpt-3.5-turbo",
|
||||||
"gpt-3.5-turbo-1106",
|
|
||||||
"gpt-3.5-turbo-0125",
|
|
||||||
"gpt-4",
|
"gpt-4",
|
||||||
"gpt-4-0613",
|
|
||||||
"gpt-4-32k",
|
"gpt-4-32k",
|
||||||
"gpt-4-32k-0613",
|
|
||||||
"gpt-4-turbo",
|
"gpt-4-turbo",
|
||||||
"gpt-4-turbo-preview",
|
"gpt-4-turbo-preview",
|
||||||
"gpt-4o",
|
|
||||||
"gpt-4o-2024-05-13",
|
|
||||||
"gpt-4-vision-preview",
|
"gpt-4-vision-preview",
|
||||||
"gpt-4-turbo-2024-04-09",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const googleModels = [
|
const googleModels = [
|
||||||
"gemini-1.0-pro",
|
"gemini-1.0-pro",
|
||||||
"gemini-1.5-pro-latest",
|
"gemini-1.5-pro-latest",
|
||||||
"gemini-1.5-flash-latest",
|
|
||||||
"gemini-pro-vision",
|
"gemini-pro-vision",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -176,6 +162,8 @@ const anthropicModels = [
|
|||||||
"claude-3-haiku-20240307",
|
"claude-3-haiku-20240307",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const deepseekModels = ["deepseek-chat"];
|
||||||
|
|
||||||
export const DEFAULT_MODELS = [
|
export const DEFAULT_MODELS = [
|
||||||
...openaiModels.map((name) => ({
|
...openaiModels.map((name) => ({
|
||||||
name,
|
name,
|
||||||
@@ -204,6 +192,15 @@ export const DEFAULT_MODELS = [
|
|||||||
providerType: "anthropic",
|
providerType: "anthropic",
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
...deepseekModels.map((name) => ({
|
||||||
|
name,
|
||||||
|
available: true,
|
||||||
|
provider: {
|
||||||
|
id: "deepseek",
|
||||||
|
providerName: "DeepSeek",
|
||||||
|
providerType: "deepseek",
|
||||||
|
},
|
||||||
|
})),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CHAT_PAGE_SIZE = 15;
|
export const CHAT_PAGE_SIZE = 15;
|
||||||
@@ -220,5 +217,3 @@ export const internalAllowedWebDavEndpoints = [
|
|||||||
"https://webdav.yandex.com",
|
"https://webdav.yandex.com",
|
||||||
"https://app.koofr.net/dav/Koofr",
|
"https://app.koofr.net/dav/Koofr",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SIDEBAR_ID = "sidebar";
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"> </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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { IconButton } from "@/app/components/button";
|
|
||||||
import { showConfirm } from "@/app/components/ui-lib";
|
|
||||||
import { useChatStore } from "@/app/store/chat";
|
|
||||||
import { useAppConfig } from "@/app/store/config";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { useAccessStore } from "@/app/store/access";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { getClientConfig } from "@/app/config/client";
|
|
||||||
import { OPENAI_BASE_URL, ServiceProvider } from "@/app/constant";
|
|
||||||
import { useUpdateStore } from "@/app/store/update";
|
|
||||||
|
|
||||||
import ResetIcon from "@/app/icons/reload.svg";
|
|
||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import Input from "@/app/components/Input";
|
|
||||||
import Btn from "@/app/components/Btn";
|
|
||||||
|
|
||||||
export default function DangerItems() {
|
|
||||||
const chatStore = useChatStore();
|
|
||||||
const appConfig = useAppConfig();
|
|
||||||
const accessStore = useAccessStore();
|
|
||||||
const updateStore = useUpdateStore();
|
|
||||||
const { isMobileScreen } = appConfig;
|
|
||||||
|
|
||||||
const enabledAccessControl = useMemo(
|
|
||||||
() => accessStore.enabledAccessControl(),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
|
||||||
|
|
||||||
const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
|
|
||||||
|
|
||||||
const shouldHideBalanceQuery = useMemo(() => {
|
|
||||||
const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
|
|
||||||
return (
|
|
||||||
accessStore.hideBalanceQuery ||
|
|
||||||
isOpenAiUrl ||
|
|
||||||
accessStore.provider === ServiceProvider.Azure
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
accessStore.hideBalanceQuery,
|
|
||||||
accessStore.openaiUrl,
|
|
||||||
accessStore.provider,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [loadingUsage, setLoadingUsage] = useState(false);
|
|
||||||
const usage = {
|
|
||||||
used: updateStore.used,
|
|
||||||
subscription: updateStore.subscription,
|
|
||||||
};
|
|
||||||
|
|
||||||
function checkUsage(force = false) {
|
|
||||||
if (shouldHideBalanceQuery) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingUsage(true);
|
|
||||||
updateStore.updateUsage(force).finally(() => {
|
|
||||||
setLoadingUsage(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const showUsage = accessStore.isAuthorized();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showUsage && checkUsage();
|
|
||||||
// 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,
|
|
||||||
inputNextLine: isMobileScreen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showAccessCode && (
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.AccessCode.Title}
|
|
||||||
subTitle={Locale.Settings.Access.AccessCode.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={accessStore.accessCode}
|
|
||||||
type="password"
|
|
||||||
placeholder={Locale.Settings.Access.AccessCode.Placeholder}
|
|
||||||
onChange={(e) => {
|
|
||||||
accessStore.update((access) => (access.accessCode = e));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!shouldHideBalanceQuery && !clientConfig?.isApp ? (
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Usage.Title}
|
|
||||||
subTitle={
|
|
||||||
showUsage
|
|
||||||
? loadingUsage
|
|
||||||
? Locale.Settings.Usage.IsChecking
|
|
||||||
: Locale.Settings.Usage.SubTitle(
|
|
||||||
usage?.used ?? "[?]",
|
|
||||||
usage?.subscription ?? "[?]",
|
|
||||||
)
|
|
||||||
: Locale.Settings.Usage.NoAccess
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!showUsage || loadingUsage ? (
|
|
||||||
<div />
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={<ResetIcon />}
|
|
||||||
text={Locale.Settings.Usage.Check}
|
|
||||||
onClick={() => checkUsage(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ListItem>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Danger.Reset.Title}
|
|
||||||
subTitle={Locale.Settings.Danger.Reset.SubTitle}
|
|
||||||
>
|
|
||||||
<Btn
|
|
||||||
text={Locale.Settings.Danger.Reset.Action}
|
|
||||||
onClick={async () => {
|
|
||||||
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
|
|
||||||
appConfig.reset();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="danger"
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Danger.Clear.Title}
|
|
||||||
subTitle={Locale.Settings.Danger.Clear.SubTitle}
|
|
||||||
>
|
|
||||||
<Btn
|
|
||||||
text={Locale.Settings.Danger.Clear.Action}
|
|
||||||
onClick={async () => {
|
|
||||||
if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
|
|
||||||
chatStore.clearAllData();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="danger"
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import { ContextPrompts, MaskAvatar } from "@/app/components/mask";
|
|
||||||
import { Path } from "@/app/constant";
|
|
||||||
import { ModelConfig, useAppConfig } from "@/app/store/config";
|
|
||||||
import { Mask } from "@/app/store/mask";
|
|
||||||
import { Updater } from "@/app/typing";
|
|
||||||
import { copyToClipboard } from "@/app/utils";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { Popover, showConfirm } from "@/app/components/ui-lib";
|
|
||||||
import { AvatarPicker } from "@/app/components/emoji";
|
|
||||||
import ModelSetting from "@/app/containers/Settings/components/ModelSetting";
|
|
||||||
import { IconButton } from "@/app/components/button";
|
|
||||||
|
|
||||||
import CopyIcon from "@/app/icons/copy.svg";
|
|
||||||
import Switch from "@/app/components/Switch";
|
|
||||||
import Input from "@/app/components/Input";
|
|
||||||
|
|
||||||
export default function MaskConfig(props: {
|
|
||||||
mask: Mask;
|
|
||||||
updateMask: Updater<Mask>;
|
|
||||||
extraListItems?: JSX.Element;
|
|
||||||
readonly?: boolean;
|
|
||||||
shouldSyncFromGlobal?: boolean;
|
|
||||||
}) {
|
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
|
||||||
|
|
||||||
const updateConfig = (updater: (config: ModelConfig) => void) => {
|
|
||||||
if (props.readonly) return;
|
|
||||||
|
|
||||||
const config = { ...props.mask.modelConfig };
|
|
||||||
updater(config);
|
|
||||||
props.updateMask((mask) => {
|
|
||||||
mask.modelConfig = config;
|
|
||||||
// if user changed current session mask, it will disable auto sync
|
|
||||||
mask.syncGlobalConfig = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyMaskLink = () => {
|
|
||||||
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
|
|
||||||
copyToClipboard(maskLink);
|
|
||||||
};
|
|
||||||
|
|
||||||
const globalConfig = useAppConfig();
|
|
||||||
|
|
||||||
const { isMobileScreen } = globalConfig;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ContextPrompts
|
|
||||||
context={props.mask.context}
|
|
||||||
updateContext={(updater) => {
|
|
||||||
const context = props.mask.context.slice();
|
|
||||||
updater(context);
|
|
||||||
props.updateMask((mask) => (mask.context = context));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List
|
|
||||||
widgetStyle={{
|
|
||||||
rangeNextLine: isMobileScreen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItem title={Locale.Mask.Config.Avatar}>
|
|
||||||
<Popover
|
|
||||||
content={
|
|
||||||
<AvatarPicker
|
|
||||||
onEmojiClick={(emoji) => {
|
|
||||||
props.updateMask((mask) => (mask.avatar = emoji));
|
|
||||||
setShowPicker(false);
|
|
||||||
}}
|
|
||||||
></AvatarPicker>
|
|
||||||
}
|
|
||||||
open={showPicker}
|
|
||||||
onClose={() => setShowPicker(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => setShowPicker(true)}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
<MaskAvatar
|
|
||||||
avatar={props.mask.avatar}
|
|
||||||
model={props.mask.modelConfig.model}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={Locale.Mask.Config.Name}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={props.mask.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
props.updateMask((mask) => {
|
|
||||||
mask.name = e;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Mask.Config.HideContext.Title}
|
|
||||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={!!props.mask.hideContext}
|
|
||||||
onChange={(e) => {
|
|
||||||
props.updateMask((mask) => {
|
|
||||||
mask.hideContext = e;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
></Switch>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
{!props.shouldSyncFromGlobal ? (
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Mask.Config.Share.Title}
|
|
||||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon={<CopyIcon />}
|
|
||||||
text={Locale.Mask.Config.Share.Action}
|
|
||||||
onClick={copyMaskLink}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{props.shouldSyncFromGlobal ? (
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Mask.Config.Sync.Title}
|
|
||||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={!!props.mask.syncGlobalConfig}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const checked = e;
|
|
||||||
if (
|
|
||||||
checked &&
|
|
||||||
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
|
||||||
) {
|
|
||||||
props.updateMask((mask) => {
|
|
||||||
mask.syncGlobalConfig = checked;
|
|
||||||
mask.modelConfig = { ...globalConfig.modelConfig };
|
|
||||||
});
|
|
||||||
} else if (!checked) {
|
|
||||||
props.updateMask((mask) => {
|
|
||||||
mask.syncGlobalConfig = checked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ModelSetting
|
|
||||||
modelConfig={{ ...props.mask.modelConfig }}
|
|
||||||
updateConfig={updateConfig}
|
|
||||||
/>
|
|
||||||
{props.extraListItems}
|
|
||||||
</List>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import Switch from "@/app/components/Switch";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { useAppConfig } from "@/app/store/config";
|
|
||||||
|
|
||||||
export interface MaskSettingProps {}
|
|
||||||
|
|
||||||
export default function MaskSetting(props: MaskSettingProps) {
|
|
||||||
const config = useAppConfig();
|
|
||||||
const updateConfig = config.update;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Mask.Splash.Title}
|
|
||||||
subTitle={Locale.Settings.Mask.Splash.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={!config.dontShowMaskSplashScreen}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig((config) => (config.dontShowMaskSplashScreen = !e))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Mask.Builtin.Title}
|
|
||||||
subTitle={Locale.Settings.Mask.Builtin.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={config.hideBuiltinMasks}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig((config) => (config.hideBuiltinMasks = e))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { ListItem } from "@/app/components/List";
|
|
||||||
import {
|
|
||||||
ModalConfigValidator,
|
|
||||||
ModelConfig,
|
|
||||||
useAppConfig,
|
|
||||||
} from "@/app/store/config";
|
|
||||||
import { useAllModels } from "@/app/utils/hooks";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import Select from "@/app/components/Select";
|
|
||||||
import SlideRange from "@/app/components/SlideRange";
|
|
||||||
import Switch from "@/app/components/Switch";
|
|
||||||
import Input from "@/app/components/Input";
|
|
||||||
|
|
||||||
export default function ModelSetting(props: {
|
|
||||||
modelConfig: ModelConfig;
|
|
||||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
|
||||||
}) {
|
|
||||||
const allModels = useAllModels();
|
|
||||||
const { isMobileScreen } = useAppConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ListItem title={Locale.Settings.Model}>
|
|
||||||
<Select
|
|
||||||
value={props.modelConfig.model}
|
|
||||||
options={allModels
|
|
||||||
.filter((v) => v.available)
|
|
||||||
.map((v) => ({
|
|
||||||
value: v.name,
|
|
||||||
label: `${v.displayName}(${v.provider?.providerName})`,
|
|
||||||
}))}
|
|
||||||
onSelect={(e) => {
|
|
||||||
props.updateConfig(
|
|
||||||
(config) => (config.model = ModalConfigValidator.model(e)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Temperature.Title}
|
|
||||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
|
||||||
>
|
|
||||||
<SlideRange
|
|
||||||
value={props.modelConfig.temperature}
|
|
||||||
range={{
|
|
||||||
start: 0,
|
|
||||||
stroke: 1,
|
|
||||||
}}
|
|
||||||
step={0.1}
|
|
||||||
onSlide={(e) => {
|
|
||||||
props.updateConfig(
|
|
||||||
(config) =>
|
|
||||||
(config.temperature = ModalConfigValidator.temperature(e)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
></SlideRange>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.TopP.Title}
|
|
||||||
subTitle={Locale.Settings.TopP.SubTitle}
|
|
||||||
>
|
|
||||||
<SlideRange
|
|
||||||
value={props.modelConfig.top_p ?? 1}
|
|
||||||
range={{
|
|
||||||
start: 0,
|
|
||||||
stroke: 1,
|
|
||||||
}}
|
|
||||||
step={0.1}
|
|
||||||
onSlide={(e) => {
|
|
||||||
props.updateConfig(
|
|
||||||
(config) => (config.top_p = ModalConfigValidator.top_p(e)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
></SlideRange>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.MaxTokens.Title}
|
|
||||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1024}
|
|
||||||
max={512000}
|
|
||||||
value={props.modelConfig.max_tokens}
|
|
||||||
onChange={(e) =>
|
|
||||||
props.updateConfig(
|
|
||||||
(config) =>
|
|
||||||
(config.max_tokens = ModalConfigValidator.max_tokens(e)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
{props.modelConfig.model.startsWith("gemini") ? null : (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.PresencePenalty.Title}
|
|
||||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
|
||||||
>
|
|
||||||
<SlideRange
|
|
||||||
value={props.modelConfig.presence_penalty}
|
|
||||||
range={{
|
|
||||||
start: -2,
|
|
||||||
stroke: 4,
|
|
||||||
}}
|
|
||||||
step={0.1}
|
|
||||||
onSlide={(e) => {
|
|
||||||
props.updateConfig(
|
|
||||||
(config) =>
|
|
||||||
(config.presence_penalty =
|
|
||||||
ModalConfigValidator.presence_penalty(e)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
></SlideRange>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.FrequencyPenalty.Title}
|
|
||||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
|
||||||
>
|
|
||||||
<SlideRange
|
|
||||||
value={props.modelConfig.frequency_penalty}
|
|
||||||
range={{
|
|
||||||
start: -2,
|
|
||||||
stroke: 4,
|
|
||||||
}}
|
|
||||||
step={0.1}
|
|
||||||
onSlide={(e) => {
|
|
||||||
props.updateConfig(
|
|
||||||
(config) =>
|
|
||||||
(config.frequency_penalty =
|
|
||||||
ModalConfigValidator.frequency_penalty(e)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
></SlideRange>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.InjectSystemPrompts.Title}
|
|
||||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={props.modelConfig.enableInjectSystemPrompts}
|
|
||||||
onChange={(e) =>
|
|
||||||
props.updateConfig(
|
|
||||||
(config) => (config.enableInjectSystemPrompts = e),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.InputTemplate.Title}
|
|
||||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
|
||||||
nextline={isMobileScreen}
|
|
||||||
validator={(v: string) => {
|
|
||||||
if (!v.includes("{{input}}")) {
|
|
||||||
return {
|
|
||||||
error: true,
|
|
||||||
message: Locale.Settings.InputTemplate.Error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: false };
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={props.modelConfig.template}
|
|
||||||
onChange={(e = "") =>
|
|
||||||
props.updateConfig((config) => (config.template = e))
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.HistoryCount.Title}
|
|
||||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
|
||||||
>
|
|
||||||
<SlideRange
|
|
||||||
value={props.modelConfig.historyMessageCount}
|
|
||||||
range={{
|
|
||||||
start: 0,
|
|
||||||
stroke: 64,
|
|
||||||
}}
|
|
||||||
step={1}
|
|
||||||
onSlide={(e) => {
|
|
||||||
props.updateConfig((config) => (config.historyMessageCount = e));
|
|
||||||
}}
|
|
||||||
></SlideRange>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.CompressThreshold.Title}
|
|
||||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={500}
|
|
||||||
max={4000}
|
|
||||||
value={props.modelConfig.compressMessageLengthThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
props.updateConfig(
|
|
||||||
(config) => (config.compressMessageLengthThreshold = e),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
|
||||||
<Switch
|
|
||||||
value={props.modelConfig.sendMemory}
|
|
||||||
onChange={(e) =>
|
|
||||||
props.updateConfig((config) => (config.sendMemory = e))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import UserPromptModal from "./UserPromptModal";
|
|
||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { useAppConfig } from "@/app/store/config";
|
|
||||||
import { SearchService, usePromptStore } from "@/app/store/prompt";
|
|
||||||
|
|
||||||
import Switch from "@/app/components/Switch";
|
|
||||||
import Btn from "@/app/components/Btn";
|
|
||||||
|
|
||||||
import EditIcon from "@/app/icons/editIcon.svg";
|
|
||||||
|
|
||||||
export interface PromptSettingProps {}
|
|
||||||
|
|
||||||
export default function PromptSetting(props: PromptSettingProps) {
|
|
||||||
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
|
|
||||||
|
|
||||||
const config = useAppConfig();
|
|
||||||
const updateConfig = config.update;
|
|
||||||
|
|
||||||
const builtinCount = SearchService.count.builtin;
|
|
||||||
|
|
||||||
const promptStore = usePromptStore();
|
|
||||||
const customCount = promptStore.getUserPrompts().length ?? 0;
|
|
||||||
|
|
||||||
const textStyle = " !text-sm";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Prompt.Disable.Title}
|
|
||||||
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={config.disablePromptHint}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig((config) => (config.disablePromptHint = e))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Prompt.List}
|
|
||||||
subTitle={Locale.Settings.Prompt.ListCount(builtinCount, customCount)}
|
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Btn
|
|
||||||
onClick={() => setShowPromptModal(true)}
|
|
||||||
text={
|
|
||||||
<span className={textStyle}>{Locale.Settings.Prompt.Edit}</span>
|
|
||||||
}
|
|
||||||
prefixIcon={config.isMobileScreen ? undefined : <EditIcon />}
|
|
||||||
></Btn>
|
|
||||||
</div>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
{shouldShowPromptModal && (
|
|
||||||
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Anthropic,
|
|
||||||
Azure,
|
|
||||||
Google,
|
|
||||||
OPENAI_BASE_URL,
|
|
||||||
ServiceProvider,
|
|
||||||
SlotID,
|
|
||||||
} from "@/app/constant";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { useAccessStore } from "@/app/store/access";
|
|
||||||
import { getClientConfig } from "@/app/config/client";
|
|
||||||
import { useAppConfig } from "@/app/store/config";
|
|
||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import Select from "@/app/components/Select";
|
|
||||||
import Switch from "@/app/components/Switch";
|
|
||||||
import Input from "@/app/components/Input";
|
|
||||||
|
|
||||||
export default function ProviderSetting() {
|
|
||||||
const accessStore = useAccessStore();
|
|
||||||
const config = useAppConfig();
|
|
||||||
const { isMobileScreen } = config;
|
|
||||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
id={SlotID.CustomModel}
|
|
||||||
widgetStyle={{
|
|
||||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
|
||||||
inputClassName: "md:min-w-select",
|
|
||||||
rangeClassName: "md:min-w-select",
|
|
||||||
inputNextLine: isMobileScreen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!accessStore.hideUserApiKey && (
|
|
||||||
<>
|
|
||||||
{
|
|
||||||
// Conditionally render the following ListItem based on clientConfig.isApp
|
|
||||||
!clientConfig?.isApp && ( // only show if isApp is false
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.CustomEndpoint.Title}
|
|
||||||
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={accessStore.useCustomConfig}
|
|
||||||
onChange={(e) =>
|
|
||||||
accessStore.update((access) => (access.useCustomConfig = e))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{accessStore.useCustomConfig && (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Provider.Title}
|
|
||||||
subTitle={Locale.Settings.Access.Provider.SubTitle}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={accessStore.provider}
|
|
||||||
onSelect={(e) => {
|
|
||||||
accessStore.update((access) => (access.provider = e));
|
|
||||||
}}
|
|
||||||
options={Object.entries(ServiceProvider).map(([k, v]) => ({
|
|
||||||
value: v,
|
|
||||||
label: k,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
{accessStore.provider === ServiceProvider.OpenAI && (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
|
|
||||||
subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={accessStore.openaiUrl}
|
|
||||||
placeholder={OPENAI_BASE_URL}
|
|
||||||
onChange={(e = "") =>
|
|
||||||
accessStore.update((access) => (access.openaiUrl = e))
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.OpenAI.ApiKey.Title}
|
|
||||||
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={accessStore.openaiApiKey}
|
|
||||||
type="password"
|
|
||||||
placeholder={
|
|
||||||
Locale.Settings.Access.OpenAI.ApiKey.Placeholder
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
accessStore.update(
|
|
||||||
(access) => (access.openaiApiKey = e),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{accessStore.provider === ServiceProvider.Azure && (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Azure.Endpoint.Title}
|
|
||||||
subTitle={
|
|
||||||
Locale.Settings.Access.Azure.Endpoint.SubTitle +
|
|
||||||
Azure.ExampleEndpoint
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={accessStore.azureUrl}
|
|
||||||
placeholder={Azure.ExampleEndpoint}
|
|
||||||
onChange={(e) =>
|
|
||||||
accessStore.update((access) => (access.azureUrl = e))
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Azure.ApiKey.Title}
|
|
||||||
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={accessStore.azureApiKey}
|
|
||||||
type="password"
|
|
||||||
placeholder={
|
|
||||||
Locale.Settings.Access.Azure.ApiKey.Placeholder
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
accessStore.update(
|
|
||||||
(access) => (access.azureApiKey = e),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Azure.ApiVerion.Title}
|
|
||||||
subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={accessStore.azureApiVersion}
|
|
||||||
placeholder="2023-08-01-preview"
|
|
||||||
onChange={(e) =>
|
|
||||||
accessStore.update(
|
|
||||||
(access) => (access.azureApiVersion = e),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{accessStore.provider === ServiceProvider.Google && (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Google.Endpoint.Title}
|
|
||||||
subTitle={
|
|
||||||
Locale.Settings.Access.Google.Endpoint.SubTitle +
|
|
||||||
Google.ExampleEndpoint
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={accessStore.googleUrl}
|
|
||||||
placeholder={Google.ExampleEndpoint}
|
|
||||||
onChange={(e) =>
|
|
||||||
accessStore.update((access) => (access.googleUrl = e))
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Google.ApiKey.Title}
|
|
||||||
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={accessStore.googleApiKey}
|
|
||||||
type="password"
|
|
||||||
placeholder={
|
|
||||||
Locale.Settings.Access.Google.ApiKey.Placeholder
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
accessStore.update(
|
|
||||||
(access) => (access.googleApiKey = e),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Google.ApiVersion.Title}
|
|
||||||
subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={accessStore.googleApiVersion}
|
|
||||||
placeholder="2023-08-01-preview"
|
|
||||||
onChange={(e) =>
|
|
||||||
accessStore.update(
|
|
||||||
(access) => (access.googleApiVersion = e),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
|
|
||||||
subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={accessStore.anthropicApiKey}
|
|
||||||
type="password"
|
|
||||||
placeholder={
|
|
||||||
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
accessStore.update(
|
|
||||||
(access) => (access.anthropicApiKey = e),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Access.CustomModel.Title}
|
|
||||||
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={config.customModels}
|
|
||||||
placeholder="model1,model2,model3"
|
|
||||||
onChange={(e) => config.update((config) => (config.customModels = e))}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import Locale from "@/app/locales";
|
|
||||||
import GobackIcon from "@/app/icons/goback.svg";
|
|
||||||
|
|
||||||
export interface ChatHeaderProps {
|
|
||||||
isMobileScreen: boolean;
|
|
||||||
goback: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingHeader(props: ChatHeaderProps) {
|
|
||||||
const { isMobileScreen, goback } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
relative flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap border-b border-settings-header
|
|
||||||
max-md:h-menu-title-mobile max-md:bg-settings-header-mobile
|
|
||||||
`}
|
|
||||||
data-tauri-drag-region
|
|
||||||
>
|
|
||||||
{isMobileScreen ? (
|
|
||||||
<div
|
|
||||||
className="absolute left-4 top-[50%] translate-y-[-50%] cursor-pointer"
|
|
||||||
onClick={() => goback()}
|
|
||||||
>
|
|
||||||
<GobackIcon />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<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-settings-panel-header-title text-chat-header-title font-common
|
|
||||||
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5 !font-medium
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{Locale.Settings.Title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { Modal } from "@/app/components/ui-lib";
|
|
||||||
import { useSyncStore } from "@/app/store/sync";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { IconButton } from "@/app/components/button";
|
|
||||||
import { ProviderType } from "@/app/utils/cloud";
|
|
||||||
import { STORAGE_KEY } from "@/app/constant";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import ConnectionIcon from "@/app/icons/connection.svg";
|
|
||||||
import CloudSuccessIcon from "@/app/icons/cloud-success.svg";
|
|
||||||
import CloudFailIcon from "@/app/icons/cloud-fail.svg";
|
|
||||||
import ConfirmIcon from "@/app/icons/confirm.svg";
|
|
||||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
|
||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import Switch from "@/app/components/Switch";
|
|
||||||
import Select from "@/app/components/Select";
|
|
||||||
import Input from "@/app/components/Input";
|
|
||||||
import { useAppConfig } from "@/app/store";
|
|
||||||
|
|
||||||
function CheckButton() {
|
|
||||||
const syncStore = useSyncStore();
|
|
||||||
|
|
||||||
const couldCheck = useMemo(() => {
|
|
||||||
return syncStore.cloudSync();
|
|
||||||
}, [syncStore]);
|
|
||||||
|
|
||||||
const [checkState, setCheckState] = useState<
|
|
||||||
"none" | "checking" | "success" | "failed"
|
|
||||||
>("none");
|
|
||||||
|
|
||||||
async function check() {
|
|
||||||
setCheckState("checking");
|
|
||||||
const valid = await syncStore.check();
|
|
||||||
setCheckState(valid ? "success" : "failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!couldCheck) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
text={Locale.Settings.Sync.Config.Modal.Check}
|
|
||||||
bordered
|
|
||||||
onClick={check}
|
|
||||||
icon={
|
|
||||||
checkState === "none" ? (
|
|
||||||
<ConnectionIcon />
|
|
||||||
) : checkState === "checking" ? (
|
|
||||||
<LoadingIcon />
|
|
||||||
) : checkState === "success" ? (
|
|
||||||
<CloudSuccessIcon />
|
|
||||||
) : checkState === "failed" ? (
|
|
||||||
<CloudFailIcon />
|
|
||||||
) : (
|
|
||||||
<ConnectionIcon />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></IconButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SyncConfigModal(props: { onClose?: () => void }) {
|
|
||||||
const syncStore = useSyncStore();
|
|
||||||
const config = useAppConfig();
|
|
||||||
const { isMobileScreen } = config;
|
|
||||||
return (
|
|
||||||
<div className="modal-mask">
|
|
||||||
<Modal
|
|
||||||
title={Locale.Settings.Sync.Config.Modal.Title}
|
|
||||||
onClose={() => props.onClose?.()}
|
|
||||||
actions={[
|
|
||||||
<CheckButton key="check" />,
|
|
||||||
<IconButton
|
|
||||||
key="confirm"
|
|
||||||
onClick={props.onClose}
|
|
||||||
icon={<ConfirmIcon />}
|
|
||||||
bordered
|
|
||||||
text={Locale.UI.Confirm}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
className="!bg-modal-mask active-new"
|
|
||||||
>
|
|
||||||
<List
|
|
||||||
widgetStyle={{
|
|
||||||
rangeNextLine: isMobileScreen,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Sync.Config.SyncType.Title}
|
|
||||||
subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={syncStore.provider}
|
|
||||||
options={Object.entries(ProviderType).map(([k, v]) => ({
|
|
||||||
value: v,
|
|
||||||
label: k,
|
|
||||||
}))}
|
|
||||||
onSelect={(v) => {
|
|
||||||
syncStore.update((config) => (config.provider = v));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Sync.Config.Proxy.Title}
|
|
||||||
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={syncStore.useProxy}
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.useProxy = e));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
{syncStore.useProxy ? (
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Sync.Config.ProxyUrl.Title}
|
|
||||||
subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={syncStore.proxyUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.proxyUrl = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{syncStore.provider === ProviderType.WebDAV && (
|
|
||||||
<>
|
|
||||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={syncStore.webdav.endpoint}
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.webdav.endpoint = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={syncStore.webdav.username}
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.webdav.username = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
|
|
||||||
<Input
|
|
||||||
value={syncStore.webdav.password}
|
|
||||||
type="password"
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.webdav.password = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{syncStore.provider === ProviderType.UpStash && (
|
|
||||||
<>
|
|
||||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={syncStore.upstash.endpoint}
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.upstash.endpoint = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={syncStore.upstash.username}
|
|
||||||
placeholder={STORAGE_KEY}
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.upstash.username = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
|
|
||||||
<Input
|
|
||||||
value={syncStore.upstash.apiKey}
|
|
||||||
type="password"
|
|
||||||
onChange={(e) => {
|
|
||||||
syncStore.update((config) => (config.upstash.apiKey = e));
|
|
||||||
}}
|
|
||||||
></Input>
|
|
||||||
</ListItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import ConfigIcon from "@/app/icons/configIcon2.svg";
|
|
||||||
import ExportIcon from "@/app/icons/exportIcon.svg";
|
|
||||||
import ImportIcon from "@/app/icons/importIcon.svg";
|
|
||||||
import SyncIcon from "@/app/icons/syncIcon.svg";
|
|
||||||
|
|
||||||
import { showToast } from "@/app/components/ui-lib";
|
|
||||||
import { useChatStore } from "@/app/store/chat";
|
|
||||||
import { useMaskStore } from "@/app/store/mask";
|
|
||||||
import { usePromptStore } from "@/app/store/prompt";
|
|
||||||
import { useSyncStore } from "@/app/store/sync";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
|
|
||||||
import SyncConfigModal from "./SyncConfigModal";
|
|
||||||
import List, { ListItem } from "@/app/components/List";
|
|
||||||
import Btn from "@/app/components/Btn";
|
|
||||||
import { useAppConfig } from "@/app/store";
|
|
||||||
|
|
||||||
export default function SyncItems() {
|
|
||||||
const syncStore = useSyncStore();
|
|
||||||
const chatStore = useChatStore();
|
|
||||||
const promptStore = usePromptStore();
|
|
||||||
const maskStore = useMaskStore();
|
|
||||||
const couldSync = useMemo(() => {
|
|
||||||
return syncStore.cloudSync();
|
|
||||||
}, [syncStore]);
|
|
||||||
|
|
||||||
const { isMobileScreen } = useAppConfig();
|
|
||||||
|
|
||||||
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
|
|
||||||
|
|
||||||
const stateOverview = useMemo(() => {
|
|
||||||
const sessions = chatStore.sessions;
|
|
||||||
const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
chat: sessions.length,
|
|
||||||
message: messageCount,
|
|
||||||
prompt: Object.keys(promptStore.prompts).length,
|
|
||||||
mask: Object.keys(maskStore.masks).length,
|
|
||||||
};
|
|
||||||
}, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
|
|
||||||
|
|
||||||
const textStyle = "!text-sm";
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<List>
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Sync.CloudState}
|
|
||||||
subTitle={
|
|
||||||
syncStore.lastProvider
|
|
||||||
? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
|
|
||||||
syncStore.lastProvider
|
|
||||||
}]`
|
|
||||||
: Locale.Settings.Sync.NotSyncYet
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Btn
|
|
||||||
onClick={() => {
|
|
||||||
setShowSyncConfigModal(true);
|
|
||||||
}}
|
|
||||||
text={<span className={textStyle}>{Locale.UI.Config}</span>}
|
|
||||||
prefixIcon={isMobileScreen ? undefined : <ConfigIcon />}
|
|
||||||
></Btn>
|
|
||||||
{couldSync && (
|
|
||||||
<Btn
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await syncStore.sync();
|
|
||||||
showToast(Locale.Settings.Sync.Success);
|
|
||||||
} catch (e) {
|
|
||||||
showToast(Locale.Settings.Sync.Fail);
|
|
||||||
console.error("[Sync]", e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
text={<span className={textStyle}>{Locale.UI.Sync}</span>}
|
|
||||||
prefixIcon={<SyncIcon />}
|
|
||||||
></Btn>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={Locale.Settings.Sync.LocalState}
|
|
||||||
subTitle={Locale.Settings.Sync.Overview(stateOverview)}
|
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Btn
|
|
||||||
onClick={() => {
|
|
||||||
syncStore.export();
|
|
||||||
}}
|
|
||||||
text={<span className={textStyle}>{Locale.UI.Export}</span>}
|
|
||||||
prefixIcon={<ExportIcon />}
|
|
||||||
></Btn>
|
|
||||||
<Btn
|
|
||||||
onClick={async () => {
|
|
||||||
syncStore.import();
|
|
||||||
}}
|
|
||||||
text={<span className={textStyle}>{Locale.UI.Import}</span>}
|
|
||||||
prefixIcon={<ImportIcon />}
|
|
||||||
></Btn>
|
|
||||||
</div>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{showSyncConfigModal && (
|
|
||||||
<SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { Prompt, SearchService, usePromptStore } from "@/app/store/prompt";
|
|
||||||
import { Input as Textarea, Modal } from "@/app/components/ui-lib";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import { IconButton } from "@/app/components/button";
|
|
||||||
|
|
||||||
import AddIcon from "@/app/icons/add.svg";
|
|
||||||
import CopyIcon from "@/app/icons/copy.svg";
|
|
||||||
import ClearIcon from "@/app/icons/clear.svg";
|
|
||||||
import EditIcon from "@/app/icons/edit.svg";
|
|
||||||
import EyeIcon from "@/app/icons/eye.svg";
|
|
||||||
|
|
||||||
import styles from "../index.module.scss";
|
|
||||||
import { copyToClipboard } from "@/app/utils";
|
|
||||||
import Input from "@/app/components/Input";
|
|
||||||
|
|
||||||
function EditPromptModal(props: { id: string; onClose: () => void }) {
|
|
||||||
const promptStore = usePromptStore();
|
|
||||||
const prompt = promptStore.get(props.id);
|
|
||||||
|
|
||||||
return prompt ? (
|
|
||||||
<div className="modal-mask">
|
|
||||||
<Modal
|
|
||||||
title={Locale.Settings.Prompt.EditModal.Title}
|
|
||||||
onClose={props.onClose}
|
|
||||||
actions={[
|
|
||||||
<IconButton
|
|
||||||
key=""
|
|
||||||
onClick={props.onClose}
|
|
||||||
text={Locale.UI.Confirm}
|
|
||||||
bordered
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
// className="!bg-modal-mask"
|
|
||||||
>
|
|
||||||
<div className={styles["edit-prompt-modal"]}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={prompt.title}
|
|
||||||
readOnly={!prompt.isUser}
|
|
||||||
className={styles["edit-prompt-title"]}
|
|
||||||
onChange={(e) =>
|
|
||||||
promptStore.updatePrompt(props.id, (prompt) => (prompt.title = e))
|
|
||||||
}
|
|
||||||
></Input>
|
|
||||||
<Textarea
|
|
||||||
value={prompt.content}
|
|
||||||
readOnly={!prompt.isUser}
|
|
||||||
className={styles["edit-prompt-content"]}
|
|
||||||
rows={10}
|
|
||||||
onInput={(e) =>
|
|
||||||
promptStore.updatePrompt(
|
|
||||||
props.id,
|
|
||||||
(prompt) => (prompt.content = e.currentTarget.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Textarea>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserPromptModal(props: { onClose?: () => void }) {
|
|
||||||
const promptStore = usePromptStore();
|
|
||||||
const userPrompts = promptStore.getUserPrompts();
|
|
||||||
const builtinPrompts = SearchService.builtinPrompts;
|
|
||||||
const allPrompts = userPrompts.concat(builtinPrompts);
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
|
||||||
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
|
|
||||||
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
|
|
||||||
|
|
||||||
const [editingPromptId, setEditingPromptId] = useState<string>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchInput.length > 0) {
|
|
||||||
const searchResult = SearchService.search(searchInput);
|
|
||||||
setSearchPrompts(searchResult);
|
|
||||||
} else {
|
|
||||||
setSearchPrompts([]);
|
|
||||||
}
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-mask">
|
|
||||||
<Modal
|
|
||||||
title={Locale.Settings.Prompt.Modal.Title}
|
|
||||||
onClose={() => props.onClose?.()}
|
|
||||||
actions={[
|
|
||||||
<IconButton
|
|
||||||
key="add"
|
|
||||||
onClick={() => {
|
|
||||||
const promptId = promptStore.add({
|
|
||||||
id: nanoid(),
|
|
||||||
createdAt: Date.now(),
|
|
||||||
title: "Empty Prompt",
|
|
||||||
content: "Empty Prompt Content",
|
|
||||||
});
|
|
||||||
setEditingPromptId(promptId);
|
|
||||||
}}
|
|
||||||
icon={<AddIcon />}
|
|
||||||
bordered
|
|
||||||
text={Locale.Settings.Prompt.Modal.Add}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
// className="!bg-modal-mask"
|
|
||||||
>
|
|
||||||
<div className={styles["user-prompt-modal"]}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
className={styles["user-prompt-search"]}
|
|
||||||
placeholder={Locale.Settings.Prompt.Modal.Search}
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => setSearchInput(e)}
|
|
||||||
></Input>
|
|
||||||
|
|
||||||
<div className={styles["user-prompt-list"]}>
|
|
||||||
{prompts.map((v, _) => (
|
|
||||||
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
|
|
||||||
<div className={styles["user-prompt-header"]}>
|
|
||||||
<div className={styles["user-prompt-title"]}>{v.title}</div>
|
|
||||||
<div className={styles["user-prompt-content"] + " one-line"}>
|
|
||||||
{v.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles["user-prompt-buttons"]}>
|
|
||||||
{v.isUser && (
|
|
||||||
<IconButton
|
|
||||||
icon={<ClearIcon />}
|
|
||||||
className={styles["user-prompt-button"]}
|
|
||||||
onClick={() => promptStore.remove(v.id!)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{v.isUser ? (
|
|
||||||
<IconButton
|
|
||||||
icon={<EditIcon />}
|
|
||||||
className={styles["user-prompt-button"]}
|
|
||||||
onClick={() => setEditingPromptId(v.id)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={<EyeIcon />}
|
|
||||||
className={styles["user-prompt-button"]}
|
|
||||||
onClick={() => setEditingPromptId(v.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
icon={<CopyIcon />}
|
|
||||||
className={styles["user-prompt-button"]}
|
|
||||||
onClick={() => copyToClipboard(v.content)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{editingPromptId !== undefined && (
|
|
||||||
<EditPromptModal
|
|
||||||
id={editingPromptId!}
|
|
||||||
onClose={() => setEditingPromptId(undefined)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
.avatar {
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-prompt-modal {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.edit-prompt-title {
|
|
||||||
max-width: unset;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.edit-prompt-content {
|
|
||||||
max-width: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-prompt-modal {
|
|
||||||
min-height: 40vh;
|
|
||||||
|
|
||||||
.user-prompt-search {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-prompt-list {
|
|
||||||
border: var(--border-in-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
.user-prompt-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: var(--border-in-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-prompt-header {
|
|
||||||
max-width: calc(100% - 100px);
|
|
||||||
|
|
||||||
.user-prompt-title {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 2;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.user-prompt-content {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-prompt-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 2px;
|
|
||||||
|
|
||||||
.user-prompt-button {
|
|
||||||
//height: 100%;
|
|
||||||
padding: 7px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import Locale from "@/app/locales";
|
|
||||||
import MenuLayout from "@/app/components/MenuLayout";
|
|
||||||
|
|
||||||
import Panel from "./SettingPanel";
|
|
||||||
|
|
||||||
import GotoIcon from "@/app/icons/goto.svg";
|
|
||||||
import { useAppConfig } from "@/app/store";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export const list = [
|
|
||||||
{
|
|
||||||
id: Locale.Settings.GeneralSettings,
|
|
||||||
title: Locale.Settings.GeneralSettings,
|
|
||||||
icon: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Locale.Settings.ModelSettings,
|
|
||||||
title: Locale.Settings.ModelSettings,
|
|
||||||
icon: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Locale.Settings.DataSettings,
|
|
||||||
title: Locale.Settings.DataSettings,
|
|
||||||
icon: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default MenuLayout(function SettingList(props) {
|
|
||||||
const { setShowPanel, setExternalProps } = props;
|
|
||||||
const config = useAppConfig();
|
|
||||||
|
|
||||||
const { isMobileScreen } = config;
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState(list[0].id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setExternalProps?.(list[0]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
max-md:h-[100%] max-md:mx-[-1rem] max-md:py-6 max-md:px-4 max-md:bg-settings-menu-mobile
|
|
||||||
md:pt-7
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div data-tauri-drag-region>
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex items-center justify-between
|
|
||||||
max-md:h-menu-title-mobile
|
|
||||||
md:pb-5 md:px-4
|
|
||||||
`}
|
|
||||||
data-tauri-drag-region
|
|
||||||
>
|
|
||||||
<div className="text-setting-title text-text-settings-menu-title font-common !font-bold">
|
|
||||||
{Locale.Settings.Title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`flex flex-col gap-2 overflow-y-auto overflow-x-hidden w-[100%]`}
|
|
||||||
>
|
|
||||||
{list.map((i) => (
|
|
||||||
<div
|
|
||||||
key={i.id}
|
|
||||||
className={`
|
|
||||||
p-4 font-common text-setting-items font-normal text-text-settings-menu-item-title
|
|
||||||
cursor-pointer
|
|
||||||
border
|
|
||||||
rounded-md
|
|
||||||
border-transparent
|
|
||||||
${
|
|
||||||
selected === i.id && !isMobileScreen
|
|
||||||
? `!bg-chat-menu-session-selected !border-chat-menu-session-selected !font-medium`
|
|
||||||
: `hover:bg-chat-menu-session-unselected hover:border-chat-menu-session-unselected`
|
|
||||||
}
|
|
||||||
|
|
||||||
flex justify-between items-center
|
|
||||||
max-md:bg-settings-menu-item-mobile
|
|
||||||
`}
|
|
||||||
onClick={() => {
|
|
||||||
setShowPanel?.(true);
|
|
||||||
setExternalProps?.(i);
|
|
||||||
setSelected(i.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i.title}
|
|
||||||
{i.icon}
|
|
||||||
{isMobileScreen && <GotoIcon />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, Panel);
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import GitHubIcon from "@/app/icons/githubIcon.svg";
|
|
||||||
import DiscoverIcon from "@/app/icons/discoverActive.svg";
|
|
||||||
import DiscoverInactiveIcon from "@/app/icons/discoverInactive.svg";
|
|
||||||
import DiscoverMobileActive from "@/app/icons/discoverMobileActive.svg";
|
|
||||||
import DiscoverMobileInactive from "@/app/icons/discoverMobileInactive.svg";
|
|
||||||
import SettingIcon from "@/app/icons/settingActive.svg";
|
|
||||||
import SettingInactiveIcon from "@/app/icons/settingInactive.svg";
|
|
||||||
import SettingMobileActive from "@/app/icons/settingMobileActive.svg";
|
|
||||||
import SettingMobileInactive from "@/app/icons/settingMobileInactive.svg";
|
|
||||||
import AssistantActiveIcon from "@/app/icons/assistantActive.svg";
|
|
||||||
import AssistantInactiveIcon from "@/app/icons/assistantInactive.svg";
|
|
||||||
import AssistantMobileActive from "@/app/icons/assistantMobileActive.svg";
|
|
||||||
import AssistantMobileInactive from "@/app/icons/assistantMobileInactive.svg";
|
|
||||||
|
|
||||||
import { useAppConfig } from "@/app/store";
|
|
||||||
import { Path, REPO_URL } from "@/app/constant";
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import useHotKey from "@/app/hooks/useHotKey";
|
|
||||||
import ActionsBar from "@/app/components/ActionsBar";
|
|
||||||
|
|
||||||
export function SideBar(props: { className?: string }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const loc = useLocation();
|
|
||||||
|
|
||||||
const config = useAppConfig();
|
|
||||||
const { isMobileScreen } = config;
|
|
||||||
|
|
||||||
useHotKey();
|
|
||||||
|
|
||||||
let selectedTab: string;
|
|
||||||
|
|
||||||
switch (loc.pathname) {
|
|
||||||
case Path.Masks:
|
|
||||||
case Path.NewChat:
|
|
||||||
selectedTab = Path.Masks;
|
|
||||||
break;
|
|
||||||
case Path.Settings:
|
|
||||||
selectedTab = Path.Settings;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
selectedTab = Path.Home;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex h-[100%]
|
|
||||||
max-md:flex-col-reverse max-md:w-[100%]
|
|
||||||
md:relative
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<ActionsBar
|
|
||||||
inMobile={isMobileScreen}
|
|
||||||
actionsSchema={[
|
|
||||||
{
|
|
||||||
id: Path.Masks,
|
|
||||||
icons: {
|
|
||||||
active: <DiscoverIcon />,
|
|
||||||
inactive: <DiscoverInactiveIcon />,
|
|
||||||
mobileActive: <DiscoverMobileActive />,
|
|
||||||
mobileInactive: <DiscoverMobileInactive />,
|
|
||||||
},
|
|
||||||
title: "Discover",
|
|
||||||
activeClassName: "shadow-sidebar-btn-shadow",
|
|
||||||
className: "mb-4 hover:bg-sidebar-btn-hovered",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Path.Home,
|
|
||||||
icons: {
|
|
||||||
active: <AssistantActiveIcon />,
|
|
||||||
inactive: <AssistantInactiveIcon />,
|
|
||||||
mobileActive: <AssistantMobileActive />,
|
|
||||||
mobileInactive: <AssistantMobileInactive />,
|
|
||||||
},
|
|
||||||
title: "Assistant",
|
|
||||||
activeClassName: "shadow-sidebar-btn-shadow",
|
|
||||||
className: "mb-4 hover:bg-sidebar-btn-hovered",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "github",
|
|
||||||
icons: <GitHubIcon />,
|
|
||||||
className: "!p-2 mb-3 hover:bg-sidebar-btn-hovered",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: Path.Settings,
|
|
||||||
icons: {
|
|
||||||
active: <SettingIcon />,
|
|
||||||
inactive: <SettingInactiveIcon />,
|
|
||||||
mobileActive: <SettingMobileActive />,
|
|
||||||
mobileInactive: <SettingMobileInactive />,
|
|
||||||
},
|
|
||||||
className: "!p-2 hover:bg-sidebar-btn-hovered",
|
|
||||||
title: "Settrings",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onSelect={(id) => {
|
|
||||||
if (id === "github") {
|
|
||||||
return window.open(REPO_URL, "noopener noreferrer");
|
|
||||||
}
|
|
||||||
if (id !== Path.Masks) {
|
|
||||||
return navigate(id);
|
|
||||||
}
|
|
||||||
if (config.dontShowMaskSplashScreen !== true) {
|
|
||||||
navigate(Path.NewChat, { state: { fromHome: true } });
|
|
||||||
} else {
|
|
||||||
navigate(Path.Masks, { state: { fromHome: true } });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
groups={{
|
|
||||||
normal: [
|
|
||||||
[Path.Home, Path.Masks],
|
|
||||||
["github", Path.Settings],
|
|
||||||
],
|
|
||||||
mobile: [[Path.Home, Path.Masks, Path.Settings]],
|
|
||||||
}}
|
|
||||||
selected={selectedTab}
|
|
||||||
className={`
|
|
||||||
max-md:bg-sidebar-mobile max-md:h-mobile max-md:justify-around
|
|
||||||
2xl:px-5 xl:px-4 md:px-2 md:py-6 md:flex-col
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user