mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 16:23:41 +08:00 
			
		
		
		
	Compare commits
	
		
			40 Commits
		
	
	
		
			1042ea5a85
			...
			feat-redes
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					3fcf0513d2 | ||
| 
						 | 
					8de8acdce8 | ||
| 
						 | 
					77e321c7cb | ||
| 
						 | 
					8093d1ffba | ||
| 
						 | 
					74a6e1260e | ||
| 
						 | 
					a0e4a468d6 | ||
| 
						 | 
					00b1a9781d | ||
| 
						 | 
					240d330001 | ||
| 
						 | 
					4e4431339f | ||
| 
						 | 
					fa2f8c66d1 | ||
| 
						 | 
					32f62d70af | ||
| 
						 | 
					68f0fa917f | ||
| 
						 | 
					8a14cb19a9 | ||
| 
						 | 
					3d99965a8f | ||
| 
						 | 
					4d5a9476b6 | ||
| 
						 | 
					15d6ed252f | ||
| 
						 | 
					ecf6cc27d6 | ||
| 
						 | 
					cadd2558fd | ||
| 
						 | 
					c3d91bf0cd | ||
| 
						 | 
					996537d262 | ||
| 
						 | 
					5ea6206319 | ||
| 
						 | 
					8c28c408d8 | ||
| 
						 | 
					c34b8ab919 | ||
| 
						 | 
					9f4813326c | ||
| 
						 | 
					9569888b0e | ||
| 
						 | 
					1a636b0f50 | ||
| 
						 | 
					48e8c0a194 | ||
| 
						 | 
					59583e53bd | ||
| 
						 | 
					bb7422c526 | ||
| 
						 | 
					c99086447e | ||
| 
						 | 
					f7074bba8c | ||
| 
						 | 
					4400392c0c | ||
| 
						 | 
					4a5465f884 | ||
| 
						 | 
					37cc87531c | ||
| 
						 | 
					1074fffe79 | ||
| 
						 | 
					3d0a98d5d2 | ||
| 
						 | 
					b3559f99a2 | ||
| 
						 | 
					51a1d9f92a | ||
| 
						 | 
					3fc9b91bf1 | ||
| 
						 | 
					0a8e5d6734 | 
