diff --git a/.eslintrc.json b/.eslintrc.json index d229e86f250..f9989922d8c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,12 @@ { "extends": "next/core-web-vitals", - "plugins": ["prettier"] + "plugins": [ + "prettier" + ], + "parserOptions": { + "ecmaFeatures": { + "legacyDecorators": true + } + }, + "ignorePatterns": ["globals.css"] } diff --git a/app/api/provider/[...path]/route.ts b/app/api/provider/[...path]/route.ts new file mode 100644 index 00000000000..f6bba4ca655 --- /dev/null +++ b/app/api/provider/[...path]/route.ts @@ -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[], + ), + ), +); diff --git a/app/client/common/index.ts b/app/client/common/index.ts new file mode 100644 index 00000000000..f1414bf1a84 --- /dev/null +++ b/app/client/common/index.ts @@ -0,0 +1,7 @@ +export * from "./types"; + +export * from "./locale"; + +export * from "./utils"; + +export const modelNameRequestHeader = "x-nextchat-model-name"; diff --git a/app/client/common/locale.ts b/app/client/common/locale.ts new file mode 100644 index 00000000000..610ac488fd2 --- /dev/null +++ b/app/client/common/locale.ts @@ -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> & { + [name in Default]: TextPlainConfig; +}; + +export function getLocaleText< + TextPlainConfig extends PlainConfig, + DefaultLang extends Lang, +>(textMap: LocaleMap, defaultLang: DefaultLang) { + return textMap[getLang()] || textMap[defaultLang]; +} diff --git a/app/client/common/types.ts b/app/client/common/types.ts new file mode 100644 index 00000000000..990559f2697 --- /dev/null +++ b/app/client/common/types.ts @@ -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 { + 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 { + providerTemplateName: string; + isActive: boolean; + providerName: string; + available: boolean; + customized: boolean; // Only customized model is allowed to be modified +} + +export interface ModelInfo extends Pick { + [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 + extends StandChatRequestPayload { + providerConfig: Partial>; + isVisionModel: Model["isVisionModel"]; + stream: boolean; +} + +export interface ProviderRequestPayload { + headers: Record; + 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); + +export type CommonSettingItem = { + 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 = + CommonSettingItem & + ( + | InputSettingItem + | SelectSettingItem + | RangeSettingItem + | SwitchSettingItem + ); + +// ===================================== Provider Settings Types end ====================================== + +// ===================================== Provider Template Types start ====================================== + +export type ServerConfig = ReturnType; + +export interface IProviderTemplate< + SettingKeys extends string, + NAME extends string, + Meta extends Record, +> { + 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[]; + }; + readonly defaultModels: ModelTemplate[]; + + streamChat( + payload: InternalChatRequestPayload, + handlers: ChatHandlers, + fetch: typeof window.fetch, + ): AbortController; + + chat( + payload: InternalChatRequestPayload, + fetch: typeof window.fetch, + ): Promise; + + getAvailableModels?( + providerConfig: InternalChatRequestPayload["providerConfig"], + ): Promise; + + readonly runtime: "edge"; + readonly preferredRegion: "auto" | "global" | "home" | string | string[]; + + serverSideRequestHandler( + req: NextRequest & { + subpath: string; + }, + serverConfig: ServerConfig, + ): Promise; +} + +export type ProviderTemplate = IProviderTemplate; + +export interface Serializable { + serialize(): Snapshot; +} diff --git a/app/client/common/utils.ts b/app/client/common/utils.ts new file mode 100644 index 00000000000..bb9e579d1a8 --- /dev/null +++ b/app/client/common/utils.ts @@ -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, + 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, +) => travel(config, keys, formatUrl); + +export const disableSystemApiKey = ( + config: ServerConfig, + keys: Array, + 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 + ); +} diff --git a/app/client/core/index.ts b/app/client/core/index.ts new file mode 100644 index 00000000000..963227f5e29 --- /dev/null +++ b/app/client/core/index.ts @@ -0,0 +1,9 @@ +export * from "./shim"; + +export * from "../common/types"; + +export * from "./providerClient"; + +export * from "./modelClient"; + +export * from "../common/locale"; diff --git a/app/client/core/modelClient.ts b/app/client/core/modelClient.ts new file mode 100644 index 00000000000..eeb160bdbe5 --- /dev/null +++ b/app/client/core/modelClient.ts @@ -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 = {}; + + // 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; +} diff --git a/app/client/core/providerClient.ts b/app/client/core/providerClient.ts new file mode 100644 index 00000000000..cc733bc8cd9 --- /dev/null +++ b/app/client/core/providerClient.ts @@ -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 = Record, +> { + 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, +); + +export class ProviderClient { + providerTemplate: IProviderTemplate; + genFetch: (modelName: string) => typeof window.fetch; + + static ProviderTemplates = providerTemplates; + + static getAllProviderTemplates = () => { + return Object.values(providerTemplates).reduce( + (r, t) => ({ + ...r, + [t.name]: t, + }), + {} as Record, + ); + }; + + 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 = { + ...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 { + 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; + +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 }; diff --git a/app/client/core/shim.ts b/app/client/core/shim.ts new file mode 100644 index 00000000000..ec5def5f184 --- /dev/null +++ b/app/client/core/shim.ts @@ -0,0 +1,25 @@ +import { getClientConfig } from "@/app/config/client"; + +if (!(window.fetch as any).__hijacked__) { + let _fetch = window.fetch; + + function fetch(...args: Parameters) { + 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; +} diff --git a/app/client/index.ts b/app/client/index.ts new file mode 100644 index 00000000000..cf5414ede8b --- /dev/null +++ b/app/client/index.ts @@ -0,0 +1,3 @@ +export * from "./core"; + +export * from "./providers"; diff --git a/app/client/platforms/google.ts b/app/client/platforms/google.ts index a786f5275f4..b6eb8d3dfab 100644 --- a/app/client/platforms/google.ts +++ b/app/client/platforms/google.ts @@ -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)); } @@ -139,7 +141,7 @@ export class GeminiProApi implements LLMApi { () => controller.abort(), REQUEST_TIMEOUT_MS, ); - + if (shouldStream) { let responseText = ""; let remainText = ""; diff --git a/app/client/providers/anthropic/config.ts b/app/client/providers/anthropic/config.ts new file mode 100644 index 00000000000..60f328197a6 --- /dev/null +++ b/app/client/providers/anthropic/config.ts @@ -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[] = (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"], + }, +]; diff --git a/app/client/providers/anthropic/index.ts b/app/client/providers/anthropic/index.ts new file mode 100644 index 00000000000..a92d9485a67 --- /dev/null +++ b/app/client/providers/anthropic/index.ts @@ -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) { + 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, + 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, + 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)); + } + }; +} diff --git a/app/client/providers/anthropic/locale.ts b/app/client/providers/anthropic/locale.ts new file mode 100644 index 00000000000..9aabd9e16b4 --- /dev/null +++ b/app/client/providers/anthropic/locale.ts @@ -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", +); diff --git a/app/client/providers/anthropic/utils.ts b/app/client/providers/anthropic/utils.ts new file mode 100644 index 00000000000..7f6f576f55c --- /dev/null +++ b/app/client/providers/anthropic/utils.ts @@ -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, + }, + }; + }), + }; + }); +} diff --git a/app/client/providers/azure/config.ts b/app/client/providers/azure/config.ts new file mode 100644 index 00000000000..7b9b05e78f9 --- /dev/null +++ b/app/client/providers/azure/config.ts @@ -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[] = (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"], + }, +]; diff --git a/app/client/providers/azure/index.ts b/app/client/providers/azure/index.ts new file mode 100644 index 00000000000..ca20cf2f1c4 --- /dev/null +++ b/app/client/providers/azure/index.ts @@ -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) { + 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, + 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, + 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, + ): Promise { + 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)); + } + }; +} diff --git a/app/client/providers/azure/locale.ts b/app/client/providers/azure/locale.ts new file mode 100644 index 00000000000..b6b7d2e75d6 --- /dev/null +++ b/app/client/providers/azure/locale.ts @@ -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", +); diff --git a/app/client/providers/azure/utils.ts b/app/client/providers/azure/utils.ts new file mode 100644 index 00000000000..f1bdda4de11 --- /dev/null +++ b/app/client/providers/azure/utils.ts @@ -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 = { + "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, + }; +} diff --git a/app/client/providers/google/config.ts b/app/client/providers/google/config.ts new file mode 100644 index 00000000000..7cf4dc85dd9 --- /dev/null +++ b/app/client/providers/google/config.ts @@ -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[] = (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"], + }, +]; diff --git a/app/client/providers/google/index.ts b/app/client/providers/google/index.ts new file mode 100644 index 00000000000..1003c8b4086 --- /dev/null +++ b/app/client/providers/google/index.ts @@ -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 +{ + 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) { + 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, + 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 { + 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, + fetch: typeof window.fetch, + ): Promise { + 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, + ): Promise { + 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); + } + }; +} diff --git a/app/client/providers/google/locale.ts b/app/client/providers/google/locale.ts new file mode 100644 index 00000000000..5d92cd97087 --- /dev/null +++ b/app/client/providers/google/locale.ts @@ -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", +); diff --git a/app/client/providers/google/utils.ts b/app/client/providers/google/utils.ts new file mode 100644 index 00000000000..2d2528167d9 --- /dev/null +++ b/app/client/providers/google/utils.ts @@ -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 || + "", + }; +} diff --git a/app/client/providers/index.ts b/app/client/providers/index.ts new file mode 100644 index 00000000000..58e080337d2 --- /dev/null +++ b/app/client/providers/index.ts @@ -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"; diff --git a/app/client/providers/nextchat/config.ts b/app/client/providers/nextchat/config.ts new file mode 100644 index 00000000000..67852854e49 --- /dev/null +++ b/app/client/providers/nextchat/config.ts @@ -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[] = [ + { + name: "accessCode", + title: Locale.Auth.Title, + description: Locale.Auth.Tips, + placeholder: Locale.Auth.Input, + type: "input", + inputType: "password", + validators: ["required"], + }, +]; diff --git a/app/client/providers/nextchat/index.ts b/app/client/providers/nextchat/index.ts new file mode 100644 index 00000000000..5471a279617 --- /dev/null +++ b/app/client/providers/nextchat/index.ts @@ -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 +{ + 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) { + 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, + 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 { + 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)); + } + }; +} diff --git a/app/client/providers/nextchat/utils.ts b/app/client/providers/nextchat/utils.ts new file mode 100644 index 00000000000..397fadfffa6 --- /dev/null +++ b/app/client/providers/nextchat/utils.ts @@ -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 = { + "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`, + }; +} diff --git a/app/client/providers/openai/config.ts b/app/client/providers/openai/config.ts new file mode 100644 index 00000000000..34fa487cd9f --- /dev/null +++ b/app/client/providers/openai/config.ts @@ -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[] = (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"], + }, +]; diff --git a/app/client/providers/openai/index.ts b/app/client/providers/openai/index.ts new file mode 100644 index 00000000000..86df158f4b9 --- /dev/null +++ b/app/client/providers/openai/index.ts @@ -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 +{ + 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) { + 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, + 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, + 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, + ): Promise { + 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; diff --git a/app/client/providers/openai/locale.ts b/app/client/providers/openai/locale.ts new file mode 100644 index 00000000000..dab14e34f81 --- /dev/null +++ b/app/client/providers/openai/locale.ts @@ -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", +); diff --git a/app/client/providers/openai/type.ts b/app/client/providers/openai/type.ts new file mode 100644 index 00000000000..792ba844ffd --- /dev/null +++ b/app/client/providers/openai/type.ts @@ -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; + }>; +} diff --git a/app/client/providers/openai/utils.ts b/app/client/providers/openai/utils.ts new file mode 100644 index 00000000000..cb14b95a71b --- /dev/null +++ b/app/client/providers/openai/utils.ts @@ -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 = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + if (validString(openaiApiKey)) { + headers[authHeaderName] = makeBearer(openaiApiKey); + } + + return headers; +} diff --git a/app/components/ActionsBar/index.tsx b/app/components/ActionsBar/index.tsx new file mode 100644 index 00000000000..dda6513a94a --- /dev/null +++ b/app/components/ActionsBar/index.tsx @@ -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 ( +
+ {selected === action.id ? mobileActiveIcon : mobileInactiveIcon} +
+ {action.title || " "} +
+
+ ); + } + + return ( +
+ {selected === action.id ? activeIcon : inactiveIcon} +
+ ); + }), + ); + if (ind < arr.length - 1) { + res.push(
); + } + return res; + }, [] as JSX.Element[]); + + return
{content}
; +} diff --git a/app/components/Btn/index.tsx b/app/components/Btn/index.tsx new file mode 100644 index 00000000000..9a65a50ba6d --- /dev/null +++ b/app/components/Btn/index.tsx @@ -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 ( + + ); +} diff --git a/app/components/Card/index.tsx b/app/components/Card/index.tsx new file mode 100644 index 00000000000..92b4734f03e --- /dev/null +++ b/app/components/Card/index.tsx @@ -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 && ( +
+ {title} +
+ )} +
+ {children} +
+ + ); +} diff --git a/app/components/GlobalLoading/index.tsx b/app/components/GlobalLoading/index.tsx new file mode 100644 index 00000000000..7681f4ec613 --- /dev/null +++ b/app/components/GlobalLoading/index.tsx @@ -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 ( +
+ {!noLogo && } + +
+ ); +} diff --git a/app/components/HoverPopover/index.tsx b/app/components/HoverPopover/index.tsx new file mode 100644 index 00000000000..8605e9deeb4 --- /dev/null +++ b/app/components/HoverPopover/index.tsx @@ -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["align"]; + openDelay?: number; +} + +export default function HoverPopover(props: PopoverProps) { + const { + content, + children, + arrowClassName, + popoverClassName, + noArrow = false, + align, + openDelay = 300, + } = props; + return ( + + {children} + + + {content} + {!noArrow && } + + + + ); +} diff --git a/app/components/Imgs/index.tsx b/app/components/Imgs/index.tsx new file mode 100644 index 00000000000..6b06cc059f9 --- /dev/null +++ b/app/components/Imgs/index.tsx @@ -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 ( +
+ {imgSrcs.map((image, index) => { + return ( +
+ ); + })} +
+ ); +} diff --git a/app/components/Input/index.tsx b/app/components/Input/index.tsx new file mode 100644 index 00000000000..79826a4b4bd --- /dev/null +++ b/app/components/Input/index.tsx @@ -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, 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 ( +
+ { + 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" && ( +
setShow((pre) => !pre)}> + {show ? : } +
+ )} +
+ ); +} diff --git a/app/components/List/index.tsx b/app/components/List/index.tsx new file mode 100644 index 00000000000..46e3036ee5f --- /dev/null +++ b/app/components/List/index.tsx @@ -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; + +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({}); + + 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 ( +
+
+
+ {title} +
+ {subTitle && ( +
{subTitle}
+ )} +
+ +
+
{children}
+ {!!error && ( +
+
{error}
+
+ )} +
+
+
+ ); +} + +function List(props: ListProps) { + const { className, children, id, widgetStyle } = props; + const { isMobileScreen } = useContext(ListContext); + return ( + +
+ {children} +
+
+ ); +} + +List.ListItem = ListItem; +List.ListContext = ListContext; + +export default List; diff --git a/app/components/Loading/index.tsx b/app/components/Loading/index.tsx new file mode 100644 index 00000000000..6cedd8f26d6 --- /dev/null +++ b/app/components/Loading/index.tsx @@ -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 ( +
+ {!noLogo && } + +
+ ); +} diff --git a/app/components/MenuLayout/index.tsx b/app/components/MenuLayout/index.tsx new file mode 100644 index 00000000000..57f4c0c20fe --- /dev/null +++ b/app/components/MenuLayout/index.tsx @@ -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) => void; + setShowPanel?: (v: boolean) => void; + showPanel?: boolean; + [k: string]: any; +} + +export default function MenuLayout< + ListComponentProps extends MenuWrapperInspectProps, + PanelComponentProps extends MenuWrapperInspectProps, +>( + ListComponent: ComponentType, + PanelComponent: ComponentType, +) { + 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 ( +
+
+ +
+ {!isMobileScreen && ( +
{ + startDragWidth.current = config.sidebarWidth; + onDragStart(e as any); + }} + > +
+   +
+
+ )} +
+ +
+
+ ); + }; +} diff --git a/app/components/Modal/index.tsx b/app/components/Modal/index.tsx new file mode 100644 index 00000000000..9bca5373655 --- /dev/null +++ b/app/components/Modal/index.tsx @@ -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; + content?: React.ReactNode; +} + +export interface TriggerProps + extends Omit { + 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 ( + + + { + if (maskCloseble) { + handleClose(); + } + }} + /> + +
{ + if (maskCloseble) { + handleClose(); + } + }} + > +   +
+
+ {!noHeader && ( + +
+ {title} +
+ {closeble && ( +
{ + handleClose(); + }} + > + +
+ )} +
+ )} +
+ {typeof content === "function" + ? content({ + close: () => { + handleClose(); + }, + }) + : content} +
+ {!noFooter && ( +
+ + handleClose()} + text={cancelText} + className={`${btnCommonClass} ${cancelBtnClass}`} + /> + + + + +
+ )} +
+ {type === "modal" && ( +
{ + if (maskCloseble) { + handleClose(); + } + }} + > +   +
+ )} +
+
+
+ ); +}; + +export const Warn = ({ + title, + onOk, + visible, + content, + ...props +}: WarnProps) => { + const [internalVisible, setVisible] = useState(visible); + + return ( + + + {title} + + } + content={ + + {content} + + } + 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) => { + const root = createRoot(div); + const closeModal = () => { + root.unmount(); + }; + + return new Promise((resolve) => { + root.render( + { + closeModal(); + resolve(false); + }} + onOk={() => { + closeModal(); + resolve(true); + }} + />, + ); + }); +}; + +export const Trigger = (props: TriggerProps) => { + const { children, className, content, ...rest } = props; + + const [internalVisible, setVisible] = useState(false); + + return ( + <> +
{ + setVisible(true); + }} + > + {children} +
+ { + setVisible(false); + }} + content={ + typeof content === "function" + ? content({ + close: () => { + setVisible(false); + }, + }) + : content + } + /> + + ); +}; + +Modal.Trigger = Trigger; + +export default Modal; diff --git a/app/components/Popover/index.tsx b/app/components/Popover/index.tsx new file mode 100644 index 00000000000..54491f17acc --- /dev/null +++ b/app/components/Popover/index.tsx @@ -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 }) => { + const [color, setColor] = useState(""); + useEffect(() => { + if (sibling.current) { + const { backgroundColor } = window.getComputedStyle(sibling.current); + setColor(backgroundColor); + } + }, []); + + return ( + + + + ); +}; + +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) => 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]; + }; + + return getStyle(); + }, [Object.values(position?.poi || {})]); + + const popoverRef = useRef(null); + const closeTimer = useRef(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 ( +
{ + e.preventDefault(); + e.stopPropagation(); + if (!mergedShow) { + handleOpen(e); + } else { + handleClose(); + } + }} + > + {children} + {mergedShow && ( + <> + {!noArrow && ( +
+ +
+ )} + {createPortal( +
+ {content} +
, + popoverRoot, + )} + {createPortal( +
{ + e.preventDefault(); + handleClose(); + }} + > +   +
, + popoverRoot, + )} + + )} +
+ ); + } + + if (useGlobalRoot) { + return ( +
{ + 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 && ( + <> +
+ +
+ {createPortal( +
+ {content} +
, + popoverRoot, + )} + + )} +
+ ); + } + + return ( +
{ + getRelativePosition(e.currentTarget, ""); + e.preventDefault(); + e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + {children} + + +
+ ); +} diff --git a/app/components/Screen/index.tsx b/app/components/Screen/index.tsx new file mode 100644 index 00000000000..a2e1fafbc5c --- /dev/null +++ b/app/components/Screen/index.tsx @@ -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 ( +
+ {isAuth ? ( + props.noAuth + ) : ( + <> +
+ {props.sidebar} +
+ +
+ {props.children} +
+ + )} +
+ ); +} diff --git a/app/components/Search/index.module.scss b/app/components/Search/index.module.scss new file mode 100644 index 00000000000..922746e29b5 --- /dev/null +++ b/app/components/Search/index.module.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/components/Search/index.tsx b/app/components/Search/index.tsx new file mode 100644 index 00000000000..681c7ce2a9b --- /dev/null +++ b/app/components/Search/index.tsx @@ -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 ( +
+
+ +
+ { + e.preventDefault(); + onSearch?.(e.target.value); + }} + /> +
+ ); +}; + +export default Search; diff --git a/app/components/Select/index.tsx b/app/components/Select/index.tsx new file mode 100644 index 00000000000..640b9873723 --- /dev/null +++ b/app/components/Select/index.tsx @@ -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; + label: string; + icon?: React.ReactNode; +}; + +export interface SearchProps { + value?: string; + onSelect?: (v: Value) => void; + options?: Option[]; + inMobile?: boolean; +} + +const Select = (props: SearchProps) => { + const { value, onSelect, options = [], inMobile } = props; + + const { isMobileScreen, selectClassName } = useContext(List.ListContext); + + const optionsRef = useRef[]>([]); + optionsRef.current = options; + const selectedOption = useMemo( + () => optionsRef.current.find((o) => o.value === value), + [value], + ); + + const contentRef = useRef(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 = ( +
+ {options?.map((o) => ( +
{ + onSelect?.(o.value); + }} + > +
+ {!!o.icon &&
{o.icon}
} +
{o.label}
+
+
+ +
+
+ ))} +
+ ); + + return ( + { + getRelativePosition(contentRef.current!, ""); + }} + className={selectClassName} + > +
+
+ {!!selectedOption?.icon && ( +
{selectedOption?.icon}
+ )} +
{selectedOption?.label}
+
+
+ +
+
+
+ ); +}; + +export default Select; diff --git a/app/components/SlideRange/index.tsx b/app/components/SlideRange/index.tsx new file mode 100644 index 00000000000..bf62b36afc3 --- /dev/null +++ b/app/components/SlideRange/index.tsx @@ -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(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 ( +
+ {!!description && ( +
{description}
+ )} +
+
+   +
+
+ {value} +
+ { + setProperty(e.target.valueAsNumber); + onSlide?.(e.target.valueAsNumber); + }} + style={{ + marginLeft: margin, + marginRight: margin, + }} + /> +
+
+ ); +} diff --git a/app/components/Switch/index.tsx b/app/components/Switch/index.tsx new file mode 100644 index 00000000000..22ff5e2ad34 --- /dev/null +++ b/app/components/Switch/index.tsx @@ -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 ( + + + + ); +} diff --git a/app/components/ThumbnailImg/index.tsx b/app/components/ThumbnailImg/index.tsx new file mode 100644 index 00000000000..105e4312be8 --- /dev/null +++ b/app/components/ThumbnailImg/index.tsx @@ -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 ( +
+
+
+ +
+
+
+ ); +} diff --git a/app/components/auth.module.scss b/app/components/auth.module.scss index 6630c0613c7..3e58710bcd2 100644 --- a/app/components/auth.module.scss +++ b/app/components/auth.module.scss @@ -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; + } } diff --git a/app/components/exporter.module.scss b/app/components/exporter.module.scss index 5e992e7fda2..fe2a133295e 100644 --- a/app/components/exporter.module.scss +++ b/app/components/exporter.module.scss @@ -2,6 +2,9 @@ &-body { margin-top: 20px; } + div:not(.no-dark) > svg { + filter: invert(0.5); + } } .export-content { diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 1afd7de3b45..ec3b89339e0 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -177,13 +177,14 @@ export function Markdown( fontSize?: number; parentRef?: RefObject; defaultShow?: boolean; + className?: string; } & React.DOMAttributes, ) { const mdRef = useRef(null); return (
svg { + filter: invert(0.5); + } + .mask-page-body { padding: 20px; overflow-y: auto; diff --git a/app/components/mask.tsx b/app/components/mask.tsx index 77682b0b1ec..24c726a60b6 100644 --- a/app/components/mask.tsx +++ b/app/components/mask.tsx @@ -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(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 ( - -
+ <> +
@@ -645,6 +650,6 @@ export function MaskPage() {
)} - + ); } diff --git a/app/components/new-chat.module.scss b/app/components/new-chat.module.scss index b291a23664a..1b48659d38a 100644 --- a/app/components/new-chat.module.scss +++ b/app/components/new-chat.module.scss @@ -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; diff --git a/app/components/new-chat.tsx b/app/components/new-chat.tsx index 54c646f237c..59a26ca32b4 100644 --- a/app/components/new-chat.tsx +++ b/app/components/new-chat.tsx @@ -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 ( -
+
} diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index da700c0fb7c..76f17452771 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -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 (
-
-
{props.title}
+
+
{props.title}
-
+
setMax(!isMax)} @@ -147,11 +148,11 @@ export function Modal(props: ModalProps) {
{props.children}
-
+
{props.footer}
{props.actions?.map((action, i) => ( -
+
{action}
))} diff --git a/app/config/server.ts b/app/config/server.ts index b7c85ce6a5f..5969768bba5 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -55,7 +55,10 @@ const ACCESS_CODES = (function getAccessCodes(): Set { })(); 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]; diff --git a/app/constant.ts b/app/constant.ts index 9f1d87161ae..80042c99f60 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -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"; diff --git a/app/containers/Chat/ChatPanel.tsx b/app/containers/Chat/ChatPanel.tsx new file mode 100644 index 00000000000..0711575b827 --- /dev/null +++ b/app/containers/Chat/ChatPanel.tsx @@ -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(null); + const [userInput, setUserInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const scrollRef = useRef(null); + const chatInputPanelRef = useRef(null); + + const [hitBottom, setHitBottom] = useState(true); + + const [attachImages, setAttachImages] = useState([]); + + // 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 ( +
+ + + + + + + {showExport && ( + setShowExport(false)} /> + )} + + {isEditingMessage && ( + { + setIsEditingMessage(false); + }} + /> + )} + + + + {showPromptModal && ( + setShowPromptModal(false)} /> + )} +
+ ); +} + +export default function Chat() { + const chatStore = useChatStore(); + const sessionIndex = chatStore.currentSessionIndex; + return <_Chat key={sessionIndex}>; +} diff --git a/app/containers/Chat/components/ChatActions.tsx b/app/containers/Chat/components/ChatActions.tsx new file mode 100644 index 00000000000..a03a9cb6a80 --- /dev/null +++ b/app/containers/Chat/components/ChatActions.tsx @@ -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: , + placement: "left", + }, + { + text: currentModel, + isShow: !props.isMobileScreen, + render: (key: string) => , + placement: "left", + }, + { + onClick: props.scrollToBottom, + text: Locale.Chat.InputActions.ToBottom, + isShow: !props.hitBottom, + icon: , + placement: "left", + }, + { + onClick: props.uploadImage, + text: Locale.Chat.InputActions.UploadImage, + isShow: showUploadImage, + icon: props.uploading ? : , + placement: "left", + }, + // { + // onClick: nextTheme, + // text: Locale.Chat.InputActions.Theme[theme], + // isShow: true, + // icon: ( + // <> + // {theme === Theme.Auto ? ( + // + // ) : theme === Theme.Light ? ( + // + // ) : theme === Theme.Dark ? ( + // + // ) : null} + // + // ), + // placement: "left", + // }, + { + onClick: props.showPromptHints, + text: Locale.Chat.InputActions.Prompt, + isShow: true, + icon: , + placement: "left", + }, + { + onClick: () => { + navigate(Path.Masks); + }, + text: Locale.Chat.InputActions.Masks, + isShow: true, + icon: , + 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: , + placement: "right", + }, + { + onClick: props.showChatSetting, + text: Locale.Chat.InputActions.Settings, + isShow: true, + icon: , + placement: "right", + }, + ]; + + if (props.isMobileScreen) { + const content = ( +
+ {actions + .filter((v) => v.isShow && v.icon) + .map((act) => { + return ( +
+ {act.icon} +
+ {act.text} +
+
+ ); + })} +
+ ); + return ( + + + + ); + } + + const popoverClassName = `bg-chat-actions-btn-popover px-3 py-2.5 text-text-chat-actions-btn-popover text-sm-title rounded-md`; + + return ( +
+ {actions + .filter((v) => v.placement === "left" && v.isShow) + .map((act, ind) => { + if (act.render) { + return ( +
+ {act.render(act.text)} +
+ ); + } + return ( + +
+ {act.icon} +
+
+ ); + })} +
+ {actions + .filter((v) => v.placement === "right" && v.isShow) + .map((act, ind, arr) => { + return ( + +
+ {act.icon} +
+
+ ); + })} +
+ ); +} diff --git a/app/containers/Chat/components/ChatHeader.tsx b/app/containers/Chat/components/ChatHeader.tsx new file mode 100644 index 00000000000..73d30dbaa90 --- /dev/null +++ b/app/containers/Chat/components/ChatHeader.tsx @@ -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 ( +
+
+ {" "} +
+ + {isMobileScreen ? ( +
navigate(Path.Home)} + > + +
+ ) : ( + + )} + +
+
setIsEditingMessage(true)} + > + {!session.topic ? DEFAULT_TOPIC : session.topic} +
+
+ {isMobileScreen ? ( + + ) : ( + Locale.Chat.SubTitle(session.messages.length) + )} +
+
+ +
{ + setShowExport(true); + }} + > + +
+
+ ); +} diff --git a/app/containers/Chat/components/ChatInputPanel.tsx b/app/containers/Chat/components/ChatInputPanel.tsx new file mode 100644 index 00000000000..bd81489045a --- /dev/null +++ b/app/containers/Chat/components/ChatInputPanel.tsx @@ -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; + 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( + 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([]); + + 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) => { + // 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 ( +
+ + +
+ 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} + /> +
+ +
+ ) : 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([]); + const prompts = searchInput.length > 0 ? searchPrompts : allPrompts; + + const [editingPromptId, setEditingPromptId] = useState(); + + useEffect(() => { + if (searchInput.length > 0) { + const searchResult = SearchService.search(searchInput); + setSearchPrompts(searchResult); + } else { + setSearchPrompts([]); + } + }, [searchInput]); + + return ( +
+ props.onClose?.()} + actions={[ + { + const promptId = promptStore.add({ + id: nanoid(), + createdAt: Date.now(), + title: "Empty Prompt", + content: "Empty Prompt Content", + }); + setEditingPromptId(promptId); + }} + icon={} + bordered + text={Locale.Settings.Prompt.Modal.Add} + />, + ]} + // className="!bg-modal-mask" + > +
+ setSearchInput(e)} + > + +
+ {prompts.map((v, _) => ( +
+
+
{v.title}
+
+ {v.content} +
+
+ +
+ {v.isUser && ( + } + className={styles["user-prompt-button"]} + onClick={() => promptStore.remove(v.id!)} + /> + )} + {v.isUser ? ( + } + className={styles["user-prompt-button"]} + onClick={() => setEditingPromptId(v.id)} + /> + ) : ( + } + className={styles["user-prompt-button"]} + onClick={() => setEditingPromptId(v.id)} + /> + )} + } + className={styles["user-prompt-button"]} + onClick={() => copyToClipboard(v.content)} + /> +
+
+ ))} +
+
+
+ + {editingPromptId !== undefined && ( + setEditingPromptId(undefined)} + /> + )} +
+ ); +} diff --git a/app/containers/Settings/index.module.scss b/app/containers/Settings/index.module.scss new file mode 100644 index 00000000000..26f92a06ff1 --- /dev/null +++ b/app/containers/Settings/index.module.scss @@ -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; + } + } + } + } +} diff --git a/app/containers/Settings/index.tsx b/app/containers/Settings/index.tsx new file mode 100644 index 00000000000..f6b9f38c224 --- /dev/null +++ b/app/containers/Settings/index.tsx @@ -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 ( +
+
+
+
+ {Locale.Settings.Title} +
+
+
+ +
+ {list.map((i) => ( +
{ + setShowPanel?.(true); + setExternalProps?.(i); + setSelected(i.id); + }} + > + {i.title} + {i.icon} + {isMobileScreen && } +
+ ))} +
+
+ ); +}, Panel); diff --git a/app/containers/Sidebar/index.tsx b/app/containers/Sidebar/index.tsx new file mode 100644 index 00000000000..a68303949ff --- /dev/null +++ b/app/containers/Sidebar/index.tsx @@ -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 ( +
+ , + inactive: , + mobileActive: , + mobileInactive: , + }, + title: "Discover", + activeClassName: "shadow-sidebar-btn-shadow", + className: "mb-4 hover:bg-sidebar-btn-hovered", + }, + { + id: Path.Home, + icons: { + active: , + inactive: , + mobileActive: , + mobileInactive: , + }, + title: "Assistant", + activeClassName: "shadow-sidebar-btn-shadow", + className: "mb-4 hover:bg-sidebar-btn-hovered", + }, + { + id: "github", + icons: , + className: "!p-2 mb-3 hover:bg-sidebar-btn-hovered", + }, + { + id: Path.Settings, + icons: { + active: , + inactive: , + mobileActive: , + mobileInactive: , + }, + 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 + `} + /> +
+ ); +} diff --git a/app/containers/index.tsx b/app/containers/index.tsx new file mode 100644 index 00000000000..68047e7c67f --- /dev/null +++ b/app/containers/index.tsx @@ -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: () => , + }, +); + +const Chat = dynamic(async () => await import("@/app/containers/Chat"), { + loading: () => , +}); + +const NewChat = dynamic( + async () => (await import("@/app/components/new-chat")).NewChat, + { + loading: () => , + }, +); + +const MaskPage = dynamic( + async () => (await import("@/app/components/mask")).MaskPage, + { + loading: () => , + }, +); + +function useHtmlLang() { + useEffect(() => { + const lang = getISOLang(); + const htmlLang = document.documentElement.lang; + + if (lang !== htmlLang) { + document.documentElement.lang = lang; + } + }, []); +} + +const useHasHydrated = () => { + const [hasHydrated, setHasHydrated] = useState(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 ; + } + + return ( + + + } sidebar={}> + + + } /> + + } + /> + + } + /> + } /> + } /> + + + + + + ); +} diff --git a/app/fonts/Satoshi-Variable.ttf b/app/fonts/Satoshi-Variable.ttf new file mode 100644 index 00000000000..976e85cb583 Binary files /dev/null and b/app/fonts/Satoshi-Variable.ttf differ diff --git a/app/fonts/Satoshi-Variable.woff b/app/fonts/Satoshi-Variable.woff new file mode 100644 index 00000000000..f8dcd1d6032 Binary files /dev/null and b/app/fonts/Satoshi-Variable.woff differ diff --git a/app/fonts/Satoshi-Variable.woff2 b/app/fonts/Satoshi-Variable.woff2 new file mode 100644 index 00000000000..b00e833ed4a Binary files /dev/null and b/app/fonts/Satoshi-Variable.woff2 differ diff --git a/app/global.d.ts b/app/global.d.ts index 31e2b6e8a84..6eca5e9f05b 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -21,10 +21,13 @@ declare interface Window { writeBinaryFile(path: string, data: Uint8Array): Promise; writeTextFile(path: string, data: string): Promise; }; - notification:{ + notification: { requestPermission(): Promise; isPermissionGranted(): Promise; sendNotification(options: string | Options): void; }; + http: { + fetch: typeof window.fetch; + }; }; } diff --git a/app/hooks/useDrag.ts b/app/hooks/useDrag.ts new file mode 100644 index 00000000000..949a716074b --- /dev/null +++ b/app/hooks/useDrag.ts @@ -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, + }; +} diff --git a/app/hooks/useHotKey.ts b/app/hooks/useHotKey.ts new file mode 100644 index 00000000000..7398b33dbe5 --- /dev/null +++ b/app/hooks/useHotKey.ts @@ -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); + }); +} diff --git a/app/hooks/useListenWinResize.ts b/app/hooks/useListenWinResize.ts new file mode 100644 index 00000000000..1a6b537a17e --- /dev/null +++ b/app/hooks/useListenWinResize.ts @@ -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; + }); + }); +} diff --git a/app/hooks/useLoadData.ts b/app/hooks/useLoadData.ts new file mode 100644 index 00000000000..4360dad826a --- /dev/null +++ b/app/hooks/useLoadData.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; +import { useAppConfig } from "@/app/store/config"; +import { ClientApi } from "@/app/client/api"; +import { ModelProvider } from "@/app/constant"; +import { identifyDefaultClaudeModel } from "@/app/utils/checkers"; + +export function useLoadData() { + const config = useAppConfig(); + + var api: ClientApi; + if (config.modelConfig.model.startsWith("gemini")) { + api = new ClientApi(ModelProvider.GeminiPro); + } else if (identifyDefaultClaudeModel(config.modelConfig.model)) { + api = new ClientApi(ModelProvider.Claude); + } else { + api = new ClientApi(ModelProvider.GPT); + } + useEffect(() => { + (async () => { + const models = await api.llm.models(); + config.mergeModels(models); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/app/hooks/useMobileScreen.ts b/app/hooks/useMobileScreen.ts new file mode 100644 index 00000000000..c6b5289ffbc --- /dev/null +++ b/app/hooks/useMobileScreen.ts @@ -0,0 +1,8 @@ +import { useWindowSize } from "@/app/hooks/useWindowSize"; +import { MOBILE_MAX_WIDTH } from "@/app/hooks/useListenWinResize"; + +export default function useMobileScreen() { + const { width } = useWindowSize(); + + return width <= MOBILE_MAX_WIDTH; +} diff --git a/app/hooks/usePaste.ts b/app/hooks/usePaste.ts new file mode 100644 index 00000000000..85ebddf050c --- /dev/null +++ b/app/hooks/usePaste.ts @@ -0,0 +1,73 @@ +import { isVisionModel } from "@/app/utils"; +import { compressImage } from "@/app/utils/chat"; +import { useCallback, useRef } from "react"; +import { useChatStore } from "../store/chat"; + +interface UseUploadImageOptions { + setUploading?: (v: boolean) => void; + emitImages?: (imgs: string[]) => void; +} + +export default function usePaste( + attachImages: string[], + options: UseUploadImageOptions, +) { + const chatStore = useChatStore(); + + const attachImagesRef = useRef([]); + const optionsRef = useRef({}); + const chatStoreRef = useRef(); + + attachImagesRef.current = attachImages; + optionsRef.current = options; + chatStoreRef.current = chatStore; + + const handlePaste = useCallback( + async (event: React.ClipboardEvent) => { + const { setUploading, emitImages } = optionsRef.current; + const currentModel = + chatStoreRef.current?.currentSession().mask.modelConfig.model; + if (currentModel && !isVisionModel(currentModel)) { + return; + } + const items = (event.clipboardData || window.clipboardData).items; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + event.preventDefault(); + const file = item.getAsFile(); + if (file) { + const images: string[] = []; + images.push(...attachImages); + images.push( + ...(await new Promise((res, rej) => { + setUploading?.(true); + const imagesData: string[] = []; + compressImage(file, 256 * 1024) + .then((dataUrl) => { + imagesData.push(dataUrl); + setUploading?.(false); + res(imagesData); + }) + .catch((e) => { + setUploading?.(false); + rej(e); + }); + })), + ); + const imagesLength = images.length; + + if (imagesLength > 3) { + images.splice(3, imagesLength - 3); + } + emitImages?.(images); + } + } + } + }, + [], + ); + + return { + handlePaste, + }; +} diff --git a/app/hooks/useRelativePosition.ts b/app/hooks/useRelativePosition.ts new file mode 100644 index 00000000000..90f532be43a --- /dev/null +++ b/app/hooks/useRelativePosition.ts @@ -0,0 +1,104 @@ +import { RefObject, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; + +export interface Options { + containerRef?: RefObject; + delay?: number; + offsetDistance?: number; +} + +export enum Orientation { + left, + right, + bottom, + top, +} + +export type X = Orientation.left | Orientation.right; +export type Y = Orientation.top | Orientation.bottom; + +interface Position { + id: string; + poi: { + targetH: number; + targetW: number; + distanceToRightBoundary: number; + distanceToLeftBoundary: number; + distanceToTopBoundary: number; + distanceToBottomBoundary: number; + overlapPositions: Record; + relativePosition: [X, Y]; + }; +} + +export default function useRelativePosition({ + containerRef = { current: window.document.body }, + delay = 100, + offsetDistance = 0, +}: Options) { + const [position, setPosition] = useState(); + + const getRelativePosition = useDebouncedCallback( + (target: HTMLDivElement, id: string) => { + if (!containerRef.current) { + return; + } + const { + x: targetX, + y: targetY, + width: targetW, + height: targetH, + } = target.getBoundingClientRect(); + + const { + x: containerX, + y: containerY, + width: containerWidth, + height: containerHeight, + } = containerRef.current.getBoundingClientRect(); + + const distanceToRightBoundary = + containerX + containerWidth - (targetX + targetW) - offsetDistance; + const distanceToLeftBoundary = targetX - containerX - offsetDistance; + const distanceToTopBoundary = targetY - containerY - offsetDistance; + const distanceToBottomBoundary = + containerY + containerHeight - (targetY + targetH) - offsetDistance; + + setPosition({ + id, + poi: { + targetW: targetW + 2 * offsetDistance, + targetH: targetH + 2 * offsetDistance, + distanceToRightBoundary, + distanceToLeftBoundary, + distanceToTopBoundary, + distanceToBottomBoundary, + overlapPositions: { + [Orientation.left]: distanceToLeftBoundary <= 0, + [Orientation.top]: distanceToTopBoundary <= 0, + [Orientation.right]: distanceToRightBoundary <= 0, + [Orientation.bottom]: distanceToBottomBoundary <= 0, + }, + relativePosition: [ + distanceToLeftBoundary <= distanceToRightBoundary + ? Orientation.left + : Orientation.right, + distanceToTopBoundary <= distanceToBottomBoundary + ? Orientation.top + : Orientation.bottom, + ], + }, + }); + }, + delay, + { + leading: true, + trailing: true, + }, + ); + + return { + getRelativePosition, + position, + }; +} diff --git a/app/hooks/useRows.ts b/app/hooks/useRows.ts new file mode 100644 index 00000000000..f9d72eb3909 --- /dev/null +++ b/app/hooks/useRows.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { autoGrowTextArea } from "../utils"; +import { useAppConfig } from "../store"; + +export default function useRows({ + inputRef, +}: { + inputRef: React.RefObject; +}) { + const [inputRows, setInputRows] = useState(2); + const config = useAppConfig(); + const { isMobileScreen } = config; + + const measure = useDebouncedCallback( + () => { + const rows = inputRef.current ? autoGrowTextArea(inputRef.current) : 1; + const inputRows = Math.min( + 20, + Math.max(2 + (isMobileScreen ? -1 : 1), rows), + ); + setInputRows(inputRows); + }, + 100, + { + leading: true, + trailing: true, + }, + ); + + useEffect(() => { + measure(); + }, [isMobileScreen]); + + return { + inputRows, + measure, + }; +} diff --git a/app/hooks/useScrollToBottom.ts b/app/hooks/useScrollToBottom.ts new file mode 100644 index 00000000000..f2c35348a6e --- /dev/null +++ b/app/hooks/useScrollToBottom.ts @@ -0,0 +1,61 @@ +import { RefObject, useEffect, useRef, useState } from "react"; + +export default function useScrollToBottom( + scrollRef: RefObject, +) { + const detach = scrollRef?.current + ? Math.abs( + scrollRef.current.scrollHeight - + (scrollRef.current.scrollTop + scrollRef.current.clientHeight), + ) <= 1 + : false; + + // for auto-scroll + const [autoScroll, setAutoScroll] = useState(true); + + const autoScrollRef = useRef(); + + autoScrollRef.current = autoScroll; + + function scrollDomToBottom() { + const dom = scrollRef.current; + if (dom) { + requestAnimationFrame(() => { + setAutoScroll(true); + dom.scrollTo(0, dom.scrollHeight); + }); + } + } + + // useEffect(() => { + // const dom = scrollRef.current; + // if (dom) { + // dom.ontouchstart = (e) => { + // const autoScroll = autoScrollRef.current; + // if (autoScroll) { + // setAutoScroll(false); + // } + // } + // dom.onscroll = (e) => { + // const autoScroll = autoScrollRef.current; + // if (autoScroll) { + // setAutoScroll(false); + // } + // } + // } + // }, []); + + // auto scroll + useEffect(() => { + if (autoScroll && !detach) { + scrollDomToBottom(); + } + }); + + return { + scrollRef, + autoScroll, + setAutoScroll, + scrollDomToBottom, + }; +} diff --git a/app/hooks/useShowPromptHint.ts b/app/hooks/useShowPromptHint.ts new file mode 100644 index 00000000000..cb1b4b99075 --- /dev/null +++ b/app/hooks/useShowPromptHint.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +export default function useShowPromptHint(props: { + prompts: RenderPompt[]; +}) { + const [internalPrompts, setInternalPrompts] = useState([]); + const [notShowPrompt, setNotShowPrompt] = useState(true); + + useEffect(() => { + if (props.prompts.length !== 0) { + setInternalPrompts(props.prompts); + + window.setTimeout(() => { + setNotShowPrompt(false); + }, 50); + + return; + } + setNotShowPrompt(true); + window.setTimeout(() => { + setInternalPrompts(props.prompts); + }, 300); + }, [props.prompts]); + + return { + notShowPrompt, + internalPrompts, + }; +} diff --git a/app/hooks/useSubmitHandler.ts b/app/hooks/useSubmitHandler.ts new file mode 100644 index 00000000000..a8ab24e7c57 --- /dev/null +++ b/app/hooks/useSubmitHandler.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; +import { SubmitKey, useAppConfig } from "../store/config"; + +export default function useSubmitHandler() { + const config = useAppConfig(); + const submitKey = config.submitKey; + const isComposing = useRef(false); + + useEffect(() => { + const onCompositionStart = () => { + isComposing.current = true; + }; + const onCompositionEnd = () => { + isComposing.current = false; + }; + + window.addEventListener("compositionstart", onCompositionStart); + window.addEventListener("compositionend", onCompositionEnd); + + return () => { + window.removeEventListener("compositionstart", onCompositionStart); + window.removeEventListener("compositionend", onCompositionEnd); + }; + }, []); + + const shouldSubmit = (e: React.KeyboardEvent) => { + // Fix Chinese input method "Enter" on Safari + if (e.keyCode == 229) return false; + if (e.key !== "Enter") return false; + if (e.key === "Enter" && (e.nativeEvent.isComposing || isComposing.current)) + return false; + return ( + (config.submitKey === SubmitKey.AltEnter && e.altKey) || + (config.submitKey === SubmitKey.CtrlEnter && e.ctrlKey) || + (config.submitKey === SubmitKey.ShiftEnter && e.shiftKey) || + (config.submitKey === SubmitKey.MetaEnter && e.metaKey) || + (config.submitKey === SubmitKey.Enter && + !e.altKey && + !e.ctrlKey && + !e.shiftKey && + !e.metaKey) + ); + }; + + return { + submitKey, + shouldSubmit, + }; +} diff --git a/app/hooks/useSwitchTheme.ts b/app/hooks/useSwitchTheme.ts new file mode 100644 index 00000000000..ac8132d6200 --- /dev/null +++ b/app/hooks/useSwitchTheme.ts @@ -0,0 +1,48 @@ +import { useLayoutEffect } from "react"; +import { Theme, useAppConfig } from "@/app/store/config"; +import { getCSSVar } from "../utils"; + +const DARK_CLASS = "dark-new"; +const LIGHT_CLASS = "light-new"; + +export function useSwitchTheme() { + const config = useAppConfig(); + + useLayoutEffect(() => { + document.body.classList.remove(DARK_CLASS); + document.body.classList.remove(LIGHT_CLASS); + + if (config.theme === Theme.Dark) { + document.body.classList.add(DARK_CLASS); + } else { + document.body.classList.add(LIGHT_CLASS); + } + }, [config.theme]); + + useLayoutEffect(() => { + document.body.classList.remove("light"); + document.body.classList.remove("dark"); + + if (config.theme === "dark") { + document.body.classList.add("dark"); + } else if (config.theme === "light") { + document.body.classList.add("light"); + } + + const metaDescriptionDark = document.querySelector( + 'meta[name="theme-color"][media*="dark"]', + ); + const metaDescriptionLight = document.querySelector( + 'meta[name="theme-color"][media*="light"]', + ); + + if (config.theme === "auto") { + metaDescriptionDark?.setAttribute("content", "#151515"); + metaDescriptionLight?.setAttribute("content", "#fafafa"); + } else { + const themeColor = getCSSVar("--theme-color"); + metaDescriptionDark?.setAttribute("content", themeColor); + metaDescriptionLight?.setAttribute("content", themeColor); + } + }, [config.theme]); +} diff --git a/app/hooks/useUploadImage.ts b/app/hooks/useUploadImage.ts new file mode 100644 index 00000000000..8d898e3c52d --- /dev/null +++ b/app/hooks/useUploadImage.ts @@ -0,0 +1,69 @@ +import { compressImage } from "@/app/utils/chat"; +import { useCallback, useRef } from "react"; + +interface UseUploadImageOptions { + setUploading?: (v: boolean) => void; + emitImages?: (imgs: string[]) => void; +} + +export default function useUploadImage( + attachImages: string[], + options: UseUploadImageOptions, +) { + const attachImagesRef = useRef([]); + const optionsRef = useRef({}); + + attachImagesRef.current = attachImages; + optionsRef.current = options; + + const uploadImage = useCallback(async function uploadImage() { + const images: string[] = []; + images.push(...attachImagesRef.current); + + const { setUploading, emitImages } = optionsRef.current; + + images.push( + ...(await new Promise((res, rej) => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = + "image/png, image/jpeg, image/webp, image/heic, image/heif"; + fileInput.multiple = true; + fileInput.onchange = (event: any) => { + setUploading?.(true); + const files = event.target.files; + const imagesData: string[] = []; + for (let i = 0; i < files.length; i++) { + const file = event.target.files[i]; + compressImage(file, 256 * 1024) + .then((dataUrl) => { + imagesData.push(dataUrl); + if ( + imagesData.length === 3 || + imagesData.length === files.length + ) { + setUploading?.(false); + res(imagesData); + } + }) + .catch((e) => { + setUploading?.(false); + rej(e); + }); + } + }; + fileInput.click(); + })), + ); + + const imagesLength = images.length; + if (imagesLength > 3) { + images.splice(3, imagesLength - 3); + } + emitImages?.(images); + }, []); + + return { + uploadImage, + }; +} diff --git a/app/hooks/useWindowSize.ts b/app/hooks/useWindowSize.ts new file mode 100644 index 00000000000..a4dbf2ef930 --- /dev/null +++ b/app/hooks/useWindowSize.ts @@ -0,0 +1,43 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +type Size = { + width: number; + height: number; +}; + +export function useWindowSize(callback?: (size: Size) => void) { + const callbackRef = useRef(); + + callbackRef.current = callback; + + const [size, setSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + + useLayoutEffect(() => { + const onResize = () => { + callbackRef.current?.({ + width: window.innerWidth, + height: window.innerHeight, + }); + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + window.addEventListener("resize", onResize); + + callback?.({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return () => { + window.removeEventListener("resize", onResize); + }; + }, []); + + return size; +} diff --git a/app/icons/addCircle.svg b/app/icons/addCircle.svg new file mode 100644 index 00000000000..5c1fd45ceac --- /dev/null +++ b/app/icons/addCircle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/icons/addIcon.svg b/app/icons/addIcon.svg new file mode 100644 index 00000000000..49716741ed1 --- /dev/null +++ b/app/icons/addIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/assistantActive.svg b/app/icons/assistantActive.svg new file mode 100644 index 00000000000..afb0266e261 --- /dev/null +++ b/app/icons/assistantActive.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/icons/assistantInactive.svg b/app/icons/assistantInactive.svg new file mode 100644 index 00000000000..8b1ba47f13b --- /dev/null +++ b/app/icons/assistantInactive.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/assistantMobileActive.svg b/app/icons/assistantMobileActive.svg new file mode 100644 index 00000000000..49e1dd0316d --- /dev/null +++ b/app/icons/assistantMobileActive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/icons/assistantMobileInactive.svg b/app/icons/assistantMobileInactive.svg new file mode 100644 index 00000000000..cbe80727853 --- /dev/null +++ b/app/icons/assistantMobileInactive.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/icons/bottomArrow.svg b/app/icons/bottomArrow.svg new file mode 100644 index 00000000000..e295bcab9d1 --- /dev/null +++ b/app/icons/bottomArrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/closeIcon.svg b/app/icons/closeIcon.svg new file mode 100644 index 00000000000..3c5bbb309f5 --- /dev/null +++ b/app/icons/closeIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/comandIcon.svg b/app/icons/comandIcon.svg new file mode 100644 index 00000000000..861c63e5ef8 --- /dev/null +++ b/app/icons/comandIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/command&enterIcon.svg b/app/icons/command&enterIcon.svg new file mode 100644 index 00000000000..e021aa6d88d --- /dev/null +++ b/app/icons/command&enterIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/icons/configIcon.svg b/app/icons/configIcon.svg new file mode 100644 index 00000000000..bc5aa33f732 --- /dev/null +++ b/app/icons/configIcon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/icons/configIcon2.svg b/app/icons/configIcon2.svg new file mode 100644 index 00000000000..79a2b7e34de --- /dev/null +++ b/app/icons/configIcon2.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/icons/copyRequestIcon.svg b/app/icons/copyRequestIcon.svg new file mode 100644 index 00000000000..a83c72e525b --- /dev/null +++ b/app/icons/copyRequestIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/darkIcon.svg b/app/icons/darkIcon.svg new file mode 100644 index 00000000000..7b2a166378f --- /dev/null +++ b/app/icons/darkIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/deleteChatIcon.svg b/app/icons/deleteChatIcon.svg new file mode 100644 index 00000000000..717a5bc3146 --- /dev/null +++ b/app/icons/deleteChatIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/deleteIcon.svg b/app/icons/deleteIcon.svg new file mode 100644 index 00000000000..0f885936244 --- /dev/null +++ b/app/icons/deleteIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/deleteRequestIcon.svg b/app/icons/deleteRequestIcon.svg new file mode 100644 index 00000000000..c2b50dcc6a7 --- /dev/null +++ b/app/icons/deleteRequestIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/discoverActive.svg b/app/icons/discoverActive.svg new file mode 100644 index 00000000000..61734144169 --- /dev/null +++ b/app/icons/discoverActive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/icons/discoverInactive.svg b/app/icons/discoverInactive.svg new file mode 100644 index 00000000000..441076aa1ec --- /dev/null +++ b/app/icons/discoverInactive.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/discoverMobileActive.svg b/app/icons/discoverMobileActive.svg new file mode 100644 index 00000000000..7809e40a2bd --- /dev/null +++ b/app/icons/discoverMobileActive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/icons/discoverMobileInactive.svg b/app/icons/discoverMobileInactive.svg new file mode 100644 index 00000000000..099d32babd2 --- /dev/null +++ b/app/icons/discoverMobileInactive.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/icons/downArrowIcon.svg b/app/icons/downArrowIcon.svg new file mode 100644 index 00000000000..d450900f96f --- /dev/null +++ b/app/icons/downArrowIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/downArrowLgIcon.svg b/app/icons/downArrowLgIcon.svg new file mode 100644 index 00000000000..ce627231996 --- /dev/null +++ b/app/icons/downArrowLgIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/icons/editIcon.svg b/app/icons/editIcon.svg new file mode 100644 index 00000000000..375e0abf9b9 --- /dev/null +++ b/app/icons/editIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/editRequestIcon.svg b/app/icons/editRequestIcon.svg new file mode 100644 index 00000000000..94d8cbfd325 --- /dev/null +++ b/app/icons/editRequestIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/enterIcon.svg b/app/icons/enterIcon.svg new file mode 100644 index 00000000000..d76d40f6232 --- /dev/null +++ b/app/icons/enterIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/icons/eraserIcon.svg b/app/icons/eraserIcon.svg new file mode 100644 index 00000000000..212bc07ac1c --- /dev/null +++ b/app/icons/eraserIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/icons/exportIcon.svg b/app/icons/exportIcon.svg new file mode 100644 index 00000000000..908d27c892e --- /dev/null +++ b/app/icons/exportIcon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/icons/githubIcon.svg b/app/icons/githubIcon.svg new file mode 100644 index 00000000000..0df652e61b3 --- /dev/null +++ b/app/icons/githubIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/goback.svg b/app/icons/goback.svg new file mode 100644 index 00000000000..605760bed2b --- /dev/null +++ b/app/icons/goback.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/goto.svg b/app/icons/goto.svg new file mode 100644 index 00000000000..29ff0ebb58e --- /dev/null +++ b/app/icons/goto.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/imgDeleteIcon.svg b/app/icons/imgDeleteIcon.svg new file mode 100644 index 00000000000..662d96f8581 --- /dev/null +++ b/app/icons/imgDeleteIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/imgRetryUploadIcon.svg b/app/icons/imgRetryUploadIcon.svg new file mode 100644 index 00000000000..df86a37cff0 --- /dev/null +++ b/app/icons/imgRetryUploadIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/icons/imgUploadFailedIcon.svg b/app/icons/imgUploadFailedIcon.svg new file mode 100644 index 00000000000..1310db4d8dc --- /dev/null +++ b/app/icons/imgUploadFailedIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/icons/imgUploadIcon.svg b/app/icons/imgUploadIcon.svg new file mode 100644 index 00000000000..f08ed67521e --- /dev/null +++ b/app/icons/imgUploadIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/importIcon.svg b/app/icons/importIcon.svg new file mode 100644 index 00000000000..7627cdb70e7 --- /dev/null +++ b/app/icons/importIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/icons/lightIcon.svg b/app/icons/lightIcon.svg new file mode 100644 index 00000000000..638aa96522f --- /dev/null +++ b/app/icons/lightIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/logIcon.svg b/app/icons/logIcon.svg new file mode 100644 index 00000000000..19a3cfec669 --- /dev/null +++ b/app/icons/logIcon.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/icons/maskIcon.svg b/app/icons/maskIcon.svg new file mode 100644 index 00000000000..2e2aed516dc --- /dev/null +++ b/app/icons/maskIcon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/icons/nextchatTitle.svg b/app/icons/nextchatTitle.svg new file mode 100644 index 00000000000..bf75b542094 --- /dev/null +++ b/app/icons/nextchatTitle.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/icons/passwordInvisible.svg b/app/icons/passwordInvisible.svg new file mode 100644 index 00000000000..7daca0741f4 --- /dev/null +++ b/app/icons/passwordInvisible.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/passwordVisible.svg b/app/icons/passwordVisible.svg new file mode 100644 index 00000000000..8d93f409ac4 --- /dev/null +++ b/app/icons/passwordVisible.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/pinRequestIcon.svg b/app/icons/pinRequestIcon.svg new file mode 100644 index 00000000000..2876b4c2b78 --- /dev/null +++ b/app/icons/pinRequestIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/popoverArrowIcon.svg b/app/icons/popoverArrowIcon.svg new file mode 100644 index 00000000000..375bd89c9d8 --- /dev/null +++ b/app/icons/popoverArrowIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/retryRequestIcon.svg b/app/icons/retryRequestIcon.svg new file mode 100644 index 00000000000..da583ea6a93 --- /dev/null +++ b/app/icons/retryRequestIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/search.svg b/app/icons/search.svg new file mode 100644 index 00000000000..ad05b5e96fd --- /dev/null +++ b/app/icons/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/icons/selectedIcon.svg b/app/icons/selectedIcon.svg new file mode 100644 index 00000000000..bae1a59f442 --- /dev/null +++ b/app/icons/selectedIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/sendIcon.svg b/app/icons/sendIcon.svg new file mode 100644 index 00000000000..305e218ba6c --- /dev/null +++ b/app/icons/sendIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/settingActive.svg b/app/icons/settingActive.svg new file mode 100644 index 00000000000..c366c497ee5 --- /dev/null +++ b/app/icons/settingActive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/icons/settingInactive.svg b/app/icons/settingInactive.svg new file mode 100644 index 00000000000..2911468f630 --- /dev/null +++ b/app/icons/settingInactive.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/settingMobileActive.svg b/app/icons/settingMobileActive.svg new file mode 100644 index 00000000000..eed56e59d41 --- /dev/null +++ b/app/icons/settingMobileActive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/icons/settingMobileInactive.svg b/app/icons/settingMobileInactive.svg new file mode 100644 index 00000000000..aae79566d1a --- /dev/null +++ b/app/icons/settingMobileInactive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/icons/shareIcon.svg b/app/icons/shareIcon.svg new file mode 100644 index 00000000000..76244c8b512 --- /dev/null +++ b/app/icons/shareIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/syncIcon.svg b/app/icons/syncIcon.svg new file mode 100644 index 00000000000..d977da24048 --- /dev/null +++ b/app/icons/syncIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/icons/systemIcon.svg b/app/icons/systemIcon.svg new file mode 100644 index 00000000000..c37292a18ba --- /dev/null +++ b/app/icons/systemIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/icons/uploadImgIcon.svg b/app/icons/uploadImgIcon.svg new file mode 100644 index 00000000000..e8fe6ccf2f1 --- /dev/null +++ b/app/icons/uploadImgIcon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/icons/warning.svg b/app/icons/warning.svg new file mode 100644 index 00000000000..4ba9515e954 --- /dev/null +++ b/app/icons/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/layout.tsx b/app/layout.tsx index 5898b21a1fa..5c52ab79a3d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,9 @@ import "./styles/globals.scss"; import "./styles/markdown.scss"; import "./styles/highlight.scss"; +import "./styles/globals.css"; +import "./styles/base-new.scss"; + import { getClientConfig } from "./config/client"; import { type Metadata } from "next"; import { SpeedInsights } from "@vercel/speed-insights/next"; @@ -36,7 +39,10 @@ export default function RootLayout({ - + diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 2ff94e32d43..a4b4d2c48de 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -4,6 +4,9 @@ import { SubmitKey } from "../store/config"; const isApp = !!getClientConfig()?.isApp; const cn = { + Provider: { + // OPENAI_DISPLAY_NAME: 'OpenAI' + }, WIP: "该功能仍在开发中……", Error: { Unauthorized: isApp @@ -20,6 +23,10 @@ const cn = { }, ChatItem: { ChatItemCount: (count: number) => `${count} 条对话`, + DeleteContent: "删除助手后,无法检索聊天内容。你确定要删除它吗?", + DeleteTitle: "删除助手", + DeleteCancelBtn: "取消", + DeleteOkBtn: "删除", }, Chat: { SubTitle: (count: number) => `共 ${count} 条对话`, @@ -67,9 +74,9 @@ const cn = { }, Rename: "重命名对话", Typing: "正在输入…", - Input: (submitKey: string) => { + Input: (submitKey: string, isMobileScreen?: boolean) => { var inputHints = `${submitKey} 发送`; - if (submitKey === String(SubmitKey.Enter)) { + if (submitKey === String(SubmitKey.Enter) && !isMobileScreen) { inputHints += ",Shift + Enter 换行"; } return inputHints + ",/ 触发补全,: 触发命令"; @@ -80,6 +87,7 @@ const cn = { SaveAs: "存为面具", }, IsContext: "预设提示词", + SelectModel: "选择模型", }, Export: { Title: "分享聊天记录", @@ -128,8 +136,14 @@ const cn = { Settings: { Title: "设置", SubTitle: "所有设置选项", - + GeneralSettings: "通用设置", + ModelSettings: "模型设置", + DataSettings: "同步设置", + Basic: { + Title: "基础设置", + }, Danger: { + Title: "系统设置", Reset: { Title: "重置所有设置", SubTitle: "重置所有设置项回默认值", @@ -159,6 +173,7 @@ const cn = { InputTemplate: { Title: "用户输入预处理", SubTitle: "用户最新的一条消息会填充到此模板", + Error: "模板中必须携带占位符{{input}}", }, Update: { @@ -181,6 +196,7 @@ const cn = { SubTitle: "根据对话内容生成合适的标题", }, Sync: { + Title: "数据设置", CloudState: "云端数据", NotSyncYet: "还没有进行过同步", Success: "同步成功", @@ -224,6 +240,7 @@ const cn = { ImportFailed: "导入失败", }, Mask: { + Title: "面具设置", Splash: { Title: "面具启动页", SubTitle: "新建聊天时,展示面具启动页", @@ -234,6 +251,7 @@ const cn = { }, }, Prompt: { + Title: "提示语设置", Disable: { Title: "禁用提示词自动补全", SubTitle: "在输入框开头输入 / 即可触发自动补全", @@ -251,6 +269,9 @@ const cn = { Title: "编辑提示词", }, }, + Provider: { + Title: "自定义模型", + }, HistoryCount: { Title: "附带历史消息数", SubTitle: "每次请求携带的历史消息数", @@ -271,6 +292,7 @@ const cn = { }, Access: { + title: "接口设置", AccessCode: { Title: "访问密码", SubTitle: "管理员已开启加密访问", @@ -352,7 +374,9 @@ const cn = { SubTitle: "增加自定义模型可选项,使用英文逗号隔开", }, }, - + Models: { + Title: "模型设置", + }, Model: "模型 (model)", Temperature: { Title: "随机性 (temperature)", @@ -399,8 +423,8 @@ const cn = { Toast: (x: any) => `包含 ${x} 条预设提示词`, Edit: "当前对话设置", Add: "新增一条对话", - Clear: "上下文已清除", - Revert: "恢复上下文", + Clear: "解除以上内容关联", + Revert: "撤销", }, Plugin: { Name: "插件", @@ -484,6 +508,9 @@ const cn = { Topic: "主题", Time: "时间", }, + Discover: { + SearchPlaceholder: "搜索助手", + }, }; type DeepPartial = T extends object diff --git a/app/locales/en.ts b/app/locales/en.ts index aa153f52369..1862fbeb868 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -22,6 +22,11 @@ const en: LocaleType = { }, ChatItem: { ChatItemCount: (count: number) => `${count} messages`, + DeleteContent: + "After deleting the assistant, the chat content cannot be retrieved. Are you sure to delete it?", + DeleteTitle: "Delete assistant", + DeleteCancelBtn: "Cancel", + DeleteOkBtn: "Delete", }, Chat: { SubTitle: (count: number) => `${count} messages`, @@ -82,6 +87,7 @@ const en: LocaleType = { SaveAs: "Save as Mask", }, IsContext: "Contextual Prompt", + SelectModel: "Choose model", }, Export: { Title: "Export Messages", @@ -131,7 +137,14 @@ const en: LocaleType = { Settings: { Title: "Settings", SubTitle: "All Settings", + GeneralSettings: "General settings", + ModelSettings: "Model settings", + DataSettings: "Sync settings", + Basic: { + Title: "Basic Settings", + }, Danger: { + Title: "System Settings", Reset: { Title: "Reset All Settings", SubTitle: "Reset all setting items to default", @@ -161,6 +174,7 @@ const en: LocaleType = { InputTemplate: { Title: "Input Template", SubTitle: "Newest message will be filled to this template", + Error: "Placeholder {{input}} must be included in the template", }, Update: { @@ -183,6 +197,7 @@ const en: LocaleType = { SubTitle: "Generate a suitable title based on the conversation content", }, Sync: { + Title: "Data Settings", CloudState: "Last Update", NotSyncYet: "Not sync yet", Success: "Sync Success", @@ -227,6 +242,7 @@ const en: LocaleType = { ImportFailed: "Failed to import from file", }, Mask: { + Title: "Mask Settings", Splash: { Title: "Mask Splash Screen", SubTitle: "Show a mask splash screen before starting new chat", @@ -237,6 +253,7 @@ const en: LocaleType = { }, }, Prompt: { + Title: "Prompt Settings", Disable: { Title: "Disable auto-completion", SubTitle: "Input / to trigger auto-completion", @@ -254,6 +271,9 @@ const en: LocaleType = { Title: "Edit Prompt", }, }, + Provider: { + Title: "Custom Models", + }, HistoryCount: { Title: "Attached Messages Count", SubTitle: "Number of sent messages attached per request", @@ -274,6 +294,7 @@ const en: LocaleType = { NoAccess: "Enter API Key to check balance", }, Access: { + title: "API Settings", AccessCode: { Title: "Access Code", SubTitle: "Access control Enabled", @@ -356,7 +377,9 @@ const en: LocaleType = { }, }, }, - + Models: { + Title: "Model Settings", + }, Model: "Model", Temperature: { Title: "Temperature", @@ -491,6 +514,10 @@ const en: LocaleType = { Code: "Detected access code from url, confirm to apply? ", Settings: "Detected settings from url, confirm to apply?", }, + + Discover: { + SearchPlaceholder: "Search assistant", + }, }; export default en; diff --git a/app/locales/jp.ts b/app/locales/jp.ts index dcbd0f2821b..8f052eb31ad 100644 --- a/app/locales/jp.ts +++ b/app/locales/jp.ts @@ -95,6 +95,7 @@ const jp: PartialLocaleType = { Settings: { Title: "設定", SubTitle: "設定オプション", + GeneralSettings: "一般設定", Danger: { Reset: { Title: "設定をリセット", diff --git a/app/page.tsx b/app/page.tsx index b3f169a9b74..c24c54d3428 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import { Analytics } from "@vercel/analytics/react"; -import { Home } from "./components/home"; +import Home from "@/app/containers"; import { getServerSideConfig } from "./config/server"; diff --git a/app/store/chat.ts b/app/store/chat.ts index 27a7114a3b5..f2190d84082 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -32,9 +32,26 @@ export type ChatMessage = RequestMessage & { model?: ModelType; }; -export function createMessage(override: Partial): ChatMessage { +let tempGlobalId = 0; + +export function createMessage( + override: Partial, + options?: { temp?: boolean; customId?: string }, +): ChatMessage { + const { temp, customId } = options ?? {}; + + let id: string; + if (customId) { + id = customId; + } else if (temp) { + tempGlobalId += 1; + id = String(tempGlobalId); + } else { + id = nanoid(); + } + return { - id: nanoid(), + id, date: new Date().toLocaleString(), role: "user", content: "", diff --git a/app/store/config.ts b/app/store/config.ts index 94cfcd8ecaa..c7c38214c8c 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -7,6 +7,9 @@ import { StoreKey, } from "../constant"; import { createPersistStore } from "../utils/store"; +import System from "@/app/icons/systemIcon.svg"; +import Light from "@/app/icons/lightIcon.svg"; +import Dark from "@/app/icons/darkIcon.svg"; export type ModelType = (typeof DEFAULT_MODELS)[number]["name"]; @@ -24,6 +27,20 @@ export enum Theme { Light = "light", } +export const ThemeConfig = { + [Theme.Auto]: { + icon: System, + title: "Follow System", + }, + [Theme.Light]: { + icon: Light, + title: "Light model", + }, + [Theme.Dark]: { + icon: Dark, + title: "Dark model", + }, +}; const config = getClientConfig(); export const DEFAULT_CONFIG = { @@ -46,6 +63,8 @@ export const DEFAULT_CONFIG = { customModels: "", models: DEFAULT_MODELS as any as LLMModel[], + isMobileScreen: false, + modelConfig: { model: "gpt-3.5-turbo" as ModelType, temperature: 0.5, diff --git a/app/store/provider.ts b/app/store/provider.ts new file mode 100644 index 00000000000..bd7b366e369 --- /dev/null +++ b/app/store/provider.ts @@ -0,0 +1,137 @@ +import { + ProviderClient, + NextChatProvider, + createProvider, + Provider, + Model, +} from "@/app/client"; +// import { getClientConfig } from "../config/client"; +import { StoreKey } from "../constant"; +import { createPersistStore } from "../utils/store"; + +const firstUpdate = Date.now(); + +function getDefaultConfig() { + const providers = Object.values(ProviderClient.ProviderTemplates) + .filter((t) => !(t instanceof NextChatProvider)) + .map((t) => createProvider(t, true)); + + const initProvider = providers[0]; + + const currentModel = + initProvider.models.find((m) => m.isDefaultSelected) || + initProvider.models[0]; + + return { + lastUpdate: firstUpdate, // timestamp, to merge state + + currentModel: currentModel.name, + currentProvider: initProvider.name, + + providers, + }; +} + +export type ProvidersConfig = ReturnType; + +export const useProviders = createPersistStore( + { ...getDefaultConfig() }, + (set, get) => { + const methods = { + reset() { + set(() => getDefaultConfig()); + }, + + addProvider(provider: Provider) { + set(() => ({ + providers: [...get().providers, provider], + })); + }, + + deleteProvider(provider: Provider) { + set(() => ({ + providers: [ + ...get().providers.filter((p) => p.name !== provider.name), + ], + })); + }, + + updateProvider(provider: Provider) { + set(() => ({ + providers: get().providers.map((p) => + p.name === provider.name ? provider : p, + ), + })); + }, + + getProvider(providerName: string) { + return get().providers.find((p) => p.name === providerName); + }, + + addModel( + model: Omit, + provider: Provider, + ) { + const newModel: Model = { + ...model, + providerTemplateName: provider.providerTemplateName, + customized: true, + }; + return methods.updateProvider({ + ...provider, + models: [...provider.models, newModel], + }); + }, + + deleteModel(model: Model, provider: Provider) { + return methods.updateProvider({ + ...provider, + models: provider.models.filter((m) => m.name !== model.name), + }); + }, + + updateModel(model: Model, provider: Provider) { + return methods.updateProvider({ + ...provider, + models: provider.models.map((m) => + m.name === model.name ? model : m, + ), + }); + }, + + switchModel(model: Model, provider: Provider) { + set(() => ({ + currentModel: model.name, + currentProvider: provider.name, + })); + }, + + getModel( + modelName: string, + providerName: string, + ): (Model & { providerName: string }) | undefined { + const provider = methods.getProvider(providerName); + const model = provider?.models.find((m) => m.name === modelName); + return model + ? { + ...model, + providerName: provider!.name, + } + : undefined; + }, + + allModels() {}, + }; + + return methods; + }, + { + name: StoreKey.Provider, + version: 1.0, + migrate(persistedState, version) { + const state = persistedState as ProvidersConfig; + + return state as any; + }, + }, +); diff --git a/app/store/sync.ts b/app/store/sync.ts index d3582e3c935..77f7b9cddf5 100644 --- a/app/store/sync.ts +++ b/app/store/sync.ts @@ -100,15 +100,17 @@ export const useSyncStore = createPersistStore( const remoteState = await client.get(config.username); if (!remoteState || remoteState === "") { await client.set(config.username, JSON.stringify(localState)); - console.log("[Sync] Remote state is empty, using local state instead."); - return + console.log( + "[Sync] Remote state is empty, using local state instead.", + ); + return; } else { const parsedRemoteState = JSON.parse( await client.get(config.username), ) as AppState; mergeAppState(localState, parsedRemoteState); setLocalAppState(localState); - } + } } catch (e) { console.log("[Sync] failed to get remote state", e); throw e; diff --git a/app/styles/base-new.scss b/app/styles/base-new.scss new file mode 100644 index 00000000000..5f3f9bab09b --- /dev/null +++ b/app/styles/base-new.scss @@ -0,0 +1,312 @@ +html, +body { + height: 100%; + width: 100%; + overflow: hidden; + font-family: PingFang, "SF Pro Text", "SF Pro Icons", "Helvetica Neue", + Helvetica, Arial, sans-serif; +} + +.light-new, +.dark-new { + *:focus-visible { + outline: none; + } + + * { + font-weight: 400; + } + + input { + text-align: inherit; + background-color: inherit; + } + + .follow-parent-svg { + svg { + fill: inherit; + *:not(rect) { + fill: currentColor; + } + rect { + fill: inherit; + } + } + } + + .default-icon-color { + color: var(--default-icon-color); + } + .active-new { + .new-header, .new-footer { + border-color: var(--modal-header-bottom-border) !important; + color: var(--modal-title-text) !important; + } + + .new-btn { + button { + background: var(--default-btn-bg); + color: var(--btn-default-text); + } + } + } + + --siderbar-mobile-height: 3.125rem; + --max-message-width: calc(var(--chat-panel-max-width) * 0.6); +} + +.light-new { + --global-bg: #e3e3ed; + --global-mobile-bg: #f0f0f3; + --actions-bar-btn-default-bg: #2e42f3; + --primary-btn-bg: #2e42f3; + --primary-btn-disabled-bg: rgba(60, 68, 255, 0.2); + --danger-btn-bg: #fff6f6; + --default-btn-bg: #f7f7f8; + --hovered-btn-bg: rgba(0, 0, 0, 0.05); + --hovered-danger-btn-bg: #FFE7E7; + --card-bg: #fff; + --input-bg: #f7f7f8; + --list-item-divider-bg: #f0f0f3; + --menu-bg: #f7f7f8; + --select-option-hovered-bg: rgba(0, 0, 0, 0.05); + --select-popover-panel-bg: #fff; + --select-bg: #f7f7f8; + --slider-bg: #f0f0f3; + --slider-slided-travel-bg: #88889a; + --slider-block-bg: #fff; + --switch-unchecked-bg: #c9c9d1; + --switch-checked-bg: #2e42f3; + --switch-btn-bg: #fff; + --chat-actions-popover-panel-mobile-bg: #fff; + --chat-actions-btn-popover-bg: #434360; + --chat-actions-btn-hovered-bg: rgba(0, 0, 0, 0.05); + --chat-panel-header-mask-bg: #f7f7f8; + --chat-panel-header-mobile-bg: #fff; + --chat-panel-input-hood-bg: #fff; + --chat-panel-message-user-bg: #4a5cff; + --chat-panel-message-bot-bg: #fff; + --chat-panel-message-bg: #f7f7f8; + --chat-panel-message-mobile-bg: #f0f0f3; + --chat-message-actions-btn-hovered-bg: rgba(0, 0, 0, 0.05); + --chat-panel-bg: #f7f7f8; + --chat-panel-message-clear-divider-bg: #e2e2e6; + --chat-menu-session-selected-bg: #dee1fd; + --chat-menu-session-unselected-bg: #f0f0f3; + --chat-menu-session-hovered-bg: #e2e2e6; + --settings-menu-mobile-bg: #f7f7f8; + --settings-menu-item-mobile-bg: #fff; + --settings-menu-item-selected-bg: #dee1fd; + --settings-header-mobile-bg: #fff; + --settings-panel-bg: #f7f7f8; + --sidebar-mobile-bg: #fff; + --sidebar-btn-hovered-bg: rgba(0, 0, 0, 0.05); + --delete-chat-popover-panel-bg: #fff; + --modal-mask-bg: rgba(0, 0, 0, 0.7); + --modal-panel-bg: #fff; + --delete-chat-ok-btn-bg: #ff5454; + --delete-chat-cancel-btn-bg: #fff; + --chat-message-actions-bg: #fff; + --menu-dragger-bg: #2E42F3; + --chat-actions-select-model-bg: rgba(0, 0, 0, 0.05); + --chat-actions-select-model-hover-bg: rgba(0, 0, 0, 0.1); + --select-hover-bg: rgba(0, 0, 0, 0.05); + --input-input-ele-hover-bg: rgba(0, 0, 0, 0); + --slider-block-hover-bg: #F7F7F8; + --chat-menu-session-unselected-mobile-bg:#FFF; + --chat-menu-session-selected-mobile-bg: rgba(0, 0, 0, 0.05); + --model-select-popover-panel-bg: #fff; + + --select-popover-border: rgba(0, 0, 0, 0.1); + --slider-block-border: #c9c9d1; + --thumbnail-border: rgba(0, 0, 0, 0.1); + --chat-actions-popover-mobile-border: #f0f0f3; + --chat-header-bottom-border: #e2e2e6; + --chat-input-top-border: #e2e2e6; + --chat-input-hood-border: #fff; + --chat-input-hood-focus-border: #606078; + --chat-menu-session-selected-border: #c9cefc; + --settings-menu-item-selected-border: #c9cefc; + --settings-header-border: #e2e2e6; + --delete-chat-popover-border: rgba(0, 0, 0, 0.1); + --delete-chat-cancel-btn-border: #e2e2e6; + --chat-menu-session-unselected-border: #f0f0f3; + --chat-menu-session-hovered-border: #e2e2e6; + --modal-header-bottom-border: #f0f0f3; + --chat-menu-session-unselected-mobile-border: #FFF; + --chat-menu-session-selected-mobile-border: rgba(0, 0, 0, 0); + + --sidebar-tab-mobile-active-text: #2e42f3; + --sidebar-tab-mobile-inactive-text: #a5a5b3; + --btn-primary-text: #fff; + --btn-danger-text: #ff5454; + --btn-default-text: #606078; + --card-title-text: #18182A; + --input-text: #18182a; + --select-text: #18182a; + --list-subtitle-text: #a5a5b3; + --slider-block-text: #606078; + --chat-actions-btn-popover-text: #fff; + --chat-header-title-text: #18182a; + --chat-header-subtitle-text: #88889a; + --chat-input-placeholder-text: #88889a; + --chat-message-date-text: #88889a; + --chat-message-markdown-user-text: #fff; + --chat-message-markdown-bot-text: #18182a; + --chat-panel-message-clear-text: #a5a5b3; + --chat-panel-message-clear-revert-text: #3c44ff; + --chat-menu-item-title-text: #18182a; + --chat-menu-item-time-text: rgba(0, 0, 0, 0.3); + --chat-menu-item-description-text: #88889a; + --settings-menu-title-text: #18182a; + --settings-menu-item-title-text: #18182a; + --settings-panel-header-title-text: #18182a; + --modal-panel-text: #18182a; + --delete-chat-ok-btn-text: #fff; + --delete-chat-cancel-btn-text: #18182a; + --chat-menu-item-delete-text: #FF5454; + --list-title-text: #18182A; + --select-option-text: #18182A; + --modal-select-text: #18182A; + --modal-title-text: #18182A; + --modal-content-text: #18182A; + + --btn-shadow: 0px 4px 10px 0px rgba(60, 68, 255, 0.14); + --chat-actions-popover-mobile-shadow: 0px 14px 40px 0px rgba(0, 0, 0, 0.12); + --chat-input-hood-focus-shadow: 0px 4px 20px 0px rgba(60, 68, 255, 0.13); + --select-popover-shadow: 0px 14px 40px 0px rgba(0, 0, 0, 0.12); + --message-actions-bar-shadow: 0px 4px 30px 0px rgba(0, 0, 0, 0.1); + --prompt-hint-container-shadow: inset 0 4px 8px 0 rgba(0, 0, 0, 0.1); + --delete-chat-popover-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.08), + 0px 8px 20px 0px rgba(0, 0, 0, 0.08); + --sidebar-btn-shadow: 4px 8px 16px 0px rgba(60, 68, 255, 0.2); + + --default-icon-color: #606078; +} + +.dark-new { + --global-bg: #303030; + --actions-bar-btn-default-bg: #384cfc; + --primary-btn-bg: #384cfc; + --primary-btn-disabled-bg: rgba(60, 68, 255, 0.2); + --danger-btn-bg: #20131A; + --default-btn-bg: #1D1D1D; + --hovered-btn-bg: #303030; + --hovered-danger-btn-bg:#303030; + --card-bg: #111; + --input-bg: #1D1D1D; + --list-item-divider-bg: #303030; + --menu-bg: #1D1D1D; + --select-option-hovered-bg: rgba(255, 255, 255, 0.05); + --select-popover-panel-bg: #1D1D1D; + --select-bg: #1D1D1D; + --slider-bg: #303030; + --slider-slided-travel-bg: #B0B0B0; + --slider-block-bg: #111; + --switch-unchecked-bg: #303030; + --switch-checked-bg: #384CFC; + --switch-btn-bg: #111; + --chat-actions-popover-panel-mobile-bg: #111; + --chat-actions-btn-popover-bg: #F0F0F0; + --chat-actions-btn-hovered-bg: rgba(255, 255, 255, 0.05); + --chat-panel-header-mask-bg: #1D1D1D; + --chat-panel-header-mobile-bg: #111; + --chat-panel-input-hood-bg: #111; + --chat-panel-message-user-bg: #4a5cff; + --chat-panel-message-bot-bg: #111; + --chat-panel-message-bg: #1D1D1D; + --chat-panel-message-mobile-bg: #303030; + --chat-message-actions-btn-hovered-bg: rgba(255, 255, 255, 0.05); + --chat-panel-bg: #1D1D1D; + --chat-panel-message-clear-divider-bg: #a5a5b3; + --chat-menu-session-selected-bg: #182455; + --chat-menu-session-unselected-bg: #303030; + --chat-menu-session-hovered-bg: #404040; + --settings-menu-mobile-bg: #1D1D1D; + --settings-menu-item-mobile-bg: #111; + --settings-menu-item-selected-bg: #182455; + --settings-header-mobile-bg: #111; + --settings-panel-bg: #1D1D1D; + --sidebar-mobile-bg: #111; + --sidebar-btn-hovered-bg: rgba(255, 255, 255, 0.05); + --delete-chat-popover-panel-bg: #111; + --modal-mask-bg: rgba(0, 0, 0, 0.85); + --modal-panel-bg: #111; + --delete-chat-ok-btn-bg: #F55151; + --delete-chat-cancel-btn-bg: #404040; + --chat-message-actions-bg: rgba(255, 255, 255, 0); + --menu-dragger-bg: #2E42F3; /////////// + --chat-actions-select-model-bg: rgba(255, 255, 255, 0.05); + --chat-actions-select-model-hover-bg: rgba(255, 255, 255, 0.1); + --select-hover-bg: #303030; + --input-input-ele-hover-bg: rgba(0, 0, 0, 0); + --chat-menu-session-unselected-mobile-bg:#111; + --chat-menu-session-selected-mobile-bg: #1D1D1D; + --global-mobile-bg: #303030; + --model-select-popover-panel-bg: #111; + + --select-popover-border: rgba(255, 255, 255, 0.05); + --slider-block-border: #6C6C6C; + --thumbnail-border: rgba(255, 255, 255, 0.05); + --chat-actions-popover-mobile-border: #303030; + --chat-header-bottom-border: #303030; + --chat-input-top-border: #303030; + --chat-input-hood-border: rgba(0,0,0,0); + --chat-input-hood-focus-border: #E3E3E3; + --chat-menu-session-selected-border: #1C2E70; + --settings-menu-item-selected-border: #1C2E70; + --settings-header-border: #404040; + --delete-chat-popover-border: rgba(255, 255, 255, 0.1); + --delete-chat-cancel-btn-border: #303030; + --chat-menu-session-unselected-border: #303030; + --chat-menu-session-hovered-border: #1D1D1D; + --modal-header-bottom-border: #303030; + --chat-menu-session-unselected-mobile-border: rgba(0, 0, 0, 0); + --chat-menu-session-selected-mobile-border: rgba(0, 0, 0, 0); + + --sidebar-tab-mobile-active-text: #384CFC; + --sidebar-tab-mobile-inactive-text: #a5a5b3; + --btn-primary-text: #fff; + --btn-danger-text: #ff5454; + --btn-default-text: #E3E3E3; + --card-title-text: #FAFAFA; + --input-text: #FAFAFA; + --select-text: #FAFAFA; + --list-subtitle-text: #a5a5b3; + --slider-block-text: #E3E3E3; + --chat-actions-btn-popover-text: #111; + --chat-header-title-text: #FAFAFA; + --chat-header-subtitle-text: #B0B0B0; + --chat-input-placeholder-text: #B0B0B0; + --chat-message-date-text: #B0B0B0; + --chat-message-markdown-user-text: #FAFAFA; + --chat-message-markdown-bot-text: #FAFAFA; + --chat-panel-message-clear-text: #a5a5b3; ////////////// + --chat-panel-message-clear-revert-text: #3c44ff; /////////////// + --chat-menu-item-title-text: #fff; + --chat-menu-item-time-text: rgba(255, 255, 255, 0.30); + --chat-menu-item-description-text: #B0B0B0; + --settings-menu-title-text: #FAFAFA; + --settings-menu-item-title-text: #FAFAFA; + --settings-panel-header-title-text: #FAFAFA; + --modal-panel-text: #FAFAFA; + --delete-chat-ok-btn-text: #fff; + --delete-chat-cancel-btn-text: #FAFAFA; + --chat-menu-item-delete-text: #F55151; + --list-title-text: #FAFAFA; + --select-option-text: #FAFAFA; + --modal-select-text: #E3E3E3; + --modal-title-text: #FAFAFA; + --modal-content-text: #FAFAFA; + + --btn-shadow: 0px 4px 20px 0px rgba(60, 68, 255, 0.13); + --chat-actions-popover-mobile-shadow: 0px 14px 40px 0px rgba(0, 0, 0, 0.12); + --chat-input-hood-focus-shadow: 0px 4px 20px 0px rgba(60, 68, 255, 0.13); + --select-popover-shadow: 0px 8px 30px 0px rgba(0, 0, 0, 0.10); + --message-actions-bar-shadow: 0px 4px 30px 0px rgba(0, 0, 0, 0); + --prompt-hint-container-shadow: inset 0 4px 8px 0 rgba(0, 0, 0, 0.1); + --delete-chat-popover-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.08), 0px 8px 20px 0px rgba(0, 0, 0, 0.08); + --sidebar-btn-shadow: 4px 8px 16px 0px rgba(60, 68, 255, 0.20); + + --default-icon-color: #E3E3E3; +} \ No newline at end of file diff --git a/app/styles/globals.css b/app/styles/globals.css new file mode 100644 index 00000000000..b86d34204c6 --- /dev/null +++ b/app/styles/globals.css @@ -0,0 +1,21 @@ +/* prettier-ignore */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + @font-face { + font-family: "Satoshi Variable"; + font-style: normal; + font-weight: 400; + font-display: swap; + font-feature-settings: + "clig" off, + "liga" off; + src: + url("../fonts/Satoshi-Variable.woff2") format("woff2"), + url("../fonts/Satoshi-Variable.woff") format("woff"), + url("../fonts/Satoshi-Variable.ttf") format("truetype"); + } +} diff --git a/app/styles/globals.scss b/app/styles/globals.scss index 20792cda526..56db01b1680 100644 --- a/app/styles/globals.scss +++ b/app/styles/globals.scss @@ -38,10 +38,6 @@ --border-in-light: 1px solid rgba(255, 255, 255, 0.192); --theme-color: var(--gray); - - div:not(.no-dark) > svg { - filter: invert(0.5); - } } .light { @@ -149,10 +145,10 @@ label { cursor: pointer; } -input { - text-align: center; - font-family: inherit; -} +// input { +// text-align: center; +// font-family: inherit; +// } input[type="checkbox"] { cursor: pointer; @@ -224,20 +220,20 @@ input[type="range"]::-ms-thumb:hover { @include thumbHover(); } -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; -} +// 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; +// } div.math { overflow-x: auto; diff --git a/app/utils.ts b/app/utils.ts index 8f7adc7e2a2..062a041068f 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import dayjs from "dayjs"; import { showToast } from "./components/ui-lib"; import Locale from "./locales"; import { RequestMessage } from "./client/api"; @@ -264,3 +265,12 @@ export function isVisionModel(model: string) { visionKeywords.some((keyword) => model.includes(keyword)) || isGpt4Turbo ); } + +export function getTime(dateTime: string) { + const time = dayjs(dateTime); + const now = dayjs(); + if (time.isBefore(now, "date")) { + return time.format("MM-DD"); + } + return time.format("hh:mm"); +} diff --git a/app/utils/client.ts b/app/utils/client.ts new file mode 100644 index 00000000000..43daec4982a --- /dev/null +++ b/app/utils/client.ts @@ -0,0 +1,40 @@ +import { + MAX_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + SIDEBAR_ID, + WINDOW_WIDTH_MD, +} from "@/app/constant"; + +export function updateGlobalCSSVars(nextSidebar: number) { + const windowSize = window.innerWidth; + const inMobile = windowSize <= WINDOW_WIDTH_MD; + + nextSidebar = Math.max( + MIN_SIDEBAR_WIDTH, + Math.min(MAX_SIDEBAR_WIDTH, nextSidebar), + ); + + const menuWidth = inMobile ? 0 : nextSidebar; + const navigateBarWidth = inMobile + ? 0 + : document.querySelector(`#${SIDEBAR_ID}`)?.clientWidth ?? 0; + const chatPanelWidth = windowSize - navigateBarWidth - menuWidth; + + document.documentElement.style.setProperty("--menu-width", `${menuWidth}px`); + document.documentElement.style.setProperty( + "--navigate-bar-width", + `${navigateBarWidth}px`, + ); + document.documentElement.style.setProperty( + "--chat-panel-max-width", + `${chatPanelWidth}px`, + ); + + return { menuWidth }; +} + +let count = 0; + +export function getUid() { + return count++; +} diff --git a/app/utils/hooks.ts b/app/utils/hooks.ts index 55d5d4fca7d..c5927ee1488 100644 --- a/app/utils/hooks.ts +++ b/app/utils/hooks.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useAccessStore, useAppConfig } from "../store"; -import { collectModels, collectModelsWithDefaultModel } from "./model"; +import { collectModelsWithDefaultModel } from "./model"; export function useAllModels() { const accessStore = useAccessStore(); diff --git a/next.config.mjs b/next.config.mjs index daaeba46865..b49ba1cf36a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,7 +11,9 @@ const nextConfig = { webpack(config) { config.module.rules.push({ test: /\.svg$/, - use: ["@svgr/webpack"], + use: [ + "@svgr/webpack", + ], }); if (disableChunk) { diff --git a/package.json b/package.json index 4d06b0b14e4..f1d30fce845 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,20 @@ "@fortaine/fetch-event-source": "^3.0.6", "@hello-pangea/dnd": "^16.5.0", "@next/third-parties": "^14.1.0", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-switch": "^1.0.3", "@svgr/webpack": "^6.5.1", "@vercel/analytics": "^0.1.11", "@vercel/speed-insights": "^1.0.2", + "classnames": "^2.5.1", + "dayjs": "^1.11.10", "emoji-picker-react": "^4.9.2", "fuse.js": "^7.0.0", "heic2any": "^0.0.4", "html-to-image": "^1.11.11", + "install": "^0.13.0", + "lodash-es": "^4.17.21", "mermaid": "^10.6.1", "nanoid": "^5.0.3", "next": "^14.1.1", @@ -42,15 +49,18 @@ "sass": "^1.59.2", "spark-md5": "^3.0.2", "use-debounce": "^9.0.4", + "usehooks-ts": "^3.1.0", "zustand": "^4.3.8" }, "devDependencies": { "@tauri-apps/cli": "1.5.11", + "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.30", "@types/react": "^18.2.70", "@types/react-dom": "^18.2.7", "@types/react-katex": "^3.0.0", "@types/spark-md5": "^3.0.4", + "autoprefixer": "^10.4.19", "cross-env": "^7.0.3", "eslint": "^8.49.0", "eslint-config-next": "13.4.19", @@ -58,8 +68,11 @@ "eslint-plugin-prettier": "^5.1.3", "husky": "^8.0.0", "lint-staged": "^13.2.2", + "postcss": "^8.4.38", "prettier": "^3.0.2", + "tailwindcss": "^3.4.3", "typescript": "5.2.2", + "url-loader": "^4.1.1", "webpack": "^5.88.1" }, "resolutions": { diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000000..33ad091d26d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ee87d8d1540..43bcfe032da 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,7 +9,7 @@ }, "package": { "productName": "NextChat", - "version": "2.12.3" + "version": "3.0.1" }, "tauri": { "allowlist": { @@ -50,6 +50,10 @@ }, "notification": { "all": true + }, + "http": { + "all": true, + "request": true } }, "bundle": { diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 00000000000..3102900a336 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,259 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + screens: { + sm: '480px', + md: '768px', + lg: '1120px', + xl: '1440px', + '2xl': '1980px' + }, + fontSize: { + sm: '0.75rem', + 'sm-mobile': '0.875rem', + 'sm-title': '0.875rem', + 'sm-mobile-tab': '0.625rem', + 'chat-header-title': '1rem', + 'actions-popover-menu-item': '15px', + 'setting-title': '1.25rem', + 'setting-items': '1rem', + }, + fontFamily: { + 'common': ['Satoshi Variable', "SF Pro Text", "SF Pro Icons", "Helvetica Neue", 'Helvetica', 'Arial', 'sans-serif'], + 'time': ['Hind', "SF Pro Text", "SF Pro Icons", "Helvetica Neue", 'Helvetica', 'Arial', 'sans-serif'], + 'setting-card-title': ['PingFang HK', 'PingFang', "SF Pro Text", "SF Pro Icons", "Helvetica Neue", 'Helvetica', 'Arial', 'sans-serif'] + }, + extend: { + lineHeight: { + 'slide-btn': "17px", + 'input': '22px', + }, + backdropBlur: { + 'chat-header': '20px', + }, + minHeight: { + 'chat-input-mobile': '19px', + 'chat-input': '60px', + }, + height: { + mobile: 'var(--siderbar-mobile-height)', + // mobile: '3.125rem', + 'menu-title-mobile': '3rem', + 'thumbnail': '5rem', + 'chat-input-mobile': '19px', + 'chat-input': '60px', + 'chat-panel-mobile': '- var(--siderbar-mobile-height)', + 'setting-panel-mobile': 'calc(100vh - var(--siderbar-mobile-height))', + 'slide-btn': '18px', + 'switch': '1rem', + 'chat-header-title-mobile': '19px', + 'model-bottom-drawer': 'calc(100vh - 110px)', + }, + minWidth: { + 'select-mobile-lg': '200px', + 'select-mobile': '170px', + 'select': '240px', + 'slide-range-mobile-lg': '200px', + 'slide-range-mobile': '170px', + 'slide-range': '240px', + }, + width: { + 'md': '15rem', + 'lg': '21.25rem', + '2xl': '27.5rem', + 'page': 'calc(100% - var(--menu-width))', + 'thumbnail': '5rem', + 'actions-popover': '203px', + 'switch': '2rem', + 'modal-modal-type': '26.25rem', + 'modal-modal-type-mobile': 'calc(100vw - 2 * 44px)', + }, + flexBasis: { + 'sidebar': 'var(--menu-width)', + 'page': 'calc(100% - var(--menu-width))', + 'message-width': 'var(--max-message-width)', + }, + spacing: { + 'chat-header-gap': '0.625rem', + 'chat-panel-mobile': 'var(--siderbar-mobile-height)', + 'message-img': 'calc((100%- var(--img-gap-count)*0.25rem)/var(--img-count))', + }, + backgroundColor: { + 'global': 'var(--global-bg)', + 'global-mobile': 'var(--global-mobile-bg)', + 'actions-bar-btn-default': 'var(--actions-bar-btn-default-bg)', + 'primary-btn': 'var(--primary-btn-bg)', + 'primary-btn-disabled': 'var(--primary-btn-disabled-bg)', + 'danger-btn': 'var(--danger-btn-bg)', + 'default-btn': 'var(--default-btn-bg)', + 'hovered-btn': 'var(--hovered-btn-bg)', + 'hovered-danger-btn': 'var(--hovered-danger-btn-bg)', + 'card': 'var(--card-bg)', + 'input': 'var(--input-bg)', + 'list-item-divider': 'var(--list-item-divider-bg)', + 'menu': 'var(--menu-bg)', + 'select-option-hovered': 'var(--select-option-hovered-bg)', + 'select-popover-panel': 'var(--select-popover-panel-bg)', + 'select': 'var(--select-bg)', + 'slider': 'var(--slider-bg)', + 'slider-slided-travel': 'var(--slider-slided-travel-bg)', + 'slider-block': 'var(--slider-block-bg)', + 'slider-block-hover': 'var(--slider-block-hover-bg)', + 'switch-unchecked': 'var(--switch-unchecked-bg)', + 'switch-checked': 'var(--switch-checked-bg)', + 'switch-btn': 'var(--switch-btn-bg)', + 'chat-actions-popover-panel-mobile': 'var(--chat-actions-popover-panel-mobile-bg)', + 'chat-actions-btn-popover': 'var(--chat-actions-btn-popover-bg)', + 'chat-actions-btn-hovered': 'var(--chat-actions-btn-hovered-bg)', + 'chat-panel-header-mask': 'var(--chat-panel-header-mask-bg)', + 'chat-panel-header-mobile': 'var(--chat-panel-header-mobile-bg)', + 'chat-panel-input-hood': 'var(--chat-panel-input-hood-bg)', + 'chat-panel-message-user': 'var(--chat-panel-message-user-bg)', + 'chat-panel-message-bot': 'var(--chat-panel-message-bot-bg)', + 'chat-panel-message': 'var(--chat-panel-message-bg)', + 'chat-panel-message-mobile': 'var(--chat-panel-message-mobile-bg)', + 'chat-message-actions': 'var(--chat-message-actions-bg)', + 'chat-message-actions-btn-hovered': 'var(--chat-message-actions-btn-hovered-bg)', + 'chat-panel': 'var(--chat-panel-bg)', + 'chat-panel-message-clear-divider': 'var(--chat-panel-message-clear-divider-bg)', + 'chat-menu-session-selected': 'var(--chat-menu-session-selected-bg)', + 'chat-menu-session-selected-mobile': 'var(--chat-menu-session-selected-mobile-bg)', + 'chat-menu-session-unselected': 'var(--chat-menu-session-unselected-bg)', + 'chat-menu-session-unselected-mobile': 'var(--chat-menu-session-unselected-mobile-bg)', + 'chat-menu-session-hovered': 'var(--chat-menu-session-hovered-bg)', + 'settings-menu-mobile': 'var(--settings-menu-mobile-bg)', + 'settings-menu-item-mobile': 'var(--settings-menu-item-mobile-bg)', + 'settings-menu-item-selected': 'var(--settings-menu-item-selected-bg)', + 'settings-header-mobile': 'var(--settings-header-mobile-bg)', + 'settings-panel': 'var(--settings-panel-bg)', + 'sidebar-mobile': 'var(--sidebar-mobile-bg)', + 'sidebar-btn-hovered': 'var(--sidebar-btn-hovered-bg)', + 'delete-chat-popover-panel': 'var(--delete-chat-popover-panel-bg)', + 'modal-mask': 'var(--modal-mask-bg)', + 'moda-panel': 'var(--modal-panel-bg)', + 'delete-chat-ok-btn': 'var(--delete-chat-ok-btn-bg)', + 'delete-chat-cancel-btn': 'var(--delete-chat-cancel-btn-bg)', + 'menu-dragger': 'var(--menu-dragger-bg)', + 'chat-actions-select-model': 'var(--chat-actions-select-model-bg)', + 'chat-actions-select-model-hover': 'var(--chat-actions-select-model-hover-bg)', + 'select-hover': 'var(--select-hover-bg)', + 'input-input-ele-hover': 'var(--input-input-ele-hover-bg)', + 'model-select-popover-panel': 'var(--model-select-popover-panel-bg)', + }, + backgroundImage: { + // 'chat-panel-message-user': 'linear-gradient(259deg, #9786FF 8.42%, #4A5CFF 90.13%)', + 'thumbnail-mask': 'linear-gradient(0deg, rgba(0, 0, 0, 0.50) 0%, rgba(0, 0, 0, 0.50) 100%)', + }, + transitionProperty: { + 'time': 'all ease 0.6s', + 'message': 'all ease 0.3s', + }, + maxHeight: { + 'chat-actions-select-model-popover': '340px', + }, + maxWidth: { + 'message-width': 'var(--max-message-width)', + 'chat-actions-select-model': '282px', + }, + boxShadow: { + 'btn': 'var(--btn-shadow)', + 'chat-actions-popover-mobile': 'var(--chat-actions-popover-mobile-shadow)', + 'chat-input-hood-focus-shadow': 'var(--chat-input-hood-focus-shadow)', + 'select-popover-shadow': 'var(--select-popover-shadow)', + 'message-actions-bar': 'var(--message-actions-bar-shadow)', + 'prompt-hint-container': 'var(--prompt-hint-container-shadow)', + 'delete-chat-popover-shadow': 'var(--delete-chat-popover-shadow)', + 'sidebar-btn-shadow': 'var(--sidebar-btn-shadow)', + }, + colors: { + 'select-popover': 'var(--select-popover-border)', + 'slider-block': 'var(--slider-block-border)', + 'thumbnail': 'var(--thumbnail-border)', + 'chat-actions-popover-mobile': 'var(--chat-actions-popover-mobile-border)', + 'chat-header-bottom': 'var(--chat-header-bottom-border)', + 'chat-input-top': 'var(--chat-input-top-border)', + 'chat-input-hood': 'var(--chat-input-hood-border)', + 'chat-input-hood-focus': 'var(--chat-input-hood-focus-border)', + 'settings-menu-item-selected': 'var(--settings-menu-item-selected-border)', + 'settings-header': 'var(--settings-header-border)', + 'delete-chat-popover': 'var(--delete-chat-popover-border)', + 'delete-chat-cancel-btn': 'var(--delete-chat-cancel-btn-border)', + 'chat-menu-session-selected': 'var(--chat-menu-session-selected-border)', + 'chat-menu-session-selected-mobile': 'var(--chat-menu-session-selected-mobile-border)', + 'chat-menu-session-unselected': 'var(--chat-menu-session-unselected-border)', + 'chat-menu-session-unselected-mobile': 'var(--chat-menu-session-unselected-mobile-border)', + 'chat-menu-session-hovered': 'var(--chat-menu-session-hovered-border)', + 'modal-header-bottom': 'var(--modal-header-bottom-border)', + 'transparent': 'transparent', + + 'text-sidebar-tab-mobile-active': 'var(--sidebar-tab-mobile-active-text)', + 'text-sidebar-tab-mobile-inactive': 'var(--sidebar-tab-mobile-inactive-text)', + 'text-btn-primary': 'var(--btn-primary-text)', + 'text-btn-danger': 'var(--btn-danger-text)', + 'text-btn-default': 'var(--btn-default-text)', + 'text-card-title': 'var(--card-title-text)', + 'text-input': 'var(--input-text)', + 'text-select': 'var(--select-text)', + 'text-select-option': 'var(--select-option-text)', + 'text-modal-select': 'var(--modal-select-text)', + 'text-list-title': 'var(--list-title-text)', + 'text-list-subtitle': 'var(--list-subtitle-text)', + 'text-slider-block': 'var(--slider-block-text)', + 'text-chat-actions-btn-popover': 'var(--chat-actions-btn-popover-text)', + 'text-chat-header-title': 'var(--chat-header-title-text)', + 'text-chat-header-subtitle': 'var(--chat-header-subtitle-text)', + 'text-chat-input-placeholder': 'var(--chat-input-placeholder-text)', + 'text-chat-message-date': 'var(--chat-message-date-text)', + 'text-chat-message-markdown-user': 'var(--chat-message-markdown-user-text)', + 'text-chat-message-markdown-bot': 'var(--chat-message-markdown-bot-text)', + 'text-chat-panel-message-clear': 'var(--chat-panel-message-clear-text)', + 'text-chat-panel-message-clear-revert': 'var(--chat-panel-message-clear-revert-text)', + 'text-chat-menu-item-title': 'var(--chat-menu-item-title-text)', + 'text-chat-menu-item-time': 'var(--chat-menu-item-time-text)', + 'text-chat-menu-item-delete': 'var(--chat-menu-item-delete-text)', + 'text-chat-menu-item-description': 'var(--chat-menu-item-description-text)', + 'text-settings-menu-title': 'var(--settings-menu-title-text)', + 'text-settings-menu-item-title': 'var(--settings-menu-item-title-text)', + 'text-settings-panel-header-title': 'var(--settings-panel-header-title-text)', + 'text-modal-panel': 'var(--modal-panel-text)', + 'text-delete-chat-ok-btn': 'var(--delete-chat-ok-btn-text)', + 'text-delete-chat-cancel-btn': 'var(--delete-chat-cancel-btn-text)', + 'text-primary-btn-disabled-dark': 'var(--primary-btn-disabled-dark-text)', + 'text-modal-title': 'var(--modal-title-text)', + 'text-modal-content': 'var(--modal-content-text)', + }, + keyframes: { + mask: { + '0%': { opacity: 0 }, + '100%': { opacity: 1 }, + } + }, + animation: { + mask: 'mask 150ms cubic-bezier(0.16, 1, 0.3, 1)' + } + }, + borderRadius: { + 'none': '0', + 'sm': '0.125rem', + DEFAULT: '0.25rem', + 'md': '0.75rem', + 'lg': '1rem', + 'user-message': '16px 4px 16px 16px', + 'bot-message': '4px 16px 16px 16px', + 'action-btn': '0.5rem', + 'actions-bar-btn': '0.375rem', + 'chat-input': '0.5rem', + 'chat-img': '0.5rem', + 'slide': '0.625rem', + 'chat-model-select': '1.25rem', + }, + borderWidth: { + DEFAULT: '1px', + }, + }, + plugins: [], +} + diff --git a/yarn.lock b/yarn.lock index 72df8cafc54..95459d3db4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== +"@alloc/quick-lru@^5.2.0": + version "5.2.0" + resolved "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== + "@ampproject/remapping@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -1035,6 +1040,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.13.10": + version "7.24.4" + resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" + integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.20.7": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" @@ -1124,6 +1136,33 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== +"@floating-ui/core@^1.0.0": + version "1.6.0" + resolved "https://registry.npmmirror.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== + dependencies: + "@floating-ui/utils" "^0.2.1" + +"@floating-ui/dom@^1.6.1": + version "1.6.3" + resolved "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/react-dom@^2.0.0": + version "2.0.8" + resolved "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" + integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== + dependencies: + "@floating-ui/dom" "^1.6.1" + +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": + version "0.2.1" + resolved "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@fortaine/fetch-event-source@^3.0.6": version "3.0.6" resolved "https://registry.npmmirror.com/@fortaine/fetch-event-source/-/fetch-event-source-3.0.6.tgz#b8552a2ca2c5202f5699b93a92be0188d422b06e" @@ -1161,6 +1200,18 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1303,11 +1354,256 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + "@pkgr/core@^0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.0.tgz#7d8dacb7fdef0e4387caf7396cbd77f179867d06" integrity sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ== +"@radix-ui/primitive@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd" + integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-alert-dialog@^1.0.5": + version "1.0.5" + resolved "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.5.tgz#70dd529cbf1e4bff386814d3776901fcaa131b8c" + integrity sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dialog" "1.0.5" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + +"@radix-ui/react-arrow@1.0.3": + version "1.0.3" + resolved "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d" + integrity sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-primitive" "1.0.3" + +"@radix-ui/react-compose-refs@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" + integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-context@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" + integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-dialog@1.0.5": + version "1.0.5" + resolved "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300" + integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-controllable-state" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + +"@radix-ui/react-dismissable-layer@1.0.5": + version "1.0.5" + resolved "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4" + integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-escape-keydown" "1.0.3" + +"@radix-ui/react-focus-guards@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad" + integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-focus-scope@1.0.4": + version "1.0.4" + resolved "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525" + integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-hover-card@^1.0.7": + version "1.0.7" + resolved "https://registry.npmmirror.com/@radix-ui/react-hover-card/-/react-hover-card-1.0.7.tgz#684bca2504432566357e7157e087051aa3577948" + integrity sha512-OcUN2FU0YpmajD/qkph3XzMcK/NmSk9hGWnjV68p6QiZMgILugusgQwnLSDs3oFSJYGKf3Y49zgFedhGh04k9A== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + +"@radix-ui/react-id@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0" + integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-layout-effect" "1.0.1" + +"@radix-ui/react-popper@1.1.3": + version "1.1.3" + resolved "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42" + integrity sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w== + dependencies: + "@babel/runtime" "^7.13.10" + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + "@radix-ui/react-use-rect" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/rect" "1.0.1" + +"@radix-ui/react-portal@1.0.4": + version "1.0.4" + resolved "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15" + integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-primitive" "1.0.3" + +"@radix-ui/react-presence@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba" + integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + +"@radix-ui/react-primitive@1.0.3": + version "1.0.3" + resolved "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" + integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-slot" "1.0.2" + +"@radix-ui/react-slot@1.0.2": + version "1.0.2" + resolved "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" + integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + +"@radix-ui/react-switch@^1.0.3": + version "1.0.3" + resolved "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e" + integrity sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + +"@radix-ui/react-use-callback-ref@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a" + integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-controllable-state@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" + integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-use-escape-keydown@1.0.3": + version "1.0.3" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755" + integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-use-layout-effect@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399" + integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-previous@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66" + integrity sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-rect@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2" + integrity sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/rect" "1.0.1" + +"@radix-ui/react-use-size@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz#1c5f5fea940a7d7ade77694bb98116fb49f870b2" + integrity sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-layout-effect" "1.0.1" + +"@radix-ui/rect@1.0.1": + version "1.0.1" + resolved "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.0.1.tgz#bf8e7d947671996da2e30f4904ece343bc4a883f" + integrity sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@remix-run/router@1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.8.0.tgz#e848d2f669f601544df15ce2a313955e4bf0bafc" @@ -1582,6 +1878,18 @@ resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.14.0.tgz#b84c0afc3218069a5ad64fe2a95321881021b5fe" integrity sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA== +"@types/lodash-es@^4.17.12": + version "4.17.12" + resolved "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b" + integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.1" + resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.1.tgz#0fabfcf2f2127ef73b119d98452bd317c4a17eb8" + integrity sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q== + "@types/mdast@^3.0.0": version "3.0.11" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0" @@ -1914,11 +2222,16 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^6.0.0: +ansi-styles@^6.0.0, ansi-styles@^6.1.0: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== + anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" @@ -1927,11 +2240,23 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^5.0.2: + version "5.0.2" + resolved "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" + integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.1.1: + version "1.2.4" + resolved "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" + integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A== + dependencies: + tslib "^2.0.0" + aria-query@^5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" @@ -2004,6 +2329,18 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +autoprefixer@^10.4.19: + version "10.4.19" + resolved "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f" + integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew== + dependencies: + browserslist "^4.23.0" + caniuse-lite "^1.0.30001599" + fraction.js "^4.3.7" + normalize-range "^0.1.2" + picocolors "^1.0.0" + postcss-value-parser "^4.2.0" + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" @@ -2055,6 +2392,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.npmmirror.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -2073,6 +2415,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -2100,6 +2449,16 @@ browserslist@^4.21.3, browserslist@^4.21.5: node-releases "^2.0.8" update-browserslist-db "^1.0.10" +browserslist@^4.23.0: + version "4.23.0" + resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== + dependencies: + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -2125,6 +2484,11 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + camelcase@^6.2.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" @@ -2135,6 +2499,11 @@ caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.300015 resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb" integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA== +caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001599: + version "1.0.30001608" + resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001608.tgz#7ae6e92ffb300e4b4ec2f795e0abab456ec06cc0" + integrity sha512-cjUJTQkk9fQlJR2s4HMuPMvTiRggl0rAVMtthQuyOlDWuqHXqN8azLq+pi8B2TjwKJ32diHjUqRIKeFX4z1FoA== + ccount@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" @@ -2182,11 +2551,31 @@ character-entities@^2.0.0: optionalDependencies: fsevents "~2.3.2" +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + chrome-trace-event@^1.0.2: version "1.0.3" resolved "https://registry.npmmirror.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== +classnames@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -2269,6 +2658,11 @@ commander@^2.20.0: resolved "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^4.0.0: + version "4.1.1" + resolved "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + commander@^8.0.0, commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -2323,7 +2717,7 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" -cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -2363,6 +2757,11 @@ css-what@^6.0.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + csso@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" @@ -2686,6 +3085,11 @@ data-uri-to-buffer@^4.0.0: resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== +dayjs@^1.11.10: + version "1.11.10" + resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + dayjs@^1.11.7: version "1.11.7" resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2" @@ -2765,6 +3169,16 @@ dequal@^2.0.0: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + +didyoumean@^1.2.2: + version "1.2.2" + resolved "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" + integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== + diff@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" @@ -2777,6 +3191,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dlv@^1.1.3: + version "1.1.3" + resolved "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" + integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -2841,6 +3260,11 @@ electron-to-chromium@^1.4.431: resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.445.tgz#058d2c5f3a2981ab1a37440f5a5e42d15672aa6d" integrity sha512-++DB+9VK8SBJwC+X1zlMfJ1tMA3F0ipi39GdEp+x3cV2TyBihqAgad8cNMWtLDEkbH39nlDQP7PfGrDr3Dr7HA== +electron-to-chromium@^1.4.668: + version "1.4.730" + resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.730.tgz#5e382c83085b50b9c63cb08692e8fcd875c1b9eb" + integrity sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg== + elkjs@^0.8.2: version "0.8.2" resolved "https://registry.npmmirror.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e" @@ -2863,6 +3287,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + enhanced-resolve@^5.12.0: version "5.12.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" @@ -3275,6 +3704,17 @@ fast-glob@^3.2.11, fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.3.0: + version "3.3.2" + resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -3354,6 +3794,14 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +foreground-child@^3.1.0: + version "3.1.1" + resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" + integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" @@ -3366,6 +3814,11 @@ formdata-polyfill@^4.0.10: dependencies: fetch-blob "^3.1.2" +fraction.js@^4.3.7: + version "4.3.7" + resolved "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" + integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -3381,6 +3834,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + function.prototype.name@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" @@ -3415,6 +3873,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@ has "^1.0.3" has-symbols "^1.0.3" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" @@ -3464,6 +3927,17 @@ glob@7.1.7: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^10.3.10: + version "10.3.12" + resolved "https://registry.npmmirror.com/glob/-/glob-10.3.12.tgz#3a65c363c2e9998d220338e88a5f6ac97302960b" + integrity sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.3.6" + minimatch "^9.0.1" + minipass "^7.0.4" + path-scurry "^1.10.2" + glob@^7.1.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -3581,6 +4055,13 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + hast-util-from-dom@^4.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/hast-util-from-dom/-/hast-util-from-dom-4.2.0.tgz#25836ddecc3cc0849d32749c2a7aec03e94b59a7" @@ -3754,6 +4235,11 @@ inline-style-parser@0.1.1: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== +install@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/install/-/install-0.13.0.tgz#6af6e9da9dd0987de2ab420f78e60d9c17260776" + integrity sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA== + internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986" @@ -3773,6 +4259,13 @@ internmap@^1.0.0: resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.npmmirror.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -3834,6 +4327,13 @@ is-core-module@^2.11.0, is-core-module@^2.9.0: dependencies: has "^1.0.3" +is-core-module@^2.13.0: + version "2.13.1" + resolved "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + dependencies: + hasown "^2.0.0" + is-date-object@^1.0.1, is-date-object@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" @@ -3975,6 +4475,15 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +jackspeak@^2.3.6: + version "2.3.6" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jest-worker@^27.4.5: version "27.5.1" resolved "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" @@ -3984,6 +4493,11 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" +jiti@^1.21.0: + version "1.21.0" + resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.0.tgz#7c97f8fe045724e136a397f7340475244156105d" + integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4028,7 +4542,7 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" -json5@^2.2.2: +json5@^2.1.2, json5@^2.2.2: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -4095,11 +4609,16 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -lilconfig@2.1.0: +lilconfig@2.1.0, lilconfig@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== +lilconfig@^3.0.0: + version "3.1.1" + resolved "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.1.tgz#9d8a246fa753106cfc205fd2d77042faca56e5e3" + integrity sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ== + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -4143,6 +4662,15 @@ loader-runner@^4.2.0: resolved "https://registry.npmmirror.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== +loader-utils@^2.0.0: + version "2.0.4" + resolved "https://registry.npmmirror.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" @@ -4185,7 +4713,7 @@ longest-streak@^3.0.0: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4201,6 +4729,11 @@ lowlight@^2.0.0: fault "^2.0.0" highlight.js "~11.7.0" +lru-cache@^10.2.0: + version "10.2.0" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -4738,11 +5271,23 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^9.0.1: + version "9.0.4" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.4: + version "7.0.4" + resolved "https://registry.npmmirror.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" + integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== + mri@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" @@ -4758,7 +5303,16 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.6: +mz@^2.7.0: + version "2.7.0" + resolved "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nanoid@^3.3.6, nanoid@^3.3.7: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== @@ -4820,6 +5374,11 @@ node-releases@^2.0.12: resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.12.tgz#35627cc224a23bfb06fb3380f2b3afaaa7eb1039" integrity sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ== +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + node-releases@^2.0.8: version "2.0.10" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" @@ -4835,6 +5394,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== + npm-run-path@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.1.0.tgz#bc62f7f3f6952d9894bd08944ba011a6ee7b7e00" @@ -4849,11 +5413,16 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" -object-assign@^4.1.1: +object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== +object-hash@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" + integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== + object-inspect@^1.12.3, object-inspect@^1.9.0: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" @@ -5020,6 +5589,14 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.10.2: + version "1.10.2" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.10.2.tgz#8f6357eb1239d5fa1da8b9f70e9c080675458ba7" + integrity sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -5040,6 +5617,60 @@ pidtree@^0.6.0: resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pirates@^4.0.1: + version "4.0.6" + resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" + integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== + +postcss-import@^15.1.0: + version "15.1.0" + resolved "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" + integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-js@^4.0.1: + version "4.0.1" + resolved "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2" + integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw== + dependencies: + camelcase-css "^2.0.1" + +postcss-load-config@^4.0.1: + version "4.0.2" + resolved "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" + integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ== + dependencies: + lilconfig "^3.0.0" + yaml "^2.3.4" + +postcss-nested@^6.0.1: + version "6.0.1" + resolved "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.0.1.tgz#f83dc9846ca16d2f4fa864f16e9d9f7d0961662c" + integrity sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ== + dependencies: + postcss-selector-parser "^6.0.11" + +postcss-selector-parser@^6.0.11: + version "6.0.16" + resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz#3b88b9f5c5abd989ef4e2fc9ec8eedd34b20fb04" + integrity sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + postcss@8.4.31: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" @@ -5049,6 +5680,15 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.23, postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -5153,6 +5793,25 @@ react-redux@^8.1.3: react-is "^18.0.0" use-sync-external-store "^1.0.0" +react-remove-scroll-bar@^2.3.3: + version "2.3.6" + resolved "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c" + integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g== + dependencies: + react-style-singleton "^2.2.1" + tslib "^2.0.0" + +react-remove-scroll@2.5.5: + version "2.5.5" + resolved "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77" + integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw== + dependencies: + react-remove-scroll-bar "^2.3.3" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.0" + use-sidecar "^1.1.2" + react-router-dom@^6.15.0: version "6.15.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.15.0.tgz#6da7db61e56797266fbbef0d5e324d6ac443ee40" @@ -5168,6 +5827,15 @@ react-router@6.15.0: dependencies: "@remix-run/router" "1.8.0" +react-style-singleton@^2.2.1: + version "2.2.1" + resolved "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" + integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g== + dependencies: + get-nonce "^1.0.0" + invariant "^2.2.4" + tslib "^2.0.0" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" @@ -5175,6 +5843,13 @@ react@^18.2.0: dependencies: loose-envify "^1.1.0" +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -5317,6 +5992,15 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve@^1.1.7, resolve@^1.22.2: + version "1.22.8" + resolved "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^1.14.2, resolve@^1.22.1: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" @@ -5426,7 +6110,7 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -schema-utils@^3.1.1, schema-utils@^3.2.0: +schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" resolved "https://registry.npmmirror.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== @@ -5480,6 +6164,11 @@ signal-exit@^3.0.2, signal-exit@^3.0.7: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -5521,6 +6210,11 @@ slice-ansi@^5.0.0: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== +source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" @@ -5566,6 +6260,15 @@ string-argv@^0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -5575,7 +6278,7 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^5.0.0: +string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== @@ -5625,6 +6328,13 @@ string.prototype.trimstart@^1.0.6: define-properties "^1.1.4" es-abstract "^1.20.4" +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -5673,6 +6383,19 @@ stylis@^4.1.3: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== +sucrase@^3.32.0: + version "3.35.0" + resolved "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" + integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== + dependencies: + "@jridgewell/gen-mapping" "^0.3.2" + commander "^4.0.0" + glob "^10.3.10" + lines-and-columns "^1.1.6" + mz "^2.7.0" + pirates "^4.0.1" + ts-interface-checker "^0.1.9" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -5725,6 +6448,34 @@ synckit@^0.8.5, synckit@^0.8.6: "@pkgr/core" "^0.1.0" tslib "^2.6.2" +tailwindcss@^3.4.3: + version "3.4.3" + resolved "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.3.tgz#be48f5283df77dfced705451319a5dffb8621519" + integrity sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A== + dependencies: + "@alloc/quick-lru" "^5.2.0" + arg "^5.0.2" + chokidar "^3.5.3" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.3.0" + glob-parent "^6.0.2" + is-glob "^4.0.3" + jiti "^1.21.0" + lilconfig "^2.1.0" + micromatch "^4.0.5" + normalize-path "^3.0.0" + object-hash "^3.0.0" + picocolors "^1.0.0" + postcss "^8.4.23" + postcss-import "^15.1.0" + postcss-js "^4.0.1" + postcss-load-config "^4.0.1" + postcss-nested "^6.0.1" + postcss-selector-parser "^6.0.11" + resolve "^1.22.2" + sucrase "^3.32.0" + tapable@^2.1.1, tapable@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" @@ -5756,6 +6507,20 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + third-party-capital@1.0.20: version "1.0.20" resolved "https://registry.yarnpkg.com/third-party-capital/-/third-party-capital-1.0.20.tgz#e218a929a35bf4d2245da9addb8ab978d2f41685" @@ -5803,6 +6568,11 @@ ts-dedent@^2.2.0: resolved "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ== +ts-interface-checker@^0.1.9: + version "0.1.13" + resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" + integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== + tsconfig-paths@^3.14.1: version "3.14.2" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088" @@ -5813,16 +6583,16 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@^2.0.0, tslib@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tslib@^2.1.0, tslib@^2.4.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== -tslib@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" - integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -5980,6 +6750,14 @@ update-browserslist-db@^1.0.11: escalade "^3.1.1" picocolors "^1.0.0" +update-browserslist-db@^1.0.13: + version "1.0.13" + resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" + integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -5987,6 +6765,22 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-loader@^4.1.1: + version "4.1.1" + resolved "https://registry.npmmirror.com/url-loader/-/url-loader-4.1.1.tgz#28505e905cae158cf07c92ca622d7f237e70a4e2" + integrity sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA== + dependencies: + loader-utils "^2.0.0" + mime-types "^2.1.27" + schema-utils "^3.0.0" + +use-callback-ref@^1.3.0: + version "1.3.2" + resolved "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693" + integrity sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA== + dependencies: + tslib "^2.0.0" + use-debounce@^9.0.4: version "9.0.4" resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-9.0.4.tgz#51d25d856fbdfeb537553972ce3943b897f1ac85" @@ -5997,11 +6791,31 @@ use-memo-one@^1.1.3: resolved "https://registry.npmmirror.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== +use-sidecar@^1.1.2: + version "1.1.2" + resolved "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2" + integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== +usehooks-ts@^3.1.0: + version "3.1.0" + resolved "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca" + integrity sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw== + dependencies: + lodash.debounce "^4.0.8" + +util-deprecate@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + uuid@^9.0.0: version "9.0.0" resolved "https://registry.npmmirror.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" @@ -6141,6 +6955,15 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -6159,6 +6982,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -6184,6 +7016,11 @@ yaml@^2.2.2: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== +yaml@^2.3.4: + version "2.4.1" + resolved "https://registry.npmmirror.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" + integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"