Compare commits

...

79 Commits

Author SHA1 Message Date
Dean-YZG
3fcf0513d2 Merge branch 'feat-redesign-ui' of github.com:ChatGPTNextWeb/ChatGPT-Next-Web into feat-redesign-ui 2024-05-22 21:32:31 +08:00
Dean-YZG
8de8acdce8 feat: mix handlers of proxy server in providers 2024-05-22 21:31:54 +08:00
fred-bf
77e321c7cb Merge branch 'v3' into feat-redesign-ui 2024-05-22 14:57:07 +08:00
Dean-YZG
8093d1ffba feat: 1) Present 'maxtokens' as properties tied to a single model. 2) Remove the original author's implementation of the send verification logic and replace it with a user input validator. Pre-verification 3) Provides the ability to pull the 'User Visible modellist' provided by 'provider' 4) Provider-related parameters are passed in the constructor of 'providerClient'. Not passed in the 'chat' method 2024-05-17 21:11:21 +08:00
Dean-YZG
74a6e1260e feat: merge main 2024-05-16 15:28:10 +08:00
DeanYao
f55f04ab4f Merge pull request #4671 from ChatGPTNextWeb/chore-fix
Chore fix
2024-05-16 14:51:06 +08:00
Dean-YZG
0aa807df19 feat: remove empty memoryPrompt in ChatMessages 2024-05-16 14:41:18 +08:00
fred-bf
48d44ece58 Merge branch 'main' into chore-fix 2024-05-16 14:13:28 +08:00
fred-bf
bffd9d9173 Merge pull request #4706 from leo4life2/patch-1
gpt-4o as vision model
2024-05-16 14:09:58 +08:00
Leo Li
8688842984 gpt-4o as vision model
https://platform.openai.com/docs/guides/vision
2024-05-15 17:53:27 -04:00
Dean-YZG
a0e4a468d6 feat: model provider refactor done 2024-05-15 21:38:25 +08:00
fred-bf
1e00c89988 Merge pull request #4703 from ChatGPTNextWeb/feat/gemini-flash
feat: add gemini flash into vision model list
2024-05-15 15:44:45 +08:00
fred-bf
0eccb547b5 Merge branch 'main' into feat/gemini-flash 2024-05-15 15:44:35 +08:00
Fred
4789a7f6a9 feat: add gemini flash into vision model list 2024-05-15 15:42:06 +08:00
fred-bf
0bf758afd4 Merge pull request #4702 from ChatGPTNextWeb/feat/gemini-flash
feat: support gemini flash
2024-05-15 15:30:23 +08:00
Fred
6612550c06 feat: support gemini flash 2024-05-15 15:29:38 +08:00
fred-bf
cf635a5e6f Merge pull request #4684 from ChatGPTNextWeb/fred-bf-patch-4
feat: bump version
2024-05-14 17:36:06 +08:00
fred-bf
3a007e4f3d feat: bump version 2024-05-14 17:35:58 +08:00
fred-bf
9faab960f6 Merge pull request #4674 from leo4life2/main
support gpt-4o
2024-05-14 14:36:23 +08:00
fred-bf
5df8b1d183 fix: revert gpt-4-turbo-preview detection 2024-05-14 14:32:34 +08:00
Leo Li
ef5f910f19 support gpt-4o 2024-05-13 17:28:13 -04:00
Dean-YZG
fffbee80e8 Merge remote-tracking branch 'origin/main' into chore-fix 2024-05-13 17:58:28 +08:00
DeanYao
6b30e167e1 Merge pull request #4647 from ChatGPTNextWeb/dependabot/npm_and_yarn/next-14.1.1
chore(deps): bump next from 13.4.9 to 14.1.1
2024-05-13 17:15:08 +08:00
DeanYao
8ec721259a Merge pull request #4670 from DmitrySandalov/patch-1
Fix typo for "OpenAI Endpoint" in the en locale
2024-05-13 17:13:44 +08:00
Dean-YZG
9d7ce207b6 feat: support env var DEFAULT_INPUT_TEMPLATE to custom default template for preprocessing user inputs 2024-05-13 17:11:35 +08:00
Dean-YZG
2d1f0c9f57 feat: support env var DEFAULT_INPUT_TEMPLATE to custom default template for preprocessing user inputs 2024-05-13 17:11:11 +08:00
Dmitry Sandalov
d3131d2f55 Fix typo for "OpenAI Endpoint" in the en locale 2024-05-13 10:39:49 +02:00
Dean-YZG
c10447df79 feat: 1)upload image with type 'heic' 2)change the empty message to ';' for models 3) 2024-05-13 16:24:15 +08:00
DeanYao
212ae76d76 Merge pull request #4610 from rooben-me/fix-sync
Fix Sync Issue with Upstash
2024-05-13 11:28:29 +08:00
dependabot[bot]
cd48f7eff4 chore(deps): bump next from 13.4.9 to 14.1.1
Bumps [next](https://github.com/vercel/next.js) from 13.4.9 to 14.1.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.4.9...v14.1.1)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-10 00:27:02 +00:00
Fred
00b1a9781d Merge branch 'feat-redesign-ui' into v3 2024-05-08 14:24:43 +08:00
DeanYao
3513c6801e Merge pull request #4626 from ChatGPTNextWeb/chore-fix
feat: googleApiKey & anthropicApiKey support setting multi-key
2024-05-07 15:06:02 +08:00
Dean-YZG
240d330001 feat: 1)add font source 2)add validator in ListItem 3)settings page ui optiminize 2024-05-07 15:05:29 +08:00
Dean-YZG
4e4431339f feat: old page ui style optiminize 2024-05-07 11:22:17 +08:00
Dean-YZG
fa2f8c66d1 feat: old page dark mode compatible 2024-05-06 22:23:21 +08:00
Dean-YZG
864529cbf6 feat: googleApiKey & anthropicApiKey support setting multi-key 2024-05-06 21:14:53 +08:00
DeanYao
58c0d3e12d Merge pull request #4625 from ChatGPTNextWeb/chore-fix
feat: fix 1)the property named 'role' of the first message must be 'u…
2024-05-06 20:48:29 +08:00
Dean-YZG
a1493bfb4e feat: bugfix 2024-05-06 20:46:53 +08:00
butterfly
b3e856df1d feat: fix 1)the property named 'role' of the first message must be 'user' 2)if default summarize model 'gpt-3.5-turbo' is blocked, use currentModel instead 3)if apiurl&apikey set by location, useCustomConfig would be opened 2024-05-06 19:26:39 +08:00
Fred
32f62d70af feat: bump version 2024-05-06 14:15:27 +08:00
ruban
8ef2617eec Removed spaces 2024-05-02 23:24:41 -07:00
ruban
1da7d81122 Fix cloud data sync issue with Upstash (#4563) 2024-05-02 23:22:32 -07:00
ruban
a103582346 fix 2024-05-02 23:10:10 -07:00
ruban
7b61d05e88 new fix 2024-05-02 23:08:17 -07:00
ruban
6fc7c50f19 this 2024-05-02 22:55:41 -07:00
ruban
9d728ec3c5 this is ti 2024-05-02 22:50:35 -07:00
ruban
9cd3358e4e this is the fix 2024-05-02 22:40:52 -07:00
ruban
4cd94370e8 fix i think 2024-05-03 05:25:11 +00:00
butterfly
68f0fa917f complete colors in dark mode 2024-05-01 22:35:23 +08:00
butterfly
8a14cb19a9 feat: merge remote 2024-04-30 19:19:27 +08:00
butterfly
3d99965a8f feat: bugfix 2024-04-30 19:11:59 +08:00
Fred
4d5a9476b6 style: add transition 2024-04-30 19:04:17 +08:00
Fred
15d6ed252f style: add transition 2024-04-30 19:02:18 +08:00
Fred
ecf6cc27d6 style: add transition 2024-04-30 18:58:19 +08:00
Fred
cadd2558fd chore: update settings width 2024-04-30 17:42:59 +08:00
fred-bf
52312dbd23 Merge pull request #4595 from ChatGPTNextWeb/feat/bump-version
feat: bump version code
2024-04-30 13:28:30 +08:00
Fred
b2e8a1eaa2 feat: bump version code 2024-04-30 13:27:07 +08:00
butterfly
c3d91bf0cd feat: complete the missing UI 2024-04-30 12:37:28 +08:00
butterfly
996537d262 feat: optiminize modal UE on mobile dev 2024-04-30 11:03:37 +08:00
butterfly
5ea6206319 feat: select model done 2024-04-29 20:37:27 +08:00
butterfly
8c28c408d8 feat: refactor select model 2024-04-29 16:29:47 +08:00
butterfly
c34b8ab919 feat: ui optiminize 2024-04-28 19:58:59 +08:00
butterfly
9f4813326c feat: HoverPopover done 2024-04-28 14:10:58 +08:00
butterfly
9569888b0e feat: ui fixed 2024-04-28 12:49:06 +08:00
butterfly
1a636b0f50 feat: function delete chat dev done 2024-04-26 19:33:22 +08:00
butterfly
48e8c0a194 feat: optiminize 2024-04-26 01:31:03 +08:00
butterfly
59583e53bd feat: light theme mode 2024-04-25 21:57:50 +08:00
butterfly
bb7422c526 Merge remote-tracking branch 'origin/main' into feat-redesign-ui 2024-04-25 11:02:12 +08:00
butterfly
c99086447e feat: redesign settings page 2024-04-24 15:44:24 +08:00
butterfly
f7074bba8c feat: chat panel header add zindex config 2024-04-22 11:31:53 +08:00
butterfly
4400392c0c feat: chat panel header background blur&transparent 2024-04-22 11:29:20 +08:00
butterfly
4a5465f884 feat: chat panel header absolute 2024-04-22 11:02:25 +08:00
butterfly
37cc87531c feat: optiminize message&img display 2024-04-19 19:28:48 +08:00
butterfly
1074fffe79 feat: clear trash 2024-04-19 14:50:11 +08:00
butterfly
3d0a98d5d2 feat: maskpage&newchatpage adapt new ui framework done 2024-04-19 11:55:51 +08:00
butterfly
b3559f99a2 feat: chat panel UE done 2024-04-18 12:27:44 +08:00
butterfly
51a1d9f92a feat: chat panel redesigned ui 2024-04-16 14:07:51 +08:00
butterfly
3fc9b91bf1 feat: choe 2024-04-12 10:59:28 +08:00
butterfly
0a8e5d6734 feat: seperate chat page 2024-04-12 10:57:57 +08:00
198 changed files with 14531 additions and 261 deletions

View File

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

View File

@@ -245,13 +245,17 @@ 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` (可选) ### `WHITE_WEBDEV_ENDPOINTS` (optional)
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format 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

View File

@@ -156,6 +156,9 @@ anthropic claude Api Url.
用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。 用来控制模型列表,使用 `+` 增加一个模型,使用 `-` 来隐藏一个模型,使用 `模型名=展示名` 来自定义模型的展示名,用英文逗号隔开。
### `DEFAULT_INPUT_TEMPLATE` (可选)
自定义默认的 template用于初始化『设置』中的『用户输入预处理』配置项
## 开发 ## 开发
点击下方按钮,开始二次开发: 点击下方按钮,开始二次开发:

View File

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

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant"; import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
import { getServerSideConfig } from "@/app/config/server"; import { getServerSideConfig } from "@/app/config/server";
const config = getServerSideConfig(); const config = getServerSideConfig();
const mergedWhiteWebDavEndpoints = [ const mergedAllowedWebDavEndpoints = [
...internalWhiteWebDavEndpoints, ...internalAllowedWebDavEndpoints,
...config.whiteWebDevEndpoints, ...config.allowedWebDevEndpoints,
].filter((domain) => Boolean(domain.trim())); ].filter((domain) => Boolean(domain.trim()));
async function handle( async function handle(
@@ -24,7 +24,9 @@ async function handle(
// Validate the endpoint to prevent potential SSRF attacks // Validate the endpoint to prevent potential SSRF attacks
if ( if (
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white)) !mergedAllowedWebDavEndpoints.some(
(allowedEndpoint) => endpoint?.startsWith(allowedEndpoint),
)
) { ) {
return NextResponse.json( return NextResponse.json(
{ {

View File

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

View File

@@ -0,0 +1,19 @@
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];
}

211
app/client/common/types.ts Normal file
View File

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

View File

@@ -0,0 +1,88 @@
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
);
}

9
app/client/core/index.ts Normal file
View File

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

View File

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

View File

@@ -0,0 +1,256 @@
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 };

25
app/client/core/shim.ts Normal file
View File

@@ -0,0 +1,25 @@
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;
}

3
app/client/index.ts Normal file
View File

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

View File

@@ -161,6 +161,13 @@ export class ClaudeApi implements LLMApi {
}; };
}); });
if (prompt[0]?.role === "assistant") {
prompt.unshift({
role: "user",
content: ";",
});
}
const requestBody: AnthropicChatRequest = { const requestBody: AnthropicChatRequest = {
messages: prompt, messages: prompt,
stream: shouldStream, stream: shouldStream,

View File

@@ -120,7 +120,9 @@ export class GeminiProApi implements LLMApi {
if (!baseUrl) { if (!baseUrl) {
baseUrl = isApp baseUrl = isApp
? DEFAULT_API_HOST + "/api/proxy/google/" + Google.ChatPath(modelConfig.model) ? DEFAULT_API_HOST +
"/api/proxy/google/" +
Google.ChatPath(modelConfig.model)
: this.path(Google.ChatPath(modelConfig.model)); : this.path(Google.ChatPath(modelConfig.model));
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,408 @@
import {
settingItems,
SettingKeys,
modelConfigs,
AzureMetas,
preferredRegion,
} from "./config";
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ModelInfo,
getMessageTextContent,
ServerConfig,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import Locale from "@/app/locales";
import {
auth,
authHeaderName,
getHeaders,
getTimer,
makeAzurePath,
parseResp,
prettyObject,
} from "./utils";
import { NextRequest, NextResponse } from "next/server";
export type AzureProviderSettingKeys = SettingKeys;
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string | MultimodalContent[];
}
interface RequestPayload {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
}[];
stream?: boolean;
model: string;
temperature: number;
presence_penalty: number;
frequency_penalty: number;
top_p: number;
max_tokens?: number;
}
interface ModelList {
object: "list";
data: Array<{
capabilities: {
fine_tune: boolean;
inference: boolean;
completion: boolean;
chat_completion: boolean;
embeddings: boolean;
};
lifecycle_status: "generally-available";
id: string;
created_at: number;
object: "model";
}>;
}
interface OpenAIListModelResponse {
object: string;
data: Array<{
id: string;
object: string;
root: string;
}>;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof AzureMetas
>;
export default class Azure implements ProviderTemplate {
apiRouteRootName: "/api/provider/azure" = "/api/provider/azure";
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["POST", "GET"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "azure" as const;
metas = AzureMetas;
defaultModels = modelConfigs;
providerMeta = {
displayName: "Azure",
settingItems: settingItems(
`${this.apiRouteRootName}/${AzureMetas.ChatPath}`,
),
};
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages,
isVisionModel,
model,
stream,
modelConfig: {
temperature,
presence_penalty,
frequency_penalty,
top_p,
max_tokens,
},
providerConfig: { azureUrl, azureApiVersion },
} = payload;
const openAiMessages = messages.map((v) => ({
role: v.role,
content: isVisionModel ? v.content : getMessageTextContent(v),
}));
const requestPayload: RequestPayload = {
messages: openAiMessages,
stream,
model,
temperature,
presence_penalty,
frequency_penalty,
top_p,
};
// add max_tokens to vision model
if (isVisionModel) {
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
return {
headers: getHeaders(payload.providerConfig.azureApiKey),
body: JSON.stringify(requestPayload),
method: "POST",
url: `${azureUrl}?api-version=${azureApiVersion!}`,
};
}
private async requestAzure(req: NextRequest, serverConfig: ServerConfig) {
const controller = new AbortController();
const authValue =
req.headers
.get("Authorization")
?.trim()
.replaceAll("Bearer ", "")
.trim() ?? "";
const { azureUrl, azureApiVersion } = serverConfig;
if (!azureUrl) {
return NextResponse.json({
error: true,
message: `missing AZURE_URL in server env vars`,
});
}
if (!azureApiVersion) {
return NextResponse.json({
error: true,
message: `missing AZURE_API_VERSION in server env vars`,
});
}
let path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
this.apiRouteRootName,
"",
);
path = makeAzurePath(path, azureApiVersion);
console.log("[Proxy] ", path);
console.log("[Base Url]", azureUrl);
const fetchUrl = `${azureUrl}/${path}`;
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
},
method: req.method,
body: req.body,
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
try {
const res = await fetch(fetchUrl, fetchOptions);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
// So if the streaming is disabled, we need to remove the content-encoding header
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
// The browser will try to decode the response with brotli and fail
newHeaders.delete("content-encoding");
return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
if (delta) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
async getAvailableModels(
providerConfig: Record<SettingKeys, string>,
): Promise<ModelInfo[]> {
const { azureApiKey, azureUrl } = providerConfig;
const res = await fetch(`${azureUrl}/${AzureMetas.ListModelPath}`, {
headers: {
Authorization: `Bearer ${azureApiKey}`,
},
method: "GET",
});
const data: ModelList = await res.json();
return data.data.map((o) => ({
name: o.id,
}));
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = [AzureMetas.ChatPath];
if (!ALLOWD_PATH.includes(subpath)) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.requestAzure(req, config);
return response;
} catch (e) {
return NextResponse.json(prettyObject(e));
}
};
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,353 @@
import {
SettingKeys,
modelConfigs,
settingItems,
GoogleMetas,
GEMINI_BASE_URL,
preferredRegion,
} from "./config";
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ModelInfo,
StandChatReponseMessage,
getMessageTextContent,
getMessageImages,
} from "../../common";
import {
auth,
ensureProperEnding,
getTimer,
parseResp,
urlParamApikeyName,
} from "./utils";
import { NextResponse } from "next/server";
export type GoogleProviderSettingKeys = SettingKeys;
interface ModelList {
models: Array<{
name: string;
baseModelId: string;
version: string;
displayName: string;
description: string;
inputTokenLimit: number; // Integer
outputTokenLimit: number; // Integer
supportedGenerationMethods: [string];
temperature: number;
topP: number;
topK: number; // Integer
}>;
nextPageToken: string;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof GoogleMetas
>;
export default class GoogleProvider
implements IProviderTemplate<SettingKeys, "google", typeof GoogleMetas>
{
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["GET", "POST"];
runtime = "edge" as const;
apiRouteRootName: "/api/provider/google" = "/api/provider/google";
preferredRegion = preferredRegion;
name = "google" as const;
metas = GoogleMetas;
providerMeta = {
displayName: "Google",
settingItems: settingItems(this.apiRouteRootName),
};
defaultModels = modelConfigs;
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages,
isVisionModel,
model,
stream,
modelConfig,
providerConfig,
} = payload;
const { googleUrl, googleApiKey } = providerConfig;
const { temperature, top_p, max_tokens } = modelConfig;
const internalMessages = messages.map((v) => {
let parts: any[] = [{ text: getMessageTextContent(v) }];
if (isVisionModel) {
const images = getMessageImages(v);
if (images.length > 0) {
parts = parts.concat(
images.map((image) => {
const imageType = image.split(";")[0].split(":")[1];
const imageData = image.split(",")[1];
return {
inline_data: {
mime_type: imageType,
data: imageData,
},
};
}),
);
}
}
return {
role: v.role.replace("assistant", "model").replace("system", "user"),
parts: parts,
};
});
// google requires that role in neighboring messages must not be the same
for (let i = 0; i < internalMessages.length - 1; ) {
// Check if current and next item both have the role "model"
if (internalMessages[i].role === internalMessages[i + 1].role) {
// Concatenate the 'parts' of the current and next item
internalMessages[i].parts = internalMessages[i].parts.concat(
internalMessages[i + 1].parts,
);
// Remove the next item
internalMessages.splice(i + 1, 1);
} else {
// Move to the next item
i++;
}
}
const requestPayload = {
contents: internalMessages,
generationConfig: {
temperature,
maxOutputTokens: max_tokens,
topP: top_p,
},
safetySettings: [
{
category: "HARM_CATEGORY_HARASSMENT",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_HATE_SPEECH",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_ONLY_HIGH",
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_ONLY_HIGH",
},
],
};
const baseUrl = `${googleUrl}/${GoogleMetas.ChatPath(
model,
)}?${urlParamApikeyName}=${googleApiKey}`;
return {
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(requestPayload),
method: "POST",
url: stream
? baseUrl.replace("generateContent", "streamGenerateContent")
: baseUrl,
};
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
let existingTexts: string[] = [];
fetch(requestPayload.url, {
...requestPayload,
signal: timer.signal,
})
.then((response) => {
const reader = response?.body?.getReader();
const decoder = new TextDecoder();
let partialData = "";
return reader?.read().then(function processText({
done,
value,
}): Promise<any> {
if (done) {
if (response.status !== 200) {
try {
let data = JSON.parse(ensureProperEnding(partialData));
if (data && data[0].error) {
handlers.onError(new Error(data[0].error.message));
} else {
handlers.onError(new Error("Request failed"));
}
} catch (_) {
handlers.onError(new Error("Request failed"));
}
}
console.log("Stream complete");
return Promise.resolve();
}
partialData += decoder.decode(value, { stream: true });
try {
let data = JSON.parse(ensureProperEnding(partialData));
const textArray = data.reduce(
(acc: string[], item: { candidates: any[] }) => {
const texts = item.candidates.map((candidate) =>
candidate.content.parts
.map((part: { text: any }) => part.text)
.join(""),
);
return acc.concat(texts);
},
[],
);
if (textArray.length > existingTexts.length) {
const deltaArray = textArray.slice(existingTexts.length);
existingTexts = textArray;
handlers.onProgress(deltaArray.join(""));
}
} catch (error) {
// console.log("[Response Animation] error: ", error,partialData);
// skip error message when parsing json
}
return reader.read().then(processText);
});
})
.catch((error) => {
console.error("Error:", error);
});
return timer;
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
): Promise<StandChatReponseMessage> {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
async getAvailableModels(
providerConfig: Record<SettingKeys, string>,
): Promise<ModelInfo[]> {
const { googleApiKey, googleUrl } = providerConfig;
const res = await fetch(`${googleUrl}/v1beta/models?key=${googleApiKey}`, {
headers: {
Authorization: `Bearer ${googleApiKey}`,
},
method: "GET",
});
const data: ModelList = await res.json();
return data.models;
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, serverConfig) => {
const { googleUrl = GEMINI_BASE_URL } = serverConfig;
const controller = new AbortController();
const path = `${req.nextUrl.pathname}`.replaceAll(
this.apiRouteRootName,
"",
);
console.log("[Proxy] ", path);
console.log("[Base Url]", googleUrl);
const authResult = auth(req, serverConfig);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
const fetchUrl = `${googleUrl}/${path}?key=${authResult.apiKey}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
method: req.method,
body: req.body,
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
try {
const res = await fetch(fetchUrl, fetchOptions);
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
import {
modelConfigs,
settingItems,
SettingKeys,
NextChatMetas,
preferredRegion,
OPENAI_BASE_URL,
} from "./config";
import {
ChatHandlers,
getMessageTextContent,
InternalChatRequestPayload,
IProviderTemplate,
ServerConfig,
StandChatReponseMessage,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import { prettyObject } from "@/app/utils/format";
import Locale from "@/app/locales";
import { auth, authHeaderName, getHeaders, getTimer, parseResp } from "./utils";
import { NextRequest, NextResponse } from "next/server";
export type NextChatProviderSettingKeys = SettingKeys;
export const ROLES = ["system", "user", "assistant"] as const;
export type MessageRole = (typeof ROLES)[number];
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string | MultimodalContent[];
}
interface RequestPayload {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
}[];
stream?: boolean;
model: string;
temperature: number;
presence_penalty: number;
frequency_penalty: number;
top_p: number;
max_tokens?: number;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof NextChatMetas
>;
export default class NextChatProvider
implements IProviderTemplate<SettingKeys, "nextchat", typeof NextChatMetas>
{
apiRouteRootName: "/api/provider/nextchat" = "/api/provider/nextchat";
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["GET", "POST"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "nextchat" as const;
metas = NextChatMetas;
defaultModels = modelConfigs;
providerMeta = {
displayName: "NextChat",
settingItems,
};
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const { messages, isVisionModel, model, stream, modelConfig } = payload;
const {
temperature,
presence_penalty,
frequency_penalty,
top_p,
max_tokens,
} = modelConfig;
const openAiMessages = messages.map((v) => ({
role: v.role,
content: isVisionModel ? v.content : getMessageTextContent(v),
}));
const requestPayload: RequestPayload = {
messages: openAiMessages,
stream,
model,
temperature,
presence_penalty,
frequency_penalty,
top_p,
};
// add max_tokens to vision model
if (isVisionModel) {
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
return {
headers: getHeaders(payload.providerConfig.accessCode!),
body: JSON.stringify(requestPayload),
method: "POST",
url: [this.apiRouteRootName, NextChatMetas.ChatPath].join("/"),
};
}
private async requestOpenai(req: NextRequest, serverConfig: ServerConfig) {
const { baseUrl = OPENAI_BASE_URL, openaiOrgId } = serverConfig;
const controller = new AbortController();
const authValue = req.headers.get(authHeaderName) ?? "";
const path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
this.apiRouteRootName,
"",
);
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchUrl = `${baseUrl}/${path}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
...(openaiOrgId && {
"OpenAI-Organization": openaiOrgId,
}),
},
method: req.method,
body: req.body,
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
try {
const res = await fetch(fetchUrl, fetchOptions);
// Extract the OpenAI-Organization header from the response
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
// Check if serverConfig.openaiOrgId is defined and not an empty string
if (openaiOrgId && openaiOrgId.trim() !== "") {
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
console.log("[Org ID]", openaiOrganizationHeader);
} else {
console.log("[Org ID] is not set up.");
}
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
// Also, this is to prevent the header from being sent to the client
if (!openaiOrgId || openaiOrgId.trim() === "") {
newHeaders.delete("OpenAI-Organization");
}
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
// So if the streaming is disabled, we need to remove the content-encoding header
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
// The browser will try to decode the response with brotli and fail
newHeaders.delete("content-encoding");
return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
if (delta) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
async chat(
payload: InternalChatRequestPayload<"accessCode">,
fetch: typeof window.fetch,
): Promise<StandChatReponseMessage> {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = new Set(Object.values(NextChatMetas));
if (!ALLOWD_PATH.has(subpath)) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.requestOpenai(req, config);
return response;
} catch (e) {
return NextResponse.json(prettyObject(e));
}
};
}

View File

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

View File

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

View File

@@ -0,0 +1,381 @@
import {
ChatHandlers,
InternalChatRequestPayload,
IProviderTemplate,
ModelInfo,
getMessageTextContent,
ServerConfig,
} from "../../common";
import {
EventStreamContentType,
fetchEventSource,
} from "@fortaine/fetch-event-source";
import Locale from "@/app/locales";
import {
authHeaderName,
prettyObject,
parseResp,
auth,
getTimer,
getHeaders,
} from "./utils";
import {
modelConfigs,
settingItems,
SettingKeys,
OpenaiMetas,
ROLES,
OPENAI_BASE_URL,
preferredRegion,
} from "./config";
import { NextRequest, NextResponse } from "next/server";
import { ModelList } from "./type";
export type OpenAIProviderSettingKeys = SettingKeys;
export type MessageRole = (typeof ROLES)[number];
export interface MultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
export interface RequestMessage {
role: MessageRole;
content: string | MultimodalContent[];
}
interface RequestPayload {
messages: {
role: "system" | "user" | "assistant";
content: string | MultimodalContent[];
}[];
stream?: boolean;
model: string;
temperature: number;
presence_penalty: number;
frequency_penalty: number;
top_p: number;
max_tokens?: number;
}
type ProviderTemplate = IProviderTemplate<
SettingKeys,
"azure",
typeof OpenaiMetas
>;
class OpenAIProvider
implements IProviderTemplate<SettingKeys, "openai", typeof OpenaiMetas>
{
apiRouteRootName: "/api/provider/openai" = "/api/provider/openai";
allowedApiMethods: (
| "POST"
| "GET"
| "OPTIONS"
| "PUT"
| "PATCH"
| "DELETE"
)[] = ["GET", "POST"];
runtime = "edge" as const;
preferredRegion = preferredRegion;
name = "openai" as const;
metas = OpenaiMetas;
defaultModels = modelConfigs;
providerMeta = {
displayName: "OpenAI",
settingItems: settingItems(
`${this.apiRouteRootName}/${OpenaiMetas.ChatPath}`,
),
};
private formatChatPayload(payload: InternalChatRequestPayload<SettingKeys>) {
const {
messages,
isVisionModel,
model,
stream,
modelConfig: {
temperature,
presence_penalty,
frequency_penalty,
top_p,
max_tokens,
},
providerConfig: { openaiUrl },
} = payload;
const openAiMessages = messages.map((v) => ({
role: v.role,
content: isVisionModel ? v.content : getMessageTextContent(v),
}));
const requestPayload: RequestPayload = {
messages: openAiMessages,
stream,
model,
temperature,
presence_penalty,
frequency_penalty,
top_p,
};
// add max_tokens to vision model
if (isVisionModel) {
requestPayload["max_tokens"] = Math.max(max_tokens, 4000);
}
console.log("[Request] openai payload: ", requestPayload);
return {
headers: getHeaders(payload.providerConfig.openaiApiKey),
body: JSON.stringify(requestPayload),
method: "POST",
url: openaiUrl!,
};
}
private async requestOpenai(req: NextRequest, serverConfig: ServerConfig) {
const { baseUrl = OPENAI_BASE_URL, openaiOrgId } = serverConfig;
const controller = new AbortController();
const authValue = req.headers.get(authHeaderName) ?? "";
const path = `${req.nextUrl.pathname}${req.nextUrl.search}`.replaceAll(
this.apiRouteRootName,
"",
);
console.log("[Proxy] ", path);
console.log("[Base Url]", baseUrl);
const timeoutId = setTimeout(
() => {
controller.abort();
},
10 * 60 * 1000,
);
const fetchUrl = `${baseUrl}/${path}`;
const fetchOptions: RequestInit = {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
[authHeaderName]: authValue,
...(openaiOrgId && {
"OpenAI-Organization": openaiOrgId,
}),
},
method: req.method,
body: req.body,
// to fix #2485: https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
redirect: "manual",
// @ts-ignore
duplex: "half",
signal: controller.signal,
};
try {
const res = await fetch(fetchUrl, fetchOptions);
// Extract the OpenAI-Organization header from the response
const openaiOrganizationHeader = res.headers.get("OpenAI-Organization");
// Check if serverConfig.openaiOrgId is defined and not an empty string
if (openaiOrgId && openaiOrgId.trim() !== "") {
// If openaiOrganizationHeader is present, log it; otherwise, log that the header is not present
console.log("[Org ID]", openaiOrganizationHeader);
} else {
console.log("[Org ID] is not set up.");
}
// to prevent browser prompt for credentials
const newHeaders = new Headers(res.headers);
newHeaders.delete("www-authenticate");
// to disable nginx buffering
newHeaders.set("X-Accel-Buffering", "no");
// Conditionally delete the OpenAI-Organization header from the response if [Org ID] is undefined or empty (not setup in ENV)
// Also, this is to prevent the header from being sent to the client
if (!openaiOrgId || openaiOrgId.trim() === "") {
newHeaders.delete("OpenAI-Organization");
}
// The latest version of the OpenAI API forced the content-encoding to be "br" in json response
// So if the streaming is disabled, we need to remove the content-encoding header
// Because Vercel uses gzip to compress the response, if we don't remove the content-encoding header
// The browser will try to decode the response with brotli and fail
newHeaders.delete("content-encoding");
return new NextResponse(res.body, {
status: res.status,
statusText: res.statusText,
headers: newHeaders,
});
} finally {
clearTimeout(timeoutId);
}
}
async chat(
payload: InternalChatRequestPayload<SettingKeys>,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
const res = await fetch(requestPayload.url, {
headers: {
...requestPayload.headers,
},
body: requestPayload.body,
method: requestPayload.method,
signal: timer.signal,
});
timer.clear();
const resJson = await res.json();
const message = parseResp(resJson);
return message;
}
streamChat(
payload: InternalChatRequestPayload<SettingKeys>,
handlers: ChatHandlers,
fetch: typeof window.fetch,
) {
const requestPayload = this.formatChatPayload(payload);
const timer = getTimer();
fetchEventSource(requestPayload.url, {
...requestPayload,
fetch,
async onopen(res) {
timer.clear();
const contentType = res.headers.get("content-type");
console.log("[OpenAI] request response content type: ", contentType);
if (contentType?.startsWith("text/plain")) {
const responseText = await res.clone().text();
return handlers.onFlash(responseText);
}
if (
!res.ok ||
!res.headers
.get("content-type")
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
const responseTexts = [];
if (res.status === 401) {
responseTexts.push(Locale.Error.Unauthorized);
}
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch {}
if (extraInfo) {
responseTexts.push(extraInfo);
}
const responseText = responseTexts.join("\n\n");
return handlers.onFlash(responseText);
}
},
onmessage(msg) {
if (msg.data === "[DONE]") {
return;
}
const text = msg.data;
try {
const json = JSON.parse(text);
const choices = json.choices as Array<{
delta: { content: string };
}>;
const delta = choices[0]?.delta?.content;
if (delta) {
handlers.onProgress(delta);
}
} catch (e) {
console.error("[Request] parse error", text, msg);
}
},
onclose() {
handlers.onFinish();
},
onerror(e) {
handlers.onError(e);
throw e;
},
openWhenHidden: true,
});
return timer;
}
async getAvailableModels(
providerConfig: Record<SettingKeys, string>,
): Promise<ModelInfo[]> {
const { openaiApiKey, openaiUrl } = providerConfig;
const res = await fetch(`${openaiUrl}/v1/models`, {
headers: {
Authorization: `Bearer ${openaiApiKey}`,
},
method: "GET",
});
const data: ModelList = await res.json();
return data.data.map((o) => ({
name: o.id,
}));
}
serverSideRequestHandler: ProviderTemplate["serverSideRequestHandler"] =
async (req, config) => {
const { subpath } = req;
const ALLOWD_PATH = new Set(Object.values(OpenaiMetas));
if (!ALLOWD_PATH.has(subpath)) {
return NextResponse.json(
{
error: true,
message: "you are not allowed to request " + subpath,
},
{
status: 403,
},
);
}
const authResult = auth(req, config);
if (authResult.error) {
return NextResponse.json(authResult, {
status: 401,
});
}
try {
const response = await this.requestOpenai(req, config);
return response;
} catch (e) {
return NextResponse.json(prettyObject(e));
}
};
}
export default OpenAIProvider;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@
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);
} }
@@ -33,4 +35,18 @@
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;
}
} }

View File

@@ -59,9 +59,10 @@ 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";
@@ -1088,6 +1089,7 @@ function _Chat() {
if (payload.url) { if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!)); accessStore.update((access) => (access.openaiUrl = payload.url!));
} }
accessStore.update((access) => (access.useCustomConfig = true));
}); });
} }
} catch { } catch {

View File

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

View File

@@ -177,13 +177,14 @@ 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" className={`markdown-body ${props.className}`}
style={{ style={{
fontSize: `${props.fontSize ?? 14}px`, fontSize: `${props.fontSize ?? 14}px`,
}} }}

View File

@@ -4,6 +4,10 @@
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;

View File

@@ -1,5 +1,4 @@
import { IconButton } from "./button"; import { IconButton } from "./button";
import { ErrorBoundary } from "./error";
import styles from "./mask.module.scss"; import styles from "./mask.module.scss";
@@ -56,6 +55,7 @@ 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() { export function MaskPage(props: { className?: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const maskStore = useMaskStore(); const maskStore = useMaskStore();
@@ -466,8 +466,13 @@ export function MaskPage() {
}; };
return ( return (
<ErrorBoundary> <>
<div className={styles["mask-page"]}> <div
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">
@@ -645,6 +650,6 @@ export function MaskPage() {
</Modal> </Modal>
</div> </div>
)} )}
</ErrorBoundary> </>
); );
} }

View File

@@ -8,6 +8,10 @@
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;

View File

@@ -16,6 +16,7 @@ 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 (
@@ -71,7 +72,7 @@ function useMaskGroup(masks: Mask[]) {
return groups; return groups;
} }
export function NewChat() { export function NewChat(props: { className?: string }) {
const chatStore = useChatStore(); const chatStore = useChatStore();
const maskStore = useMaskStore(); const maskStore = useMaskStore();
@@ -110,8 +111,15 @@ export function NewChat() {
} }
}, [groups]); }, [groups]);
const isMobileScreen = useMobileScreen();
return ( return (
<div className={styles["new-chat"]}> <div
className={`
${styles["new-chat"]}
${props.className}
`}
>
<div className={styles["mask-header"]}> <div className={styles["mask-header"]}>
<IconButton <IconButton
icon={<LeftIcon />} icon={<LeftIcon />}

View File

@@ -101,6 +101,7 @@ 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(() => {
@@ -122,14 +123,14 @@ export function Modal(props: ModalProps) {
return ( return (
<div <div
className={ className={`${styles["modal-container"]} ${
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}` isMax && styles["modal-container-max"]
} } ${props.className ?? ""}`}
> >
<div className={styles["modal-header"]}> <div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
<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)}
@@ -147,11 +148,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"]}> <div className={`${styles["modal-footer"]} new-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"]}> <div key={i} className={`${styles["modal-action"]} new-btn`}>
{action} {action}
</div> </div>
))} ))}