@@ -1,4 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
  "extends": "next/core-web-vitals",
 | 
			
		||||
  "plugins": ["prettier"]
 | 
			
		||||
  "plugins": [
 | 
			
		||||
    "prettier"
 | 
			
		||||
  ],
 | 
			
		||||
  "parserOptions": {
 | 
			
		||||
    "ecmaFeatures": {
 | 
			
		||||
      "legacyDecorators": true
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "ignorePatterns": ["globals.css"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										93
									
								
								app/api/provider/[...path]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								app/api/provider/[...path]/route.ts
									
									
									
									
									
										Normal 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[],
 | 
			
		||||
    ),
 | 
			
		||||
  ),
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										7
									
								
								app/client/common/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/client/common/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
export * from "./types";
 | 
			
		||||
 | 
			
		||||
export * from "./locale";
 | 
			
		||||
 | 
			
		||||
export * from "./utils";
 | 
			
		||||
 | 
			
		||||
export const modelNameRequestHeader = "x-nextchat-model-name";
 | 
			
		||||
							
								
								
									
										19
									
								
								app/client/common/locale.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/client/common/locale.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										211
									
								
								app/client/common/types.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								app/client/common/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								app/client/common/utils.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										9
									
								
								app/client/core/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
export * from "./shim";
 | 
			
		||||
 | 
			
		||||
export * from "../common/types";
 | 
			
		||||
 | 
			
		||||
export * from "./providerClient";
 | 
			
		||||
 | 
			
		||||
export * from "./modelClient";
 | 
			
		||||
 | 
			
		||||
export * from "../common/locale";
 | 
			
		||||
							
								
								
									
										98
									
								
								app/client/core/modelClient.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								app/client/core/modelClient.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										256
									
								
								app/client/core/providerClient.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								app/client/core/providerClient.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										25
									
								
								app/client/core/shim.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										3
									
								
								app/client/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
export * from "./core";
 | 
			
		||||
 | 
			
		||||
export * from "./providers";
 | 
			
		||||
@@ -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));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										131
									
								
								app/client/providers/anthropic/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								app/client/providers/anthropic/config.ts
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										356
									
								
								app/client/providers/anthropic/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								app/client/providers/anthropic/index.ts
									
									
									
									
									
										Normal 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));
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										134
									
								
								app/client/providers/anthropic/locale.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								app/client/providers/anthropic/locale.ts
									
									
									
									
									
										Normal 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",
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										151
									
								
								app/client/providers/anthropic/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								app/client/providers/anthropic/utils.ts
									
									
									
									
									
										Normal 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,
 | 
			
		||||
              },
 | 
			
		||||
            };
 | 
			
		||||
          }),
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										79
									
								
								app/client/providers/azure/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								app/client/providers/azure/config.ts
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										408
									
								
								app/client/providers/azure/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										408
									
								
								app/client/providers/azure/index.ts
									
									
									
									
									
										Normal 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));
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										133
									
								
								app/client/providers/azure/locale.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								app/client/providers/azure/locale.ts
									
									
									
									
									
										Normal 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",
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										110
									
								
								app/client/providers/azure/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								app/client/providers/azure/utils.ts
									
									
									
									
									
										Normal 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,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										95
									
								
								app/client/providers/google/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								app/client/providers/google/config.ts
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										353
									
								
								app/client/providers/google/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								app/client/providers/google/index.ts
									
									
									
									
									
										Normal 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);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										113
									
								
								app/client/providers/google/locale.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								app/client/providers/google/locale.ts
									
									
									
									
									
										Normal 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",
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										87
									
								
								app/client/providers/google/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								app/client/providers/google/utils.ts
									
									
									
									
									
										Normal 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 ||
 | 
			
		||||
      "",
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								app/client/providers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/client/providers/index.ts
									
									
									
									
									
										Normal 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";
 | 
			
		||||
							
								
								
									
										89
									
								
								app/client/providers/nextchat/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								app/client/providers/nextchat/config.ts
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										348
									
								
								app/client/providers/nextchat/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										348
									
								
								app/client/providers/nextchat/index.ts
									
									
									
									
									
										Normal 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));
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										112
									
								
								app/client/providers/nextchat/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								app/client/providers/nextchat/utils.ts
									
									
									
									
									
										Normal 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`,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										214
									
								
								app/client/providers/openai/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										214
									
								
								app/client/providers/openai/config.ts
									
									
									
									
									
										Normal 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"],
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										381
									
								
								app/client/providers/openai/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								app/client/providers/openai/index.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										100
									
								
								app/client/providers/openai/locale.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								app/client/providers/openai/locale.ts
									
									
									
									
									
										Normal 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",
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										18
									
								
								app/client/providers/openai/type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/client/providers/openai/type.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
  }>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										103
									
								
								app/client/providers/openai/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								app/client/providers/openai/utils.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										123
									
								
								app/components/ActionsBar/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								app/components/ActionsBar/index.tsx
									
									
									
									
									
										Normal 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>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										78
									
								
								app/components/Btn/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								app/components/Btn/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								app/components/Card/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/components/Card/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								app/components/GlobalLoading/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/components/GlobalLoading/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								app/components/HoverPopover/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/components/HoverPopover/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								app/components/Imgs/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/components/Imgs/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								app/components/Input/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								app/components/Input/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										167
									
								
								app/components/List/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								app/components/List/index.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										35
									
								
								app/components/Loading/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/components/Loading/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										115
									
								
								app/components/MenuLayout/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								app/components/MenuLayout/index.tsx
									
									
									
									
									
										Normal 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]">
 | 
			
		||||
               
 | 
			
		||||
            </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>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										352
									
								
								app/components/Modal/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								app/components/Modal/index.tsx
									
									
									
									
									
										Normal 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();
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
             
 | 
			
		||||
          </div>
 | 
			
		||||
          <div
 | 
			
		||||
            className={`flex flex-col flex-0      
 | 
			
		||||
              bg-moda-panel text-modal-panel    
 | 
			
		||||
              ${modelClassName}
 | 
			
		||||
              ${panelClassName}
 | 
			
		||||
            `}
 | 
			
		||||
          >
 | 
			
		||||
            {!noHeader && (
 | 
			
		||||
              <AlertDialog.Title
 | 
			
		||||
                className={`
 | 
			
		||||
                      flex items-center justify-between gap-3 font-common
 | 
			
		||||
                      md:text-chat-header-title md:font-bold md:leading-5 
 | 
			
		||||
                      ${
 | 
			
		||||
                        headerBordered
 | 
			
		||||
                          ? " border-b border-modal-header-bottom"
 | 
			
		||||
                          : ""
 | 
			
		||||
                      }
 | 
			
		||||
                      ${titleClassName}
 | 
			
		||||
                  `}
 | 
			
		||||
              >
 | 
			
		||||
                <div className="flex gap-3 justify-start flex-1 items-center text-text-modal-title text-chat-header-title">
 | 
			
		||||
                  {title}
 | 
			
		||||
                </div>
 | 
			
		||||
                {closeble && (
 | 
			
		||||
                  <div
 | 
			
		||||
                    className="items-center"
 | 
			
		||||
                    onClick={() => {
 | 
			
		||||
                      handleClose();
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Close />
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </AlertDialog.Title>
 | 
			
		||||
            )}
 | 
			
		||||
            <div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
 | 
			
		||||
              {typeof content === "function"
 | 
			
		||||
                ? content({
 | 
			
		||||
                    close: () => {
 | 
			
		||||
                      handleClose();
 | 
			
		||||
                    },
 | 
			
		||||
                  })
 | 
			
		||||
                : content}
 | 
			
		||||
            </div>
 | 
			
		||||
            {!noFooter && (
 | 
			
		||||
              <div
 | 
			
		||||
                className={`
 | 
			
		||||
                  flex gap-3 sm:justify-end max-sm:justify-between
 | 
			
		||||
                  ${footerClassName}
 | 
			
		||||
                  `}
 | 
			
		||||
              >
 | 
			
		||||
                <AlertDialog.Cancel asChild>
 | 
			
		||||
                  <Btn
 | 
			
		||||
                    {...cancelBtnProps}
 | 
			
		||||
                    onClick={() => handleClose()}
 | 
			
		||||
                    text={cancelText}
 | 
			
		||||
                    className={`${btnCommonClass} ${cancelBtnClass}`}
 | 
			
		||||
                  />
 | 
			
		||||
                </AlertDialog.Cancel>
 | 
			
		||||
                <AlertDialog.Action asChild>
 | 
			
		||||
                  <Btn
 | 
			
		||||
                    {...okBtnProps}
 | 
			
		||||
                    onClick={handleOk}
 | 
			
		||||
                    text={okText}
 | 
			
		||||
                    className={`${btnCommonClass} ${okBtnClass}`}
 | 
			
		||||
                  />
 | 
			
		||||
                </AlertDialog.Action>
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
          {type === "modal" && (
 | 
			
		||||
            <div
 | 
			
		||||
              className="flex-1"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                if (maskCloseble) {
 | 
			
		||||
                  handleClose();
 | 
			
		||||
                }
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
               
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </AlertDialog.Content>
 | 
			
		||||
      </AlertDialog.Portal>
 | 
			
		||||
    </AlertDialog.Root>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Warn = ({
 | 
			
		||||
  title,
 | 
			
		||||
  onOk,
 | 
			
		||||
  visible,
 | 
			
		||||
  content,
 | 
			
		||||
  ...props
 | 
			
		||||
}: WarnProps) => {
 | 
			
		||||
  const [internalVisible, setVisible] = useState(visible);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      {...props}
 | 
			
		||||
      title={
 | 
			
		||||
        <>
 | 
			
		||||
          <Warning />
 | 
			
		||||
          {title}
 | 
			
		||||
        </>
 | 
			
		||||
      }
 | 
			
		||||
      content={
 | 
			
		||||
        <AlertDialog.Description
 | 
			
		||||
          className={`
 | 
			
		||||
                    font-common font-normal
 | 
			
		||||
                    md:text-sm-title md:leading-[158%]
 | 
			
		||||
                `}
 | 
			
		||||
        >
 | 
			
		||||
          {content}
 | 
			
		||||
        </AlertDialog.Description>
 | 
			
		||||
      }
 | 
			
		||||
      closeble={false}
 | 
			
		||||
      onOk={() => {
 | 
			
		||||
        const toDo = onOk?.();
 | 
			
		||||
        if (toDo instanceof Promise) {
 | 
			
		||||
          toDo.then(() => {
 | 
			
		||||
            setVisible(false);
 | 
			
		||||
          });
 | 
			
		||||
        } else {
 | 
			
		||||
          setVisible(false);
 | 
			
		||||
        }
 | 
			
		||||
      }}
 | 
			
		||||
      visible={internalVisible}
 | 
			
		||||
      okBtnProps={{
 | 
			
		||||
        className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
 | 
			
		||||
      }}
 | 
			
		||||
      cancelBtnProps={{
 | 
			
		||||
        className: `bg-delete-chat-cancel-btn  border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const div = document.createElement("div");
 | 
			
		||||
