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",
"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.
### `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
- Each address must be a complete endpoint
> `https://xxxx/yyy`
- Multiple addresses are connected by ', '
### `DEFAULT_INPUT_TEMPLATE` (optional)
Customize the default template used to initialize the User Input Preprocessing configuration item in Settings.
## Requirements
NodeJS >= 18, Docker >= 20

View File

@@ -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 { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant";
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
import { getServerSideConfig } from "@/app/config/server";
const config = getServerSideConfig();
const mergedWhiteWebDavEndpoints = [
...internalWhiteWebDavEndpoints,
...config.whiteWebDevEndpoints,
const mergedAllowedWebDavEndpoints = [
...internalAllowedWebDavEndpoints,
...config.allowedWebDevEndpoints,
].filter((domain) => Boolean(domain.trim()));
async function handle(
@@ -24,7 +24,9 @@ async function handle(
// Validate the endpoint to prevent potential SSRF attacks
if (
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white))
!mergedAllowedWebDavEndpoints.some(
(allowedEndpoint) => endpoint?.startsWith(allowedEndpoint),
)
) {
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 = {
messages: prompt,
stream: shouldStream,

View File

@@ -120,7 +120,9 @@ export class GeminiProApi implements LLMApi {
if (!baseUrl) {
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));
}

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%;
flex-direction: column;
background-color: var(--white);
.auth-logo {
transform: scale(1.4);
}
@@ -33,4 +35,18 @@
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,
getMessageImages,
isVisionModel,
compressImage,
} from "../utils";
import { compressImage } from "@/app/utils/chat";
import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
@@ -1088,6 +1089,7 @@ function _Chat() {
if (payload.url) {
accessStore.update((access) => (access.openaiUrl = payload.url!));
}
accessStore.update((access) => (access.useCustomConfig = true));
});
}
} catch {

View File

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

View File

@@ -177,13 +177,14 @@ export function Markdown(
fontSize?: number;
parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean;
className?: string;
} & React.DOMAttributes<HTMLDivElement>,
) {
const mdRef = useRef<HTMLDivElement>(null);
return (
<div
className="markdown-body"
className={`markdown-body ${props.className}`}
style={{
fontSize: `${props.fontSize ?? 14}px`,
}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,13 +47,21 @@ export enum StoreKey {
Prompt = "prompt-store",
Update = "chat-update",
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 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 LAST_INPUT_KEY = "last-input";
@@ -127,6 +135,8 @@ export const KnowledgeCutOffDate: Record<string, string> = {
"gpt-4-turbo": "2023-12",
"gpt-4-turbo-2024-04-09": "2023-12",
"gpt-4-turbo-preview": "2023-12",
"gpt-4o": "2023-10",
"gpt-4o-2024-05-13": "2023-10",
"gpt-4-vision-preview": "2023-04",
// After improvements,
// 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-turbo",
"gpt-4-turbo-preview",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4-vision-preview",
"gpt-4-turbo-2024-04-09",
];
@@ -151,6 +163,7 @@ const openaiModels = [
const googleModels = [
"gemini-1.0-pro",
"gemini-1.5-pro-latest",
"gemini-1.5-flash-latest",
"gemini-pro-vision",
];
@@ -197,7 +210,7 @@ export const CHAT_PAGE_SIZE = 15;
export const MAX_RENDER_MSG_COUNT = 45;
// some famous webdav endpoints
export const internalWhiteWebDavEndpoints = [
export const internalAllowedWebDavEndpoints = [
"https://dav.jianguoyun.com/dav/",
"https://dav.dropdav.com/",
"https://dav.box.com/dav",
@@ -207,3 +220,5 @@ export const internalWhiteWebDavEndpoints = [
"https://webdav.yandex.com",
"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