View File

@@ -1,4 +1,5 @@
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") {
@@ -38,6 +39,7 @@ export const getBuildConfig = () => {
...commitInfo, ...commitInfo,
buildMode, buildMode,
isApp, isApp,
template: process.env.DEFAULT_INPUT_TEMPLATE ?? DEFAULT_INPUT_TEMPLATE,
}; };
}; };

View File

@@ -34,6 +34,9 @@ declare global {
// google tag manager // google tag manager
GTM_ID?: string; GTM_ID?: string;
// custom template for preprocessing user input
DEFAULT_INPUT_TEMPLATE?: string;
} }
} }
} }
@@ -51,6 +54,25 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
} }
})(); })();
function getApiKey(keys?: string) {
if (!keys) {
return;
}
const apiKeyEnvVar = keys;
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
const randomIndex = Math.floor(Math.random() * apiKeys.length);
const apiKey = apiKeys[randomIndex];
if (apiKey) {
console.log(
`[Server Config] using ${randomIndex + 1} of ${
apiKeys.length
} api key - ${apiKey}`,
);
}
return apiKey;
}
export const getServerSideConfig = () => { export const getServerSideConfig = () => {
if (typeof process === "undefined") { if (typeof process === "undefined") {
throw Error( throw Error(
@@ -74,34 +96,34 @@ export const getServerSideConfig = () => {
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 apiKeyEnvVar = process.env.OPENAI_API_KEY ?? ""; // const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim()); // const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
const randomIndex = Math.floor(Math.random() * apiKeys.length); // const randomIndex = Math.floor(Math.random() * apiKeys.length);
const apiKey = apiKeys[randomIndex]; // const apiKey = apiKeys[randomIndex];
console.log( // console.log(
`[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`, // `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
); // );
const whiteWebDevEndpoints = (process.env.WHITE_WEBDEV_ENDPOINTS ?? "").split( const allowedWebDevEndpoints = (
",", process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
); ).split(",");
return { return {
baseUrl: process.env.BASE_URL, baseUrl: process.env.BASE_URL,
apiKey, apiKey: getApiKey(process.env.OPENAI_API_KEY),
openaiOrgId: process.env.OPENAI_ORG_ID, openaiOrgId: process.env.OPENAI_ORG_ID,
isAzure, isAzure,
azureUrl: process.env.AZURE_URL, azureUrl: process.env.AZURE_URL,
azureApiKey: process.env.AZURE_API_KEY, azureApiKey: getApiKey(process.env.AZURE_API_KEY),
azureApiVersion: process.env.AZURE_API_VERSION, azureApiVersion: process.env.AZURE_API_VERSION,
isGoogle, isGoogle,
googleApiKey: process.env.GOOGLE_API_KEY, googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
googleUrl: process.env.GOOGLE_URL, googleUrl: process.env.GOOGLE_URL,
isAnthropic, isAnthropic,
anthropicApiKey: process.env.ANTHROPIC_API_KEY, anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION, anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
anthropicUrl: process.env.ANTHROPIC_URL, anthropicUrl: process.env.ANTHROPIC_URL,
@@ -120,6 +142,6 @@ export const getServerSideConfig = () => {
disableFastLink: !!process.env.DISABLE_FAST_LINK, disableFastLink: !!process.env.DISABLE_FAST_LINK,
customModels, customModels,
defaultModel, defaultModel,
whiteWebDevEndpoints, allowedWebDevEndpoints,
}; };
}; };

View File

@@ -47,13 +47,21 @@ export enum StoreKey {
Prompt = "prompt-store", Prompt = "prompt-store",
Update = "chat-update", Update = "chat-update",
Sync = "sync", Sync = "sync",
Provider = "provider",
} }
export const DEFAULT_SIDEBAR_WIDTH = 300;
export const MAX_SIDEBAR_WIDTH = 500;
export const MIN_SIDEBAR_WIDTH = 230;
export const NARROW_SIDEBAR_WIDTH = 100; export const NARROW_SIDEBAR_WIDTH = 100;
export const DEFAULT_SIDEBAR_WIDTH = 340;
export const MAX_SIDEBAR_WIDTH = 440;
export const MIN_SIDEBAR_WIDTH = 230;
export const WINDOW_WIDTH_SM = 480;
export const WINDOW_WIDTH_MD = 768;
export const WINDOW_WIDTH_LG = 1120;
export const WINDOW_WIDTH_XL = 1440;
export const WINDOW_WIDTH_2XL = 1980;
export const ACCESS_CODE_PREFIX = "nk-"; export const ACCESS_CODE_PREFIX = "nk-";
export const LAST_INPUT_KEY = "last-input"; export const LAST_INPUT_KEY = "last-input";
@@ -127,6 +135,8 @@ 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.
@@ -144,6 +154,8 @@ const openaiModels = [
"gpt-4-32k-0613", "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", "gpt-4-turbo-2024-04-09",
]; ];
@@ -151,6 +163,7 @@ const openaiModels = [
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",
]; ];
@@ -197,7 +210,7 @@ export const CHAT_PAGE_SIZE = 15;
export const MAX_RENDER_MSG_COUNT = 45; export const MAX_RENDER_MSG_COUNT = 45;
// some famous webdav endpoints // some famous webdav endpoints
export const internalWhiteWebDavEndpoints = [ export const internalAllowedWebDavEndpoints = [
"https://dav.jianguoyun.com/dav/", "https://dav.jianguoyun.com/dav/",
"https://dav.dropdav.com/", "https://dav.dropdav.com/",
"https://dav.box.com/dav", "https://dav.box.com/dav",
@@ -207,3 +220,5 @@ export const internalWhiteWebDavEndpoints = [
"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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,200 @@
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>
);
}

View File

@@ -0,0 +1,153 @@
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>
);
}

View File

@@ -0,0 +1,162 @@
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>
</>
);
}