div.id = "confirm-root";
 | 
			
		||||
div.style.height = "0px";
 | 
			
		||||
document.body.appendChild(div);
 | 
			
		||||
 | 
			
		||||
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
 | 
			
		||||
  const root = createRoot(div);
 | 
			
		||||
  const closeModal = () => {
 | 
			
		||||
    root.unmount();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return new Promise<boolean>((resolve) => {
 | 
			
		||||
    root.render(
 | 
			
		||||
      <Warn
 | 
			
		||||
        {...props}
 | 
			
		||||
        visible={true}
 | 
			
		||||
        onCancel={() => {
 | 
			
		||||
          closeModal();
 | 
			
		||||
          resolve(false);
 | 
			
		||||
        }}
 | 
			
		||||
        onOk={() => {
 | 
			
		||||
          closeModal();
 | 
			
		||||
          resolve(true);
 | 
			
		||||
        }}
 | 
			
		||||
      />,
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Trigger = (props: TriggerProps) => {
 | 
			
		||||
  const { children, className, content, ...rest } = props;
 | 
			
		||||
 | 
			
		||||
  const [internalVisible, setVisible] = useState(false);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div
 | 
			
		||||
        className={className}
 | 
			
		||||
        onClick={() => {
 | 
			
		||||
          setVisible(true);
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
      </div>
 | 
			
		||||
      <Modal
 | 
			
		||||
        {...rest}
 | 
			
		||||
        visible={internalVisible}
 | 
			
		||||
        onCancel={() => {
 | 
			
		||||
          setVisible(false);
 | 
			
		||||
        }}
 | 
			
		||||
        content={
 | 
			
		||||
          typeof content === "function"
 | 
			
		||||
            ? content({
 | 
			
		||||
                close: () => {
 | 
			
		||||
                  setVisible(false);
 | 
			
		||||
                },
 | 
			
		||||
              })
 | 
			
		||||
            : content
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
Modal.Trigger = Trigger;
 | 
			
		||||
 | 
			
		||||
export default Modal;
 | 
			
		||||
							
								
								
									
										352
									
								
								app/components/Popover/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								app/components/Popover/index.tsx
									
									
									
									
									
										Normal 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();
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                 
 | 
			
		||||
              </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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										71
									
								
								app/components/Screen/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								app/components/Screen/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								app/components/Search/index.module.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								app/components/Search/index.module.scss
									
									
									
									
									
										Normal 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;
 | 
			
		||||
    } 
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								app/components/Search/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/components/Search/index.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										118
									
								
								app/components/Select/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								app/components/Select/index.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										99
									
								
								app/components/SlideRange/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								app/components/SlideRange/index.tsx
									
									
									
									
									
										Normal 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">
 | 
			
		||||
           
 | 
			
		||||
        </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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								app/components/Switch/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/components/Switch/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								app/components/ThumbnailImg/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/components/ThumbnailImg/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,9 @@
 | 
			
		||||
  &-body {
 | 
			
		||||
    margin-top: 20px;
 | 
			
		||||
  }
 | 
			
		||||
  div:not(.no-dark) > svg {
 | 
			
		||||
    filter: invert(0.5);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.export-content {
 | 
			
		||||
 
 | 
			
		||||
@@ -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`,
 | 
			
		||||
      }}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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 />}
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
          ))}
 | 
			
		||||
 
 | 
			
		||||
@@ -55,7 +55,10 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
function getApiKey(keys?: string) {
 | 
			
		||||
  const apiKeyEnvVar = keys ?? "";
 | 
			
		||||
  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];
 | 
			
		||||
 
 | 
			
		||||
@@ -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";
 | 
			
		||||
@@ -149,7 +157,7 @@ const openaiModels = [
 | 
			
		||||
  "gpt-4o",
 | 
			
		||||
  "gpt-4o-2024-05-13",
 | 
			
		||||
  "gpt-4-vision-preview",
 | 
			
		||||
  "gpt-4-turbo-2024-04-09"
 | 
			
		||||
  "gpt-4-turbo-2024-04-09",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const googleModels = [
 | 
			
		||||
@@ -212,3 +220,5 @@ export const internalAllowedWebDavEndpoints = [
 | 
			
		||||
  "https://webdav.yandex.com",
 | 
			
		||||
  "https://app.koofr.net/dav/Koofr",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const SIDEBAR_ID = "sidebar";
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										300
									
								
								app/containers/Chat/ChatPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										300
									
								
								app/containers/Chat/ChatPanel.tsx
									
									
									
									
									
										Normal 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>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										277
									
								
								app/containers/Chat/components/ChatActions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								app/containers/Chat/components/ChatActions.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								app/containers/Chat/components/ChatHeader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								app/containers/Chat/components/ChatHeader.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										322
									
								
								app/containers/Chat/components/ChatInputPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								app/containers/Chat/components/ChatInputPanel.tsx
									
									
									
									
									
										Normal 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"> </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>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										246
									
								
								app/containers/Chat/components/ChatMessagePanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								app/containers/Chat/components/ChatMessagePanel.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								app/containers/Chat/components/ClearContextDivider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								app/containers/Chat/components/ClearContextDivider.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										75
									
								
								app/containers/Chat/components/EditMessageModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								app/containers/Chat/components/EditMessageModal.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										295
									
								
								app/containers/Chat/components/MessageActions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										295
									
								
								app/containers/Chat/components/MessageActions.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										159
									
								
								app/containers/Chat/components/ModelSelect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								app/containers/Chat/components/ModelSelect.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
							
								
								
									
										96
									
								
								app/containers/Chat/components/PromptHint.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								app/containers/Chat/components/PromptHint.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								app/containers/Chat/components/PromptToast.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/containers/Chat/components/PromptToast.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										77
									
								
								app/containers/Chat/components/SessionConfigModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								app/containers/Chat/components/SessionConfigModal.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										184
									
								
								app/containers/Chat/components/SessionItem.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								app/containers/Chat/components/SessionItem.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										609
									
								
								app/containers/Chat/index.module.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										609
									
								
								app/containers/Chat/index.module.scss
									
									
									
									
									
										Normal 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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										146
									
								
								app/containers/Chat/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								app/containers/Chat/index.tsx
									
									
									
									
									
										Normal 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);
 | 
			
		||||
							
								
								
									
										137
									
								
								app/containers/Settings/SettingPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								app/containers/Settings/SettingPanel.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										200
									
								
								app/containers/Settings/components/AppSetting.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								app/containers/Settings/components/AppSetting.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										153
									
								
								app/containers/Settings/components/DangerItems.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								app/containers/Settings/components/DangerItems.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										162
									
								
								app/containers/Settings/components/MaskConfig.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								app/containers/Settings/components/MaskConfig.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										39
									
								
								app/containers/Settings/components/MaskSetting.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/containers/Settings/components/MaskSetting.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										220
									
								
								app/containers/Settings/components/ModelSetting.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								app/containers/Settings/components/ModelSetting.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								app/containers/Settings/components/PromptSetting.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								app/containers/Settings/components/PromptSetting.tsx
									
									
									
									
									
										Normal 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)} />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										283
									
								
								app/containers/Settings/components/ProviderSetting.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										283
									
								
								app/containers/Settings/components/ProviderSetting.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								app/containers/Settings/components/SettingHeader.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/containers/Settings/components/SettingHeader.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										199
									
								
								app/containers/Settings/components/SyncConfigModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								app/containers/Settings/components/SyncConfigModal.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										112
									
								
								app/containers/Settings/components/SyncItems.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								app/containers/Settings/components/SyncItems.tsx
									
									
									
									
									
										Normal 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)} />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										169
									
								
								app/containers/Settings/components/UserPromptModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								app/containers/Settings/components/UserPromptModal.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								app/containers/Settings/index.module.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								app/containers/Settings/index.module.scss
									
									
									
									
									
										Normal 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;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										98
									
								
								app/containers/Settings/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								app/containers/Settings/index.tsx
									
									
									
									
									
										Normal 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);
 | 
			
		||||
							
								
								
									
										124
									
								
								app/containers/Sidebar/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								app/containers/Sidebar/index.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										146
									
								
								app/containers/index.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								app/fonts/Satoshi-Variable.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/fonts/Satoshi-Variable.ttf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								app/fonts/Satoshi-Variable.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/fonts/Satoshi-Variable.woff
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								app/fonts/Satoshi-Variable.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/fonts/Satoshi-Variable.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										5
									
								
								app/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								app/global.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -21,10 +21,13 @@ declare interface Window {
 | 
			
		||||
      writeBinaryFile(path: string, data: Uint8Array): Promise<void>;
 | 
			
		||||
      writeTextFile(path: string, data: string): Promise<void>;
 | 
			
		||||
    };
 | 
			
		||||
    notification:{
 | 
			
		||||
    notification: {
 | 
			
		||||
      requestPermission(): Promise<Permission>;
 | 
			
		||||
      isPermissionGranted(): Promise<boolean>;
 | 
			
		||||
      sendNotification(options: string | Options): void;
 | 
			
		||||
    };
 | 
			
		||||
    http: {
 | 
			
		||||
      fetch: typeof window.fetch;
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										59
									
								
								app/hooks/useDrag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								app/hooks/useDrag.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
			
		||||
import { RefObject, useRef } from "react";
 | 
			
		||||
 | 
			
		||||
export default function useDrag(options: {
 | 
			
		||||
  customDragMove: (nextWidth: number, start?: number) => void;
 | 
			
		||||
  customToggle: () => void;
 | 
			
		||||
  customLimit?: (x: number, start?: number) => number;
 | 
			
		||||
  customDragEnd?: (nextWidth: number, start?: number) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const { customDragMove, customToggle, customLimit, customDragEnd } =
 | 
			
		||||
    options || {};
 | 
			
		||||
  const limit = customLimit;
 | 
			
		||||
 | 
			
		||||
  const startX = useRef(0);
 | 
			
		||||
  const lastUpdateTime = useRef(Date.now());
 | 
			
		||||
 | 
			
		||||
  const toggleSideBar = customToggle;
 | 
			
		||||
 | 
			
		||||
  const onDragMove = customDragMove;
 | 
			
		||||
 | 
			
		||||
  const onDragStart = (e: MouseEvent) => {
 | 
			
		||||
    // Remembers the initial width each time the mouse is pressed
 | 
			
		||||
    startX.current = e.clientX;
 | 
			
		||||
    const dragStartTime = Date.now();
 | 
			
		||||
 | 
			
		||||
    const handleDragMove = (e: MouseEvent) => {
 | 
			
		||||
      if (Date.now() < lastUpdateTime.current + 20) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      lastUpdateTime.current = Date.now();
 | 
			
		||||
      const d = e.clientX - startX.current;
 | 
			
		||||
      const nextWidth = limit?.(d, startX.current) ?? d;
 | 
			
		||||
 | 
			
		||||
      onDragMove(nextWidth, startX.current);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleDragEnd = (e: MouseEvent) => {
 | 
			
		||||
      // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
 | 
			
		||||
      window.removeEventListener("pointermove", handleDragMove);
 | 
			
		||||
      window.removeEventListener("pointerup", handleDragEnd);
 | 
			
		||||
 | 
			
		||||
      // if user click the drag icon, should toggle the sidebar
 | 
			
		||||
      const shouldFireClick = Date.now() - dragStartTime < 300;
 | 
			
		||||
      if (shouldFireClick) {
 | 
			
		||||
        toggleSideBar();
 | 
			
		||||
      } else {
 | 
			
		||||
        const d = e.clientX - startX.current;
 | 
			
		||||
        const nextWidth = limit?.(d, startX.current) ?? d;
 | 
			
		||||
        customDragEnd?.(nextWidth, startX.current);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.addEventListener("pointermove", handleDragMove);
 | 
			
		||||
    window.addEventListener("pointerup", handleDragEnd);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    onDragStart,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								app/hooks/useHotKey.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/hooks/useHotKey.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { useChatStore } from "../store/chat";
 | 
			
		||||
 | 
			
		||||
export default function useHotKey() {
 | 
			
		||||
  const chatStore = useChatStore();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const onKeyDown = (e: KeyboardEvent) => {
 | 
			
		||||
      if (e.altKey || e.ctrlKey) {
 | 
			
		||||
        if (e.key === "ArrowUp") {
 | 
			
		||||
          chatStore.nextSession(-1);
 | 
			
		||||
        } else if (e.key === "ArrowDown") {
 | 
			
		||||
          chatStore.nextSession(1);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.addEventListener("keydown", onKeyDown);
 | 
			
		||||
    return () => window.removeEventListener("keydown", onKeyDown);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								app/hooks/useListenWinResize.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								app/hooks/useListenWinResize.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
import { useWindowSize } from "@/app/hooks/useWindowSize";
 | 
			
		||||
import {
 | 
			
		||||
  WINDOW_WIDTH_2XL,
 | 
			
		||||
  WINDOW_WIDTH_LG,
 | 
			
		||||
  WINDOW_WIDTH_MD,
 | 
			
		||||
  WINDOW_WIDTH_SM,
 | 
			
		||||
  WINDOW_WIDTH_XL,
 | 
			
		||||
  DEFAULT_SIDEBAR_WIDTH,
 | 
			
		||||
  MAX_SIDEBAR_WIDTH,
 | 
			
		||||
  MIN_SIDEBAR_WIDTH,
 | 
			
		||||
} from "@/app/constant";
 | 
			
		||||
import { useAppConfig } from "@/app/store/config";
 | 
			
		||||
import { updateGlobalCSSVars } from "@/app/utils/client";
 | 
			
		||||
 | 
			
		||||
export const MOBILE_MAX_WIDTH = 768;
 | 
			
		||||
 | 
			
		||||
const widths = [
 | 
			
		||||
  WINDOW_WIDTH_2XL,
 | 
			
		||||
  WINDOW_WIDTH_XL,
 | 
			
		||||
  WINDOW_WIDTH_LG,
 | 
			
		||||
  WINDOW_WIDTH_MD,
 | 
			
		||||
  WINDOW_WIDTH_SM,
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export default function useListenWinResize() {
 | 
			
		||||
  const config = useAppConfig();
 | 
			
		||||
 | 
			
		||||
  useWindowSize((size) => {
 | 
			
		||||
    let nextSidebar = config.sidebarWidth;
 | 
			
		||||
    if (!nextSidebar) {
 | 
			
		||||
      switch (widths.find((w) => w < size.width)) {
 | 
			
		||||
        case WINDOW_WIDTH_2XL:
 | 
			
		||||
          nextSidebar = MAX_SIDEBAR_WIDTH;
 | 
			
		||||
          break;
 | 
			
		||||
        case WINDOW_WIDTH_XL:
 | 
			
		||||
        case WINDOW_WIDTH_LG:
 | 
			
		||||
          nextSidebar = DEFAULT_SIDEBAR_WIDTH;
 | 
			
		||||
          break;
 | 
			
		||||
        case WINDOW_WIDTH_MD:
 | 
			
		||||
        case WINDOW_WIDTH_SM:
 | 
			
		||||
        default:
 | 
			
		||||
          nextSidebar = MIN_SIDEBAR_WIDTH;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { menuWidth } = updateGlobalCSSVars(nextSidebar);
 | 
			
		||||
 | 
			
		||||
    config.update((config) => {
 | 
			
		||||
      config.sidebarWidth = menuWidth;
 | 
			
		||||
    });
 | 
			
		||||
    config.update((config) => {
 | 
			
		||||
      config.isMobileScreen = size.width <= MOBILE_MAX_WIDTH;
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user