View File

@@ -0,0 +1,39 @@
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>
);
}

View File

@@ -0,0 +1,220 @@
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>
</>
);
}

View File

@@ -0,0 +1,63 @@
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)} />
)}
</>
);
}

View File

@@ -0,0 +1,283 @@
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>
);
}

View File

@@ -0,0 +1,47 @@
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>
);
}

View File

@@ -0,0 +1,199 @@
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>
);
}

View File

@@ -0,0 +1,112 @@
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)} />
)}
</>
);
}

View File

@@ -0,0 +1,169 @@
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>
);
}

View File

@@ -0,0 +1,69 @@
.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;
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
"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);

View File

@@ -0,0 +1,124 @@
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>
);
}

146
app/containers/index.tsx Normal file
View File

@@ -0,0 +1,146 @@
"use client";
require("../polyfill");
import { HashRouter as Router, Routes, Route } from "react-router-dom";
import { useState, useEffect, useLayoutEffect } from "react";
import dynamic from "next/dynamic";
import { Path } from "@/app/constant";
import { ErrorBoundary } from "@/app/components/error";
import { getISOLang } from "@/app/locales";
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
import { AuthPage } from "@/app/components/auth";
import { getClientConfig } from "@/app/config/client";
import { useAccessStore, useAppConfig } from "@/app/store";
import { useLoadData } from "@/app/hooks/useLoadData";
import Loading from "@/app/components/Loading";
import Screen from "@/app/components/Screen";
import { SideBar } from "./Sidebar";
import GlobalLoading from "@/app/components/GlobalLoading";
import { MOBILE_MAX_WIDTH } from "../hooks/useListenWinResize";
const Settings = dynamic(
async () => await import("@/app/containers/Settings"),
{
loading: () => <Loading noLogo />,
},
);
const Chat = dynamic(async () => await import("@/app/containers/Chat"), {
loading: () => <Loading noLogo />,
});
const NewChat = dynamic(
async () => (await import("@/app/components/new-chat")).NewChat,
{
loading: () => <Loading noLogo />,
},
);
const MaskPage = dynamic(
async () => (await import("@/app/components/mask")).MaskPage,
{
loading: () => <Loading noLogo />,
},
);
function useHtmlLang() {
useEffect(() => {
const lang = getISOLang();
const htmlLang = document.documentElement.lang;
if (lang !== htmlLang) {
document.documentElement.lang = lang;
}
}, []);
}
const useHasHydrated = () => {
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
useEffect(() => {
setHasHydrated(true);
}, []);
return hasHydrated;
};
const loadAsyncGoogleFont = () => {
const linkEl = document.createElement("link");
const proxyFontUrl = "/google-fonts";
const remoteFontUrl = "https://fonts.googleapis.com";
const googleFontUrl =
getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
linkEl.rel = "stylesheet";
linkEl.href =
googleFontUrl +
"/css2?family=" +
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
"&display=swap";
document.head.appendChild(linkEl);
};
export default function Home() {
useSwitchTheme();
useLoadData();
useHtmlLang();
const config = useAppConfig();
useEffect(() => {
console.log("[Config] got config from build time", getClientConfig());
useAccessStore.getState().fetch();
}, []);
useLayoutEffect(() => {
loadAsyncGoogleFont();
config.update(
(config) =>
(config.isMobileScreen = window.innerWidth <= MOBILE_MAX_WIDTH),
);
}, []);
if (!useHasHydrated()) {
return <GlobalLoading />;
}
return (
<ErrorBoundary>
<Router>
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
<ErrorBoundary>
<Routes>
<Route path={Path.Home} element={<Chat />} />
<Route
path={Path.NewChat}
element={
<NewChat
className={`
md:w-[100%] px-1
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
`}
/>
}
/>
<Route
path={Path.Masks}
element={
<MaskPage
className={`
md:w-[100%]
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
`}
/>
}
/>
<Route path={Path.Chat} element={<Chat />} />
<Route path={Path.Settings} element={<Settings />} />
</Routes>
</ErrorBoundary>
</Screen>
</Router>
</ErrorBoundary>
);
}

Binary file not shown.

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