Compare commits
5 Commits
v3
...
feat-multi
Author | SHA1 | Date | |
---|---|---|---|
|
f271e1654a | ||
|
f6d4ae6fc0 | ||
|
a902f1abac | ||
|
da0ca997c1 | ||
|
f871c67da7 |
@@ -47,17 +47,3 @@ ENABLE_BALANCE_QUERY=
|
||||
# If you want to disable parse settings from url, set this value to 1.
|
||||
DISABLE_FAST_LINK=
|
||||
|
||||
|
||||
# anthropic claude Api Key.(optional)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
### anthropic claude Api version. (optional)
|
||||
ANTHROPIC_API_VERSION=
|
||||
|
||||
|
||||
|
||||
### anthropic claude Api url (optional)
|
||||
ANTHROPIC_URL=
|
||||
|
||||
### (optional)
|
||||
WHITE_WEBDEV_ENDPOINTS=
|
@@ -1,12 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": [
|
||||
"prettier"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"legacyDecorators": true
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["globals.css"]
|
||||
"plugins": ["prettier"]
|
||||
}
|
||||
|
19
README.md
@@ -200,18 +200,6 @@ Google Gemini Pro Api Key.
|
||||
|
||||
Google Gemini Pro Api Url.
|
||||
|
||||
### `ANTHROPIC_API_KEY` (optional)
|
||||
|
||||
anthropic claude Api Key.
|
||||
|
||||
### `ANTHROPIC_API_VERSION` (optional)
|
||||
|
||||
anthropic claude Api version.
|
||||
|
||||
### `ANTHROPIC_URL` (optional)
|
||||
|
||||
anthropic claude Api Url.
|
||||
|
||||
### `HIDE_USER_API_KEY` (optional)
|
||||
|
||||
> Default: Empty
|
||||
@@ -245,13 +233,6 @@ To control custom models, use `+` to add a custom model, use `-` to hide a model
|
||||
|
||||
User `-all` to disable all default models, `+all` to enable all default models.
|
||||
|
||||
### `WHITE_WEBDEV_ENDPOINTS` (可选)
|
||||
|
||||
You can use this option if you want to increase the number of webdav service addresses you are allowed to access, as required by the format:
|
||||
- Each address must be a complete endpoint
|
||||
> `https://xxxx/yyy`
|
||||
- Multiple addresses are connected by ', '
|
||||
|
||||
## Requirements
|
||||
|
||||
NodeJS >= 18, Docker >= 20
|
||||
|
19
README_CN.md
@@ -114,18 +114,6 @@ Google Gemini Pro 密钥.
|
||||
|
||||
Google Gemini Pro Api Url.
|
||||
|
||||
### `ANTHROPIC_API_KEY` (optional)
|
||||
|
||||
anthropic claude Api Key.
|
||||
|
||||
### `ANTHROPIC_API_VERSION` (optional)
|
||||
|
||||
anthropic claude Api version.
|
||||
|
||||
### `ANTHROPIC_URL` (optional)
|
||||
|
||||
anthropic claude Api Url.
|
||||
|
||||
### `HIDE_USER_API_KEY` (可选)
|
||||
|
||||
如果你不想让用户自行填入 API Key,将此环境变量设置为 1 即可。
|
||||
@@ -142,13 +130,6 @@ anthropic claude Api Url.
|
||||
|
||||
如果你想禁用从链接解析预制设置,将此环境变量设置为 1 即可。
|
||||
|
||||
### `WHITE_WEBDEV_ENDPOINTS` (可选)
|
||||
|
||||
如果你想增加允许访问的webdav服务地址,可以使用该选项,格式要求:
|
||||
- 每一个地址必须是一个完整的 endpoint
|
||||
> `https://xxxx/xxx`
|
||||
- 多个地址以`,`相连
|
||||
|
||||
### `CUSTOM_MODELS` (可选)
|
||||
|
||||
> 示例:`+qwen-7b-chat,+glm-6b,-gpt-3.5-turbo,gpt-4-1106-preview=gpt-4-turbo` 表示增加 `qwen-7b-chat` 和 `glm-6b` 到模型列表,而从列表中删除 `gpt-3.5-turbo`,并将 `gpt-4-1106-preview` 模型名字展示为 `gpt-4-turbo`。
|
||||
|
@@ -13,7 +13,6 @@ const DANGER_CONFIG = {
|
||||
hideBalanceQuery: serverConfig.hideBalanceQuery,
|
||||
disableFastLink: serverConfig.disableFastLink,
|
||||
customModels: serverConfig.customModels,
|
||||
defaultModel: serverConfig.defaultModel,
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
@@ -1,14 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant";
|
||||
import { getServerSideConfig } from "@/app/config/server";
|
||||
|
||||
const config = getServerSideConfig();
|
||||
|
||||
const mergedWhiteWebDavEndpoints = [
|
||||
...internalWhiteWebDavEndpoints,
|
||||
...config.whiteWebDevEndpoints,
|
||||
].filter((domain) => Boolean(domain.trim()));
|
||||
|
||||
import { STORAGE_KEY } from "../../../constant";
|
||||
async function handle(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } },
|
||||
@@ -23,9 +14,7 @@ async function handle(
|
||||
let endpoint = requestUrl.searchParams.get("endpoint");
|
||||
|
||||
// Validate the endpoint to prevent potential SSRF attacks
|
||||
if (
|
||||
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white))
|
||||
) {
|
||||
if (!endpoint || !endpoint.startsWith("/")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -36,13 +25,8 @@ async function handle(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!endpoint?.endsWith("/")) {
|
||||
endpoint += "/";
|
||||
}
|
||||
|
||||
const endpointPath = params.path.join("/");
|
||||
const targetPath = `${endpoint}${endpointPath}`;
|
||||
const targetPath = `${endpoint}/${endpointPath}`;
|
||||
|
||||
// only allow MKCOL, GET, PUT
|
||||
if (req.method !== "MKCOL" && req.method !== "GET" && req.method !== "PUT") {
|
||||
@@ -58,7 +42,10 @@ async function handle(
|
||||
}
|
||||
|
||||
// for MKCOL request, only allow request ${folder}
|
||||
if (req.method === "MKCOL" && !targetPath.endsWith(folder)) {
|
||||
if (
|
||||
req.method === "MKCOL" &&
|
||||
!targetPath.endsWith(folder)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -71,7 +58,10 @@ async function handle(
|
||||
}
|
||||
|
||||
// for GET request, only allow request ending with fileName
|
||||
if (req.method === "GET" && !targetPath.endsWith(fileName)) {
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
!targetPath.endsWith(fileName)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -84,7 +74,10 @@ async function handle(
|
||||
}
|
||||
|
||||
// for PUT request, only allow request ending with fileName
|
||||
if (req.method === "PUT" && !targetPath.endsWith(fileName)) {
|
||||
if (
|
||||
req.method === "PUT" &&
|
||||
!targetPath.endsWith(fileName)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: true,
|
||||
@@ -96,7 +89,7 @@ async function handle(
|
||||
);
|
||||
}
|
||||
|
||||
const targetUrl = targetPath;
|
||||
const targetUrl = `${endpoint}/${endpointPath}`;
|
||||
|
||||
const method = req.method;
|
||||
const shouldNotHaveBody = ["get", "head"].includes(
|
||||
@@ -108,34 +101,23 @@ async function handle(
|
||||
authorization: req.headers.get("authorization") ?? "",
|
||||
},
|
||||
body: shouldNotHaveBody ? null : req.body,
|
||||
redirect: "manual",
|
||||
redirect: 'manual',
|
||||
method,
|
||||
// @ts-ignore
|
||||
duplex: "half",
|
||||
};
|
||||
|
||||
let fetchResult;
|
||||
const fetchResult = await fetch(targetUrl, fetchOptions);
|
||||
|
||||
try {
|
||||
fetchResult = await fetch(targetUrl, fetchOptions);
|
||||
} finally {
|
||||
console.log(
|
||||
"[Any Proxy]",
|
||||
targetUrl,
|
||||
{
|
||||
method: req.method,
|
||||
},
|
||||
{
|
||||
status: fetchResult?.status,
|
||||
statusText: fetchResult?.statusText,
|
||||
},
|
||||
);
|
||||
}
|
||||
console.log("[Any Proxy]", targetUrl, {
|
||||
status: fetchResult.status,
|
||||
statusText: fetchResult.statusText,
|
||||
});
|
||||
|
||||
return fetchResult;
|
||||
}
|
||||
|
||||
export const PUT = handle;
|
||||
export const POST = handle;
|
||||
export const GET = handle;
|
||||
export const OPTIONS = handle;
|
||||
|
||||
|
@@ -348,11 +348,7 @@ export class ClaudeApi implements LLMApi {
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl: string = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.anthropicUrl;
|
||||
}
|
||||
let baseUrl: string = accessStore.anthropicUrl;
|
||||
|
||||
// if endpoint is empty, use default endpoint
|
||||
if (baseUrl.trim().length === 0) {
|
||||
|
@@ -104,13 +104,7 @@ export class GeminiProApi implements LLMApi {
|
||||
};
|
||||
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
baseUrl = accessStore.googleUrl;
|
||||
}
|
||||
|
||||
let baseUrl = accessStore.googleUrl;
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
|
||||
let shouldStream = !!options.config.stream;
|
||||
@@ -118,8 +112,8 @@ export class GeminiProApi implements LLMApi {
|
||||
options.onController?.(controller);
|
||||
try {
|
||||
let googleChatPath = visionModel
|
||||
? Google.VisionChatPath(modelConfig.model)
|
||||
: Google.ChatPath(modelConfig.model);
|
||||
? Google.VisionChatPath
|
||||
: Google.ChatPath;
|
||||
let chatPath = this.path(googleChatPath);
|
||||
|
||||
// let baseUrl = accessStore.googleUrl;
|
||||
|
@@ -40,44 +40,22 @@ export interface OpenAIListModelResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class ChatGPTApi implements LLMApi {
|
||||
private disableListModels = true;
|
||||
|
||||
path(path: string): string {
|
||||
const accessStore = useAccessStore.getState();
|
||||
|
||||
let baseUrl = "";
|
||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||
|
||||
if (accessStore.useCustomConfig) {
|
||||
const isAzure = accessStore.provider === ServiceProvider.Azure;
|
||||
|
||||
if (isAzure && !accessStore.isValidAzure()) {
|
||||
throw Error(
|
||||
"incomplete azure config, please check it in your settings page",
|
||||
);
|
||||
}
|
||||
|
||||
if (isAzure) {
|
||||
path = makeAzurePath(path, accessStore.azureApiVersion);
|
||||
}
|
||||
|
||||
baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||
if (isAzure && !accessStore.isValidAzure()) {
|
||||
throw Error(
|
||||
"incomplete azure config, please check it in your settings page",
|
||||
);
|
||||
}
|
||||
|
||||
let baseUrl = isAzure ? accessStore.azureUrl : accessStore.openaiUrl;
|
||||
|
||||
if (baseUrl.length === 0) {
|
||||
const isApp = !!getClientConfig()?.isApp;
|
||||
baseUrl = isApp
|
||||
@@ -92,6 +70,10 @@ export class ChatGPTApi implements LLMApi {
|
||||
baseUrl = "https://" + baseUrl;
|
||||
}
|
||||
|
||||
if (isAzure) {
|
||||
path = makeAzurePath(path, accessStore.azureApiVersion);
|
||||
}
|
||||
|
||||
console.log("[Proxy Endpoint] ", baseUrl, path);
|
||||
|
||||
return [baseUrl, path].join("/");
|
||||
@@ -116,7 +98,7 @@ export class ChatGPTApi implements LLMApi {
|
||||
},
|
||||
};
|
||||
|
||||
const requestPayload: RequestPayload = {
|
||||
const requestPayload = {
|
||||
messages,
|
||||
stream: options.config.stream,
|
||||
model: modelConfig.model,
|
||||
@@ -130,7 +112,12 @@ export class ChatGPTApi implements LLMApi {
|
||||
|
||||
// add max_tokens to vision model
|
||||
if (visionModel) {
|
||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||
Object.defineProperty(requestPayload, "max_tokens", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: modelConfig.max_tokens,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[Request] openai payload: ", requestPayload);
|
||||
@@ -242,9 +229,7 @@ export class ChatGPTApi implements LLMApi {
|
||||
const text = msg.data;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
const choices = json.choices as Array<{
|
||||
delta: { content: string };
|
||||
}>;
|
||||
const choices = json.choices as Array<{ delta: { content: string } }>;
|
||||
const delta = choices[0]?.delta?.content;
|
||||
const textmoderation = json?.prompt_filter_results;
|
||||
|
||||
@@ -252,17 +237,9 @@ export class ChatGPTApi implements LLMApi {
|
||||
remainText += delta;
|
||||
}
|
||||
|
||||
if (
|
||||
textmoderation &&
|
||||
textmoderation.length > 0 &&
|
||||
ServiceProvider.Azure
|
||||
) {
|
||||
const contentFilterResults =
|
||||
textmoderation[0]?.content_filter_results;
|
||||
console.log(
|
||||
`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`,
|
||||
contentFilterResults,
|
||||
);
|
||||
if (textmoderation && textmoderation.length > 0 && ServiceProvider.Azure) {
|
||||
const contentFilterResults = textmoderation[0]?.content_filter_results;
|
||||
console.log(`[${ServiceProvider.Azure}] [Text Moderation] flagged categories result:`, contentFilterResults);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Request] parse error", text, msg);
|
||||
|
@@ -1,123 +0,0 @@
|
||||
import { isValidElement } from "react";
|
||||
|
||||
type IconMap = {
|
||||
active?: JSX.Element;
|
||||
inactive?: JSX.Element;
|
||||
mobileActive?: JSX.Element;
|
||||
mobileInactive?: JSX.Element;
|
||||
};
|
||||
interface Action {
|
||||
id: string;
|
||||
title?: string;
|
||||
icons: JSX.Element | IconMap;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
activeClassName?: string;
|
||||
}
|
||||
|
||||
type Groups = {
|
||||
normal: string[][];
|
||||
mobile: string[][];
|
||||
};
|
||||
|
||||
export interface ActionsBarProps {
|
||||
actionsShema: Action[];
|
||||
onSelect?: (id: string) => void;
|
||||
selected?: string;
|
||||
groups: string[][] | Groups;
|
||||
className?: string;
|
||||
inMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionsBar(props: ActionsBarProps) {
|
||||
const { actionsShema, 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 = actionsShema.find((a) => a.id === i);
|
||||
if (!action) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { icons } = action;
|
||||
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
|
||||
|
||||
if (isValidElement(icons)) {
|
||||
activeIcon = icons;
|
||||
inactiveIcon = icons;
|
||||
mobileActiveIcon = icons;
|
||||
mobileInactiveIcon = icons;
|
||||
} else {
|
||||
activeIcon = (icons as IconMap).active;
|
||||
inactiveIcon = (icons as IconMap).inactive;
|
||||
mobileActiveIcon = (icons as IconMap).mobileActive;
|
||||
mobileInactiveIcon = (icons as IconMap).mobileInactive;
|
||||
}
|
||||
|
||||
if (inMobile) {
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={` cursor-pointer shrink-1 grow-0 basis-[${
|
||||
(100 - 1) / arr.length
|
||||
}%] flex flex-col items-center justify-around gap-0.5 py-1.5
|
||||
${
|
||||
selected === action.id
|
||||
? "text-text-sidebar-tab-mobile-active"
|
||||
: "text-text-sidebar-tab-mobile-inactive"
|
||||
}
|
||||
`}
|
||||
onClick={handlerClick(action)}
|
||||
>
|
||||
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
|
||||
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
|
||||
{action.title || " "}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={`cursor-pointer p-3 ${
|
||||
selected === action.id
|
||||
? `!bg-actions-bar-btn-default ${action.activeClassName}`
|
||||
: "bg-transparent"
|
||||
} rounded-md items-center ${
|
||||
action.className
|
||||
} transition duration-300 ease-in-out`}
|
||||
onClick={handlerClick(action)}
|
||||
>
|
||||
{selected === action.id ? activeIcon : inactiveIcon}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (ind < arr.length - 1) {
|
||||
res.push(<div key={String(ind)} className=" flex-1"></div>);
|
||||
}
|
||||
return res;
|
||||
}, [] as JSX.Element[]);
|
||||
|
||||
return <div className={`flex items-center ${className} `}>{content}</div>;
|
||||
}
|
@@ -1,78 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type ButtonType = "primary" | "danger" | null;
|
||||
|
||||
export interface BtnProps {
|
||||
onClick?: () => void;
|
||||
icon?: JSX.Element;
|
||||
prefixIcon?: JSX.Element;
|
||||
type?: ButtonType;
|
||||
text?: React.ReactNode;
|
||||
bordered?: boolean;
|
||||
shadow?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export default function Btn(props: BtnProps) {
|
||||
const {
|
||||
onClick,
|
||||
icon,
|
||||
type,
|
||||
text,
|
||||
className,
|
||||
title,
|
||||
disabled,
|
||||
tabIndex,
|
||||
autoFocus,
|
||||
prefixIcon,
|
||||
} = props;
|
||||
|
||||
let btnClassName;
|
||||
|
||||
switch (type) {
|
||||
case "primary":
|
||||
btnClassName = `${
|
||||
disabled
|
||||
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
|
||||
: "bg-primary-btn shadow-btn"
|
||||
} text-text-btn-primary `;
|
||||
break;
|
||||
case "danger":
|
||||
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
|
||||
break;
|
||||
default:
|
||||
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
${className ?? ""}
|
||||
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
|
||||
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
|
||||
${btnClassName}
|
||||
follow-parent-svg
|
||||
`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
role="button"
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={autoFocus}
|
||||
>
|
||||
{prefixIcon && (
|
||||
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
|
||||
</button>
|
||||
);
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface CardProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Card(props: CardProps) {
|
||||
const { className, children, title } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<div
|
||||
className={`
|
||||
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
|
||||
mb-3
|
||||
|
||||
ml-3
|
||||
md:ml-4
|
||||
`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
import BotIcon from "@/app/icons/bot.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
export default function GloablLoading({
|
||||
noLogo,
|
||||
}: {
|
||||
noLogo?: boolean;
|
||||
useSkeleton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
|
||||
>
|
||||
{!noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,39 +0,0 @@
|
||||
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
export interface PopoverProps {
|
||||
content?: JSX.Element | string;
|
||||
children?: JSX.Element;
|
||||
arrowClassName?: string;
|
||||
popoverClassName?: string;
|
||||
noArrow?: boolean;
|
||||
align?: ComponentProps<typeof HoverCard.Content>["align"];
|
||||
openDelay?: number;
|
||||
}
|
||||
|
||||
export default function HoverPopover(props: PopoverProps) {
|
||||
const {
|
||||
content,
|
||||
children,
|
||||
arrowClassName,
|
||||
popoverClassName,
|
||||
noArrow = false,
|
||||
align,
|
||||
openDelay = 300,
|
||||
} = props;
|
||||
return (
|
||||
<HoverCard.Root openDelay={openDelay}>
|
||||
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className={`${popoverClassName}`}
|
||||
sideOffset={5}
|
||||
align={align}
|
||||
>
|
||||
{content}
|
||||
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
);
|
||||
}
|
@@ -1,42 +0,0 @@
|
||||
import { CSSProperties } from "react";
|
||||
import { getMessageImages } from "@/app/utils";
|
||||
import { RequestMessage } from "@/app/client/api";
|
||||
|
||||
interface ImgsProps {
|
||||
message: RequestMessage;
|
||||
}
|
||||
|
||||
export default function Imgs(props: ImgsProps) {
|
||||
const { message } = props;
|
||||
const imgSrcs = getMessageImages(message);
|
||||
|
||||
if (imgSrcs.length < 1) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const imgVars = {
|
||||
"--imgs-width": `calc(var(--max-message-width) - ${
|
||||
imgSrcs.length - 1
|
||||
}*0.25rem)`,
|
||||
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[100%] mt-[0.625rem] flex gap-1`}
|
||||
style={imgVars as CSSProperties}
|
||||
>
|
||||
{imgSrcs.map((image, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
|
||||
style={{
|
||||
backgroundImage: `url(${image})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,88 +0,0 @@
|
||||
import PasswordVisible from "@/app/icons/passwordVisible.svg";
|
||||
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import List, { ListContext } from "@/app/components/List";
|
||||
|
||||
export interface CommonInputProps
|
||||
extends Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
"onChange" | "type" | "value"
|
||||
> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface NumberInputProps {
|
||||
onChange?: (v: number) => void;
|
||||
type?: "number";
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface TextInputProps {
|
||||
onChange?: (v: string) => void;
|
||||
type?: "text" | "password";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface InputProps {
|
||||
onChange?: ((v: string) => void) | ((v: number) => void);
|
||||
type?: "text" | "password" | "number";
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
export default function Input(
|
||||
props: CommonInputProps & NumberInputProps,
|
||||
): JSX.Element;
|
||||
export default function Input(
|
||||
props: CommonInputProps & TextInputProps,
|
||||
): JSX.Element;
|
||||
export default function Input(props: CommonInputProps & InputProps) {
|
||||
const { value, type = "text", onChange, className, ...rest } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const { inputClassName } = useContext(ListContext);
|
||||
|
||||
const internalType = (show && "text") || type;
|
||||
|
||||
const { update, handleValidate } = useContext(List.ListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
update?.({ type: "input" });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
handleValidate?.(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
|
||||
>
|
||||
<input
|
||||
{...rest}
|
||||
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
|
||||
type={internalType}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (type === "number") {
|
||||
const v = e.currentTarget.valueAsNumber;
|
||||
(onChange as NumberInputProps["onChange"])?.(v);
|
||||
} else {
|
||||
const v = e.currentTarget.value;
|
||||
(onChange as TextInputProps["onChange"])?.(v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{type == "password" && (
|
||||
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
|
||||
{show ? <PasswordVisible /> : <PasswordInvisible />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface WidgetStyle {
|
||||
selectClassName?: string;
|
||||
inputClassName?: string;
|
||||
rangeClassName?: string;
|
||||
switchClassName?: string;
|
||||
inputNextLine?: boolean;
|
||||
rangeNextLine?: boolean;
|
||||
}
|
||||
|
||||
interface ChildrenMeta {
|
||||
type?: "unknown" | "input" | "range";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
id?: string;
|
||||
isMobileScreen?: boolean;
|
||||
widgetStyle?: WidgetStyle;
|
||||
}
|
||||
|
||||
type Error =
|
||||
| {
|
||||
error: true;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
error: false;
|
||||
};
|
||||
|
||||
export interface ListItemProps {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
nextline?: boolean;
|
||||
validator?: (v: any) => Error | Promise<Error>;
|
||||
}
|
||||
|
||||
export const ListContext = createContext<
|
||||
{
|
||||
isMobileScreen?: boolean;
|
||||
update?: (m: ChildrenMeta) => void;
|
||||
handleValidate?: (v: any) => void;
|
||||
} & WidgetStyle
|
||||
>({ isMobileScreen: false });
|
||||
|
||||
export function ListItem(props: ListItemProps) {
|
||||
const {
|
||||
className = "",
|
||||
onClick,
|
||||
title,
|
||||
subTitle,
|
||||
children,
|
||||
nextline,
|
||||
validator,
|
||||
} = props;
|
||||
|
||||
const context = useContext(ListContext);
|
||||
|
||||
const [childrenMeta, setMeta] = useState<ChildrenMeta>({});
|
||||
|
||||
const { inputNextLine, rangeNextLine } = context;
|
||||
|
||||
const { type, error } = childrenMeta;
|
||||
|
||||
let internalNextLine;
|
||||
|
||||
switch (type) {
|
||||
case "input":
|
||||
internalNextLine = !!(nextline || inputNextLine);
|
||||
break;
|
||||
case "range":
|
||||
internalNextLine = !!(nextline || rangeNextLine);
|
||||
break;
|
||||
default:
|
||||
internalNextLine = false;
|
||||
}
|
||||
|
||||
const update = useCallback((m: ChildrenMeta) => {
|
||||
setMeta((pre) => ({ ...pre, ...m }));
|
||||
}, []);
|
||||
|
||||
const handleValidate = useCallback((v: any) => {
|
||||
const insideValidator = validator || (() => {});
|
||||
|
||||
Promise.resolve(insideValidator(v)).then((result) => {
|
||||
if (result && result.error) {
|
||||
return update({
|
||||
error: result.message,
|
||||
});
|
||||
}
|
||||
update({
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative after:h-[0.5px] after:bottom-0 after:w-[100%] after:left-0 after:absolute last:after:hidden after:bg-list-item-divider ${
|
||||
internalNextLine ? "" : "flex gap-3"
|
||||
} justify-between items-center px-0 py-2 md:py-3 ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={`flex-1 flex flex-col justify-start gap-1`}>
|
||||
<div className=" font-common text-sm-mobile font-weight-[500] line-clamp-1 text-text-list-title">
|
||||
{title}
|
||||
</div>
|
||||
{subTitle && (
|
||||
<div className={` text-sm text-text-list-subtitle`}>{subTitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<ListContext.Provider value={{ ...context, update, handleValidate }}>
|
||||
<div
|
||||
className={`${
|
||||
internalNextLine ? "mt-[0.625rem]" : "max-w-[70%]"
|
||||
} flex flex-col items-center justify-center`}
|
||||
>
|
||||
<div>{children}</div>
|
||||
{!!error && (
|
||||
<div className="text-text-btn-danger text-sm-mobile-tab mt-[0.3125rem] flex items-start w-[100%]">
|
||||
<div className="">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function List(props: ListProps) {
|
||||
const { className, children, id, widgetStyle } = props;
|
||||
const { isMobileScreen } = useContext(ListContext);
|
||||
return (
|
||||
<ListContext.Provider value={{ isMobileScreen, ...widgetStyle }}>
|
||||
<div className={`flex flex-col w-[100%] ${className}`} id={id}>
|
||||
{children}
|
||||
</div>
|
||||
</ListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
List.ListItem = ListItem;
|
||||
List.ListContext = ListContext;
|
||||
|
||||
export default List;
|
@@ -1,35 +0,0 @@
|
||||
import BotIcon from "@/app/icons/bot.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
import { getCSSVar } from "@/app/utils";
|
||||
|
||||
export default function Loading({
|
||||
noLogo,
|
||||
useSkeleton = true,
|
||||
}: {
|
||||
noLogo?: boolean;
|
||||
useSkeleton?: boolean;
|
||||
}) {
|
||||
let theme;
|
||||
if (typeof window !== "undefined") {
|
||||
theme = getCSSVar("--default-container-bg");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col justify-center items-center w-[100%]
|
||||
h-[100%]
|
||||
md:my-2.5
|
||||
md:ml-1
|
||||
md:mr-2.5
|
||||
md:rounded-md
|
||||
md:h-[calc(100%-1.25rem)]
|
||||
`}
|
||||
style={{ background: useSkeleton ? theme : "" }}
|
||||
>
|
||||
{!noLogo && <BotIcon />}
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,115 +0,0 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Path,
|
||||
} from "@/app/constant";
|
||||
import useDrag from "@/app/hooks/useDrag";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||
import { ComponentType, useRef, useState } from "react";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
|
||||
export interface MenuWrapperInspectProps {
|
||||
setExternalProps?: (v: Record<string, any>) => void;
|
||||
setShowPanel?: (v: boolean) => void;
|
||||
showPanel?: boolean;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export default function MenuLayout<
|
||||
ListComponentProps extends MenuWrapperInspectProps,
|
||||
PanelComponentProps extends MenuWrapperInspectProps,
|
||||
>(
|
||||
ListComponent: ComponentType<ListComponentProps>,
|
||||
PanelComponent: ComponentType<PanelComponentProps>,
|
||||
) {
|
||||
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [externalProps, setExternalProps] = useState({});
|
||||
const config = useAppConfig();
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
|
||||
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||
// drag side bar
|
||||
const { onDragStart } = useDrag({
|
||||
customToggle: () => {
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||
});
|
||||
},
|
||||
customDragMove: (nextWidth: number) => {
|
||||
const { menuWidth } = updateGlobalCSSVars(nextWidth);
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--menu-width",
|
||||
`${menuWidth}px`,
|
||||
);
|
||||
config.update((config) => {
|
||||
config.sidebarWidth = nextWidth;
|
||||
});
|
||||
},
|
||||
customLimit: (x: number) =>
|
||||
Math.max(
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
w-[100%] relative bg-center
|
||||
max-md:h-[100%]
|
||||
md:flex md:my-2.5
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
flex flex-col px-6
|
||||
h-[100%]
|
||||
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
|
||||
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
|
||||
`}
|
||||
>
|
||||
<ListComponent
|
||||
{...props}
|
||||
setShowPanel={setShowPanel}
|
||||
setExternalProps={setExternalProps}
|
||||
showPanel={showPanel}
|
||||
/>
|
||||
</div>
|
||||
{!isMobileScreen && (
|
||||
<div
|
||||
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
|
||||
onPointerDown={(e) => {
|
||||
startDragWidth.current = config.sidebarWidth;
|
||||
onDragStart(e as any);
|
||||
}}
|
||||
>
|
||||
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
md:flex-1 md:h-[100%] md:w-page
|
||||
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
|
||||
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
|
||||
} max-md:z-10
|
||||
`}
|
||||
>
|
||||
<PanelComponent
|
||||
{...props}
|
||||
{...externalProps}
|
||||
setShowPanel={setShowPanel}
|
||||
setExternalProps={setExternalProps}
|
||||
showPanel={showPanel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
@@ -1,352 +0,0 @@
|
||||
import React, { useLayoutEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import * as AlertDialog from "@radix-ui/react-alert-dialog";
|
||||
import Btn, { BtnProps } from "@/app/components/Btn";
|
||||
|
||||
import Warning from "@/app/icons/warning.svg";
|
||||
import Close from "@/app/icons/closeIcon.svg";
|
||||
|
||||
export interface ModalProps {
|
||||
onOk?: () => void;
|
||||
onCancel?: () => void;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
okBtnProps?: BtnProps;
|
||||
cancelBtnProps?: BtnProps;
|
||||
content?:
|
||||
| React.ReactNode
|
||||
| ((handlers: { close: () => void }) => JSX.Element);
|
||||
title?: React.ReactNode;
|
||||
visible?: boolean;
|
||||
noFooter?: boolean;
|
||||
noHeader?: boolean;
|
||||
isMobile?: boolean;
|
||||
closeble?: boolean;
|
||||
type?: "modal" | "bottom-drawer";
|
||||
headerBordered?: boolean;
|
||||
modelClassName?: string;
|
||||
onOpen?: (v: boolean) => void;
|
||||
maskCloseble?: boolean;
|
||||
}
|
||||
|
||||
export interface WarnProps
|
||||
extends Omit<
|
||||
ModalProps,
|
||||
| "closeble"
|
||||
| "isMobile"
|
||||
| "noHeader"
|
||||
| "noFooter"
|
||||
| "onOk"
|
||||
| "okBtnProps"
|
||||
| "cancelBtnProps"
|
||||
| "content"
|
||||
> {
|
||||
onOk?: () => Promise<void> | void;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TriggerProps
|
||||
extends Omit<ModalProps, "visible" | "onOk" | "onCancel"> {
|
||||
children: JSX.Element;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const baseZIndex = 150;
|
||||
|
||||
const Modal = (props: ModalProps) => {
|
||||
const {
|
||||
onOk,
|
||||
onCancel,
|
||||
okText,
|
||||
cancelText,
|
||||
content,
|
||||
title,
|
||||
visible,
|
||||
noFooter,
|
||||
noHeader,
|
||||
closeble = true,
|
||||
okBtnProps,
|
||||
cancelBtnProps,
|
||||
type = "modal",
|
||||
headerBordered,
|
||||
modelClassName,
|
||||
onOpen,
|
||||
maskCloseble = true,
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = useState(!!visible);
|
||||
|
||||
const mergeOpen = visible ?? open;
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
setOpen(false);
|
||||
onOk?.();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onOpen?.(mergeOpen);
|
||||
}, [mergeOpen]);
|
||||
|
||||
let layoutClassName = "";
|
||||
let panelClassName = "";
|
||||
let titleClassName = "";
|
||||
let footerClassName = "";
|
||||
|
||||
switch (type) {
|
||||
case "bottom-drawer":
|
||||
layoutClassName = "fixed inset-0 flex flex-col w-[100%] bottom-0";
|
||||
panelClassName =
|
||||
"rounded-t-chat-model-select overflow-y-auto overflow-x-hidden";
|
||||
titleClassName = "px-4 py-3";
|
||||
footerClassName = "absolute w-[100%]";
|
||||
break;
|
||||
case "modal":
|
||||
default:
|
||||
layoutClassName =
|
||||
"fixed inset-0 flex flex-col item-start top-0 left-[50vw] translate-x-[-50%] max-sm:w-modal-modal-type-mobile";
|
||||
panelClassName = "rounded-lg px-6 sm:w-modal-modal-type";
|
||||
titleClassName = "py-6 max-sm:pb-3";
|
||||
footerClassName = "py-6";
|
||||
}
|
||||
const btnCommonClass = "px-4 py-2.5 rounded-md max-sm:flex-1";
|
||||
const { className: okBtnClass } = okBtnProps || {};
|
||||
const { className: cancelBtnClass } = cancelBtnProps || {};
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={mergeOpen} onOpenChange={setOpen}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay
|
||||
className="bg-modal-mask fixed inset-0 animate-mask "
|
||||
style={{ zIndex: baseZIndex - 1 }}
|
||||
onClick={() => {
|
||||
if (maskCloseble) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AlertDialog.Content
|
||||
className={`
|
||||
${layoutClassName}
|
||||
`}
|
||||
style={{ zIndex: baseZIndex - 1 }}
|
||||
>
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (maskCloseble) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col flex-0
|
||||
bg-moda-panel text-modal-panel
|
||||
${modelClassName}
|
||||
${panelClassName}
|
||||
`}
|
||||
>
|
||||
{!noHeader && (
|
||||
<AlertDialog.Title
|
||||
className={`
|
||||
flex items-center justify-between gap-3 font-common
|
||||
md:text-chat-header-title md:font-bold md:leading-5
|
||||
${
|
||||
headerBordered
|
||||
? " border-b border-modal-header-bottom"
|
||||
: ""
|
||||
}
|
||||
${titleClassName}
|
||||
`}
|
||||
>
|
||||
<div className="flex gap-3 justify-start flex-1 items-center text-text-modal-title text-chat-header-title">
|
||||
{title}
|
||||
</div>
|
||||
{closeble && (
|
||||
<div
|
||||
className="items-center"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</div>
|
||||
)}
|
||||
</AlertDialog.Title>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden text-text-modal-content text-sm-title">
|
||||
{typeof content === "function"
|
||||
? content({
|
||||
close: () => {
|
||||
handleClose();
|
||||
},
|
||||
})
|
||||
: content}
|
||||
</div>
|
||||
{!noFooter && (
|
||||
<div
|
||||
className={`
|
||||
flex gap-3 sm:justify-end max-sm:justify-between
|
||||
${footerClassName}
|
||||
`}
|
||||
>
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Btn
|
||||
{...cancelBtnProps}
|
||||
onClick={() => handleClose()}
|
||||
text={cancelText}
|
||||
className={`${btnCommonClass} ${cancelBtnClass}`}
|
||||
/>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<Btn
|
||||
{...okBtnProps}
|
||||
onClick={handleOk}
|
||||
text={okText}
|
||||
className={`${btnCommonClass} ${okBtnClass}`}
|
||||
/>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{type === "modal" && (
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (maskCloseble) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const Warn = ({
|
||||
title,
|
||||
onOk,
|
||||
visible,
|
||||
content,
|
||||
...props
|
||||
}: WarnProps) => {
|
||||
const [internalVisible, setVisible] = useState(visible);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
title={
|
||||
<>
|
||||
<Warning />
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<AlertDialog.Description
|
||||
className={`
|
||||
font-common font-normal
|
||||
md:text-sm-title md:leading-[158%]
|
||||
`}
|
||||
>
|
||||
{content}
|
||||
</AlertDialog.Description>
|
||||
}
|
||||
closeble={false}
|
||||
onOk={() => {
|
||||
const toDo = onOk?.();
|
||||
if (toDo instanceof Promise) {
|
||||
toDo.then(() => {
|
||||
setVisible(false);
|
||||
});
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
}}
|
||||
visible={internalVisible}
|
||||
okBtnProps={{
|
||||
className: `bg-delete-chat-ok-btn text-text-delete-chat-ok-btn `,
|
||||
}}
|
||||
cancelBtnProps={{
|
||||
className: `bg-delete-chat-cancel-btn border border-delete-chat-cancel-btn text-text-delete-chat-cancel-btn`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.id = "confirm-root";
|
||||
div.style.height = "0px";
|
||||
document.body.appendChild(div);
|
||||
|
||||
Modal.warn = (props: Omit<WarnProps, "visible" | "onCancel" | "onOk">) => {
|
||||
const root = createRoot(div);
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
root.render(
|
||||
<Warn
|
||||
{...props}
|
||||
visible={true}
|
||||
onCancel={() => {
|
||||
closeModal();
|
||||
resolve(false);
|
||||
}}
|
||||
onOk={() => {
|
||||
closeModal();
|
||||
resolve(true);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const Trigger = (props: TriggerProps) => {
|
||||
const { children, className, content, ...rest } = props;
|
||||
|
||||
const [internalVisible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Modal
|
||||
{...rest}
|
||||
visible={internalVisible}
|
||||
onCancel={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
content={
|
||||
typeof content === "function"
|
||||
? content({
|
||||
close: () => {
|
||||
setVisible(false);
|
||||
},
|
||||
})
|
||||
: content
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Modal.Trigger = Trigger;
|
||||
|
||||
export default Modal;
|
@@ -1,352 +0,0 @@
|
||||
import useRelativePosition from "@/app/hooks/useRelativePosition";
|
||||
import {
|
||||
RefObject,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const ArrowIcon = ({ sibling }: { sibling: RefObject<HTMLDivElement> }) => {
|
||||
const [color, setColor] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (sibling.current) {
|
||||
const { backgroundColor } = window.getComputedStyle(sibling.current);
|
||||
setColor(backgroundColor);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="6"
|
||||
viewBox="0 0 16 6"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const baseZIndex = 100;
|
||||
const popoverRootName = "popoverRoot";
|
||||
let popoverRoot = document.querySelector(
|
||||
`#${popoverRootName}`,
|
||||
) as HTMLDivElement;
|
||||
if (!popoverRoot) {
|
||||
popoverRoot = document.createElement("div");
|
||||
document.body.appendChild(popoverRoot);
|
||||
popoverRoot.style.height = "0px";
|
||||
popoverRoot.style.width = "100%";
|
||||
popoverRoot.style.position = "fixed";
|
||||
popoverRoot.style.bottom = "0";
|
||||
popoverRoot.style.zIndex = "10000";
|
||||
popoverRoot.id = "popover-root";
|
||||
}
|
||||
|
||||
export interface PopoverProps {
|
||||
content?: JSX.Element | string;
|
||||
children?: JSX.Element;
|
||||
show?: boolean;
|
||||
onShow?: (v: boolean) => void;
|
||||
className?: string;
|
||||
popoverClassName?: string;
|
||||
trigger?: "hover" | "click";
|
||||
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b" | "l" | "r";
|
||||
noArrow?: boolean;
|
||||
delayClose?: number;
|
||||
useGlobalRoot?: boolean;
|
||||
getPopoverPanelRef?: (ref: RefObject<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export default function Popover(props: PopoverProps) {
|
||||
const {
|
||||
content,
|
||||
children,
|
||||
show,
|
||||
onShow,
|
||||
className,
|
||||
popoverClassName,
|
||||
trigger = "hover",
|
||||
placement = "t",
|
||||
noArrow = false,
|
||||
delayClose = 0,
|
||||
useGlobalRoot,
|
||||
getPopoverPanelRef,
|
||||
} = props;
|
||||
|
||||
const [internalShow, setShow] = useState(false);
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
const popoverCommonClass = `absolute p-2 box-border`;
|
||||
|
||||
const mergedShow = show ?? internalShow;
|
||||
|
||||
const { arrowClassName, placementStyle, placementClassName } = useMemo(() => {
|
||||
const arrowCommonClassName = `${
|
||||
noArrow ? "hidden" : ""
|
||||
} absolute z-10 left-[50%] translate-x-[calc(-50%)]`;
|
||||
|
||||
let defaultTopPlacement = true; // when users dont config 't' or 'b'
|
||||
|
||||
const {
|
||||
distanceToBottomBoundary = 0,
|
||||
distanceToLeftBoundary = 0,
|
||||
distanceToRightBoundary = -10000,
|
||||
distanceToTopBoundary = 0,
|
||||
targetH = 0,
|
||||
targetW = 0,
|
||||
} = position?.poi || {};
|
||||
|
||||
if (distanceToBottomBoundary > distanceToTopBoundary) {
|
||||
defaultTopPlacement = false;
|
||||
}
|
||||
|
||||
const placements = {
|
||||
lt: {
|
||||
placementStyle: {
|
||||
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||
placementClassName: "bottom-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||
},
|
||||
lb: {
|
||||
placementStyle: {
|
||||
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||
placementClassName: "top-[calc(100%+0.5rem)] left-[calc(-2%)]",
|
||||
},
|
||||
rt: {
|
||||
placementStyle: {
|
||||
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||
placementClassName: "bottom-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||
},
|
||||
rb: {
|
||||
placementStyle: {
|
||||
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||
right: `calc(${distanceToRightBoundary}px - ${targetW * 0.02}px)`,
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||
placementClassName: "top-[calc(100%+0.5rem)] right-[calc(-2%)]",
|
||||
},
|
||||
t: {
|
||||
placementStyle: {
|
||||
bottom: `calc(${distanceToBottomBoundary + targetH}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)] pb-[0.5rem]`,
|
||||
placementClassName:
|
||||
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||
},
|
||||
b: {
|
||||
placementStyle: {
|
||||
top: `calc(-${distanceToBottomBoundary}px + 0.5rem)`,
|
||||
left: `calc(${distanceToLeftBoundary + targetW / 2}px`,
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
arrowClassName: `${arrowCommonClassName} top-[calc(100%+0.5rem)] translate-y-[calc(-100%)] pt-[0.5rem]`,
|
||||
placementClassName:
|
||||
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]",
|
||||
},
|
||||
};
|
||||
|
||||
const getStyle = () => {
|
||||
if (["l", "r"].includes(placement)) {
|
||||
return placements[
|
||||
`${placement}${defaultTopPlacement ? "t" : "b"}` as
|
||||
| "lt"
|
||||
| "lb"
|
||||
| "rb"
|
||||
| "rt"
|
||||
];
|
||||
}
|
||||
return placements[placement as Exclude<typeof placement, "l" | "r">];
|
||||
};
|
||||
|
||||
return getStyle();
|
||||
}, [Object.values(position?.poi || {})]);
|
||||
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimer = useRef<number>(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
getPopoverPanelRef?.(popoverRef);
|
||||
onShow?.(internalShow);
|
||||
}, [internalShow]);
|
||||
|
||||
if (trigger === "click") {
|
||||
const handleOpen = (e: { currentTarget: any }) => {
|
||||
clearTimeout(closeTimer.current);
|
||||
setShow(true);
|
||||
getRelativePosition(e.currentTarget, "");
|
||||
window.document.documentElement.style.overflow = "hidden";
|
||||
};
|
||||
const handleClose = () => {
|
||||
if (delayClose) {
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
setShow(false);
|
||||
}, delayClose);
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
window.document.documentElement.style.overflow = "auto";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!mergedShow) {
|
||||
handleOpen(e);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{mergedShow && (
|
||||
<>
|
||||
{!noArrow && (
|
||||
<div className={`${arrowClassName}`}>
|
||||
<ArrowIcon sibling={popoverRef} />
|
||||
</div>
|
||||
)}
|
||||
{createPortal(
|
||||
<div
|
||||
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
|
||||
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||
ref={popoverRef}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
popoverRoot,
|
||||
)}
|
||||
{createPortal(
|
||||
<div
|
||||
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
|
||||
style={{ zIndex: baseZIndex }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
|
||||
</div>,
|
||||
popoverRoot,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (useGlobalRoot) {
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onPointerEnter={(e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(closeTimer.current);
|
||||
onShow?.(true);
|
||||
setShow(true);
|
||||
getRelativePosition(e.currentTarget, "");
|
||||
window.document.documentElement.style.overflow = "hidden";
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.preventDefault();
|
||||
if (delayClose) {
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
onShow?.(false);
|
||||
setShow(false);
|
||||
}, delayClose);
|
||||
} else {
|
||||
onShow?.(false);
|
||||
setShow(false);
|
||||
}
|
||||
window.document.documentElement.style.overflow = "auto";
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{mergedShow && (
|
||||
<>
|
||||
<div
|
||||
className={`${
|
||||
noArrow ? "opacity-0" : ""
|
||||
} bg-inherit ${arrowClassName}`}
|
||||
style={{ zIndex: baseZIndex + 1 }}
|
||||
>
|
||||
<ArrowIcon sibling={popoverRef} />
|
||||
</div>
|
||||
{createPortal(
|
||||
<div
|
||||
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
|
||||
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||
ref={popoverRef}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
popoverRoot,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group/popover relative ${className}`}
|
||||
onPointerEnter={(e) => {
|
||||
getRelativePosition(e.currentTarget, "");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className={`
|
||||
hidden group-hover/popover:block
|
||||
${noArrow ? "opacity-0" : ""}
|
||||
bg-inherit
|
||||
${arrowClassName}
|
||||
`}
|
||||
style={{ zIndex: baseZIndex + 1 }}
|
||||
>
|
||||
<ArrowIcon sibling={popoverRef} />
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
hidden group-hover/popover:block whitespace-nowrap
|
||||
${popoverCommonClass}
|
||||
${placementClassName}
|
||||
${popoverClassName}
|
||||
`}
|
||||
ref={popoverRef}
|
||||
style={{ zIndex: baseZIndex + 1 }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,71 +0,0 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useMemo, ReactNode } from "react";
|
||||
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
|
||||
import { getLang } from "@/app/locales";
|
||||
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
import { isIOS } from "@/app/utils";
|
||||
import useListenWinResize from "@/app/hooks/useListenWinResize";
|
||||
|
||||
interface ScreenProps {
|
||||
children: ReactNode;
|
||||
noAuth: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
}
|
||||
|
||||
export default function Screen(props: ScreenProps) {
|
||||
const location = useLocation();
|
||||
const isAuth = location.pathname === Path.Auth;
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
const isIOSMobile = useMemo(
|
||||
() => isIOS() && isMobileScreen,
|
||||
[isMobileScreen],
|
||||
);
|
||||
|
||||
useListenWinResize();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex h-[100%] w-[100%] bg-center
|
||||
max-md:relative max-md:flex-col-reverse max-md:bg-global-mobile
|
||||
md:overflow-hidden md:bg-global
|
||||
`}
|
||||
style={{
|
||||
direction: getLang() === "ar" ? "rtl" : "ltr",
|
||||
}}
|
||||
>
|
||||
{isAuth ? (
|
||||
props.noAuth
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
max-md:absolute max-md:w-[100%] max-md:bottom-0 max-md:z-10
|
||||
md:flex-0 md:overflow-hidden
|
||||
`}
|
||||
id={SIDEBAR_ID}
|
||||
>
|
||||
{props.sidebar}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`
|
||||
h-[100%]
|
||||
max-md:w-[100%]
|
||||
md:flex-1 md:min-w-0 md:overflow-hidden md:flex
|
||||
`}
|
||||
id={SlotID.AppBody}
|
||||
style={{
|
||||
// #3016 disable transition on ios mobile screen
|
||||
transition: isIOSMobile ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
.search {
|
||||
display: flex;
|
||||
max-width: 460px;
|
||||
height: 50px;
|
||||
padding: 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--Light-Text-Black, #18182A);
|
||||
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
|
||||
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
|
||||
|
||||
.icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
flex: 0 0;
|
||||
}
|
||||
.input {
|
||||
height: 18px;
|
||||
flex: 1 1;
|
||||
}
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
import styles from "./index.module.scss";
|
||||
import SearchIcon from "@/app/icons/search.svg";
|
||||
|
||||
export interface SearchProps {
|
||||
value?: string;
|
||||
onSearch?: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const Search = (props: SearchProps) => {
|
||||
const { placeholder = "", value, onSearch } = props;
|
||||
return (
|
||||
<div className={styles["search"]}>
|
||||
<div className={styles["icon"]}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
<input
|
||||
className={styles["input"]}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
onSearch?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
@@ -1,118 +0,0 @@
|
||||
import SelectIcon from "@/app/icons/downArrowIcon.svg";
|
||||
import Popover from "@/app/components/Popover";
|
||||
import React, { useContext, useMemo, useRef } from "react";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import List from "@/app/components/List";
|
||||
|
||||
import Selected from "@/app/icons/selectedIcon.svg";
|
||||
|
||||
export type Option<Value> = {
|
||||
value: Value;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export interface SearchProps<Value> {
|
||||
value?: string;
|
||||
onSelect?: (v: Value) => void;
|
||||
options?: Option<Value>[];
|
||||
inMobile?: boolean;
|
||||
}
|
||||
|
||||
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
|
||||
const { value, onSelect, options = [], inMobile } = props;
|
||||
|
||||
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
|
||||
|
||||
const optionsRef = useRef<Option<Value>[]>([]);
|
||||
optionsRef.current = options;
|
||||
const selectedOption = useMemo(
|
||||
() => optionsRef.current.find((o) => o.value === value),
|
||||
[value],
|
||||
);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
let headerH = 100;
|
||||
let baseH = position?.poi.distanceToBottomBoundary || 0;
|
||||
if (isMobileScreen) {
|
||||
headerH = 60;
|
||||
}
|
||||
if (position?.poi.relativePosition[1] === Orientation.bottom) {
|
||||
baseH = position?.poi.distanceToTopBoundary;
|
||||
}
|
||||
|
||||
const maxHeight = `${baseH - headerH}px`;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{options?.map((o) => (
|
||||
<div
|
||||
key={o.value}
|
||||
className={`
|
||||
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
|
||||
`}
|
||||
onClick={() => {
|
||||
onSelect?.(o.value);
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
|
||||
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
|
||||
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
|
||||
}
|
||||
>
|
||||
<Selected />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
noArrow
|
||||
placement={
|
||||
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
|
||||
}
|
||||
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
|
||||
onShow={(e) => {
|
||||
getRelativePosition(contentRef.current!, "");
|
||||
}}
|
||||
className={selectClassName}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
|
||||
ref={contentRef}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
|
||||
>
|
||||
{!!selectedOption?.icon && (
|
||||
<div className={``}>{selectedOption?.icon}</div>
|
||||
)}
|
||||
<div className={`flex-1`}>{selectedOption?.label}</div>
|
||||
</div>
|
||||
<div className={``}>
|
||||
<SelectIcon />
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
@@ -1,99 +0,0 @@
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { ListContext } from "@/app/components/List";
|
||||
import { useResizeObserver } from "usehooks-ts";
|
||||
|
||||
interface SlideRangeProps {
|
||||
className?: string;
|
||||
description?: string;
|
||||
range?: {
|
||||
start?: number;
|
||||
stroke?: number;
|
||||
};
|
||||
onSlide?: (v: number) => void;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const margin = 15;
|
||||
|
||||
export default function SlideRange(props: SlideRangeProps) {
|
||||
const {
|
||||
className = "",
|
||||
description = "",
|
||||
range = {},
|
||||
value,
|
||||
onSlide,
|
||||
step,
|
||||
} = props;
|
||||
const { start = 0, stroke = 1 } = range;
|
||||
|
||||
const { rangeClassName, update } = useContext(ListContext);
|
||||
|
||||
const slideRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useResizeObserver({
|
||||
ref: slideRef,
|
||||
onResize: () => {
|
||||
setProperty(value);
|
||||
},
|
||||
});
|
||||
|
||||
const transformToWidth = (x: number = start) => {
|
||||
const abs = x - start;
|
||||
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
|
||||
const result = (abs / stroke) * maxWidth;
|
||||
return result;
|
||||
};
|
||||
|
||||
const setProperty = (value?: number) => {
|
||||
const initWidth = transformToWidth(value);
|
||||
slideRef.current?.style.setProperty(
|
||||
"--slide-value-size",
|
||||
`${initWidth + margin}px`,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
update?.({ type: "range" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
|
||||
>
|
||||
{!!description && (
|
||||
<div className=" text-common text-sm ">{description}</div>
|
||||
)}
|
||||
<div
|
||||
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
|
||||
ref={slideRef}
|
||||
>
|
||||
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
|
||||
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
|
||||
// onPointerDown={onPointerDown}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
|
||||
value={value}
|
||||
min={start}
|
||||
max={start + stroke}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
setProperty(e.target.valueAsNumber);
|
||||
onSlide?.(e.target.valueAsNumber);
|
||||
}}
|
||||
style={{
|
||||
marginLeft: margin,
|
||||
marginRight: margin,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,33 +0,0 @@
|
||||
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||
import { useContext } from "react";
|
||||
import List from "../List";
|
||||
|
||||
interface SwitchProps {
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const { value, onChange } = props;
|
||||
|
||||
const { switchClassName = "" } = useContext(List.ListContext);
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
className={`
|
||||
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
|
||||
${switchClassName}
|
||||
${
|
||||
value
|
||||
? "bg-switch-checked justify-end"
|
||||
: "bg-switch-unchecked justify-start"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<RadixSwitch.Thumb
|
||||
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
|
||||
/>
|
||||
</RadixSwitch.Root>
|
||||
);
|
||||
}
|
@@ -1,27 +0,0 @@
|
||||
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
|
||||
|
||||
export interface ThumbnailProps {
|
||||
image: string;
|
||||
deleteImage: () => void;
|
||||
}
|
||||
|
||||
export default function Thumbnail(props: ThumbnailProps) {
|
||||
const { image, deleteImage } = props;
|
||||
return (
|
||||
<div
|
||||
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
|
||||
style={{ backgroundImage: `url("${image}")` }}
|
||||
>
|
||||
<div
|
||||
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
|
||||
>
|
||||
<div
|
||||
className={`cursor-pointer flex items-center justify-center float-right`}
|
||||
onClick={deleteImage}
|
||||
>
|
||||
<ImgDeleteIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -6,8 +6,6 @@
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: var(--white);
|
||||
|
||||
.auth-logo {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
@@ -35,18 +33,4 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -20,6 +20,8 @@ export function AuthPage() {
|
||||
accessStore.update((access) => {
|
||||
access.openaiApiKey = "";
|
||||
access.accessCode = "";
|
||||
access.googleApiKey = "";
|
||||
access.anthropicApiKey = "";
|
||||
});
|
||||
}; // Reset access code to empty string
|
||||
|
||||
@@ -75,6 +77,17 @@ export function AuthPage() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className={styles["auth-input"]}
|
||||
type="password"
|
||||
placeholder={Locale.Settings.Access.Anthropic.ApiKey.Placeholder}
|
||||
value={accessStore.anthropicApiKey}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicApiKey = e.currentTarget.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
@@ -448,20 +448,10 @@ export function ChatActions(props: {
|
||||
// switch model
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
const allModels = useAllModels();
|
||||
const models = useMemo(() => {
|
||||
const filteredModels = allModels.filter((m) => m.available);
|
||||
const defaultModel = filteredModels.find((m) => m.isDefault);
|
||||
|
||||
if (defaultModel) {
|
||||
const arr = [
|
||||
defaultModel,
|
||||
...filteredModels.filter((m) => m !== defaultModel),
|
||||
];
|
||||
return arr;
|
||||
} else {
|
||||
return filteredModels;
|
||||
}
|
||||
}, [allModels]);
|
||||
const models = useMemo(
|
||||
() => allModels.filter((m) => m.available),
|
||||
[allModels],
|
||||
);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const [showUploadImage, setShowUploadImage] = useState(false);
|
||||
|
||||
@@ -477,10 +467,7 @@ export function ChatActions(props: {
|
||||
// switch to first available model
|
||||
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
||||
if (isUnavaliableModel && models.length > 0) {
|
||||
// show next model to default model if exist
|
||||
let nextModel: ModelType = (
|
||||
models.find((model) => model.isDefault) || models[0]
|
||||
).name;
|
||||
const nextModel = models[0].name as ModelType;
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.mask.modelConfig.model = nextModel),
|
||||
);
|
||||
@@ -1115,13 +1102,11 @@ function _Chat() {
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
if (!isVisionModel(currentModel)) {
|
||||
return;
|
||||
}
|
||||
if(!isVisionModel(currentModel)){return;}
|
||||
const items = (event.clipboardData || window.clipboardData).items;
|
||||
for (const item of items) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
|
@@ -2,9 +2,6 @@
|
||||
&-body {
|
||||
margin-top: 20px;
|
||||
}
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.export-content {
|
||||
|
@@ -40,7 +40,6 @@ import { EXPORT_MESSAGE_CLASS_NAME, ModelProvider } from "../constant";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { ClientApi } from "../client/api";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||
|
||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
@@ -316,7 +315,7 @@ export function PreviewActions(props: {
|
||||
var api: ClientApi;
|
||||
if (config.modelConfig.model.startsWith("gemini")) {
|
||||
api = new ClientApi(ModelProvider.GeminiPro);
|
||||
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||
} else if (config.modelConfig.model.startsWith("claude")) {
|
||||
api = new ClientApi(ModelProvider.Claude);
|
||||
} else {
|
||||
api = new ClientApi(ModelProvider.GPT);
|
||||
|
@@ -29,7 +29,6 @@ import { AuthPage } from "./auth";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { ClientApi } from "../client/api";
|
||||
import { useAccessStore } from "../store";
|
||||
import { identifyDefaultClaudeModel } from "../utils/checkers";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
@@ -174,7 +173,7 @@ export function useLoadData() {
|
||||
var api: ClientApi;
|
||||
if (config.modelConfig.model.startsWith("gemini")) {
|
||||
api = new ClientApi(ModelProvider.GeminiPro);
|
||||
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||
} else if (config.modelConfig.model.startsWith("claude")) {
|
||||
api = new ClientApi(ModelProvider.Claude);
|
||||
} else {
|
||||
api = new ClientApi(ModelProvider.GPT);
|
||||
|
@@ -135,9 +135,10 @@ function escapeBrackets(text: string) {
|
||||
}
|
||||
|
||||
function _MarkDownContent(props: { content: string }) {
|
||||
const escapedContent = useMemo(() => {
|
||||
return escapeBrackets(escapeDollarNumber(props.content));
|
||||
}, [props.content]);
|
||||
const escapedContent = useMemo(
|
||||
() => escapeBrackets(escapeDollarNumber(props.content)),
|
||||
[props.content],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
@@ -177,14 +178,13 @@ export function Markdown(
|
||||
fontSize?: number;
|
||||
parentRef?: RefObject<HTMLDivElement>;
|
||||
defaultShow?: boolean;
|
||||
className?: string;
|
||||
} & React.DOMAttributes<HTMLDivElement>,
|
||||
) {
|
||||
const mdRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`markdown-body ${props.className}`}
|
||||
className="markdown-body"
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
}}
|
||||
|
@@ -4,10 +4,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.mask-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import styles from "./mask.module.scss";
|
||||
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { getMessageTextContent } from "../utils";
|
||||
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||
|
||||
// drag and drop helper function
|
||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||
@@ -398,14 +398,14 @@ export function ContextPrompts(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function MaskPage(props: { className?: string }) {
|
||||
export function MaskPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const maskStore = useMaskStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const [filterLang, setFilterLang] = useState<Lang | undefined>(
|
||||
() => localStorage.getItem("Mask-language") as Lang | undefined,
|
||||
localStorage.getItem("Mask-language") as Lang | undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (filterLang) {
|
||||
@@ -466,13 +466,8 @@ export function MaskPage(props: { className?: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
${styles["mask-page"]}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
@@ -650,6 +645,6 @@ export function MaskPage(props: { className?: string }) {
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
@@ -8,10 +8,6 @@
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
div:not(.no-dark) > svg {
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.mask-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@@ -16,7 +16,6 @@ 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 (
|
||||
@@ -72,7 +71,7 @@ function useMaskGroup(masks: Mask[]) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function NewChat(props: { className?: string }) {
|
||||
export function NewChat() {
|
||||
const chatStore = useChatStore();
|
||||
const maskStore = useMaskStore();
|
||||
|
||||
@@ -111,15 +110,8 @@ export function NewChat(props: { className?: string }) {
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
const isMobileScreen = useMobileScreen();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${styles["new-chat"]}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
<div className={styles["new-chat"]}>
|
||||
<div className={styles["mask-header"]}>
|
||||
<IconButton
|
||||
icon={<LeftIcon />}
|
||||
|
@@ -101,7 +101,6 @@ interface ModalProps {
|
||||
defaultMax?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
export function Modal(props: ModalProps) {
|
||||
useEffect(() => {
|
||||
@@ -123,14 +122,14 @@ export function Modal(props: ModalProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles["modal-container"]} ${
|
||||
isMax && styles["modal-container-max"]
|
||||
} ${props.className ?? ""}`}
|
||||
className={
|
||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||||
}
|
||||
>
|
||||
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
|
||||
<div className={`${styles["modal-title"]}`}>{props.title}</div>
|
||||
<div className={styles["modal-header"]}>
|
||||
<div className={styles["modal-title"]}>{props.title}</div>
|
||||
|
||||
<div className={`${styles["modal-header-actions"]}`}>
|
||||
<div className={styles["modal-header-actions"]}>
|
||||
<div
|
||||
className={styles["modal-header-action"]}
|
||||
onClick={() => setMax(!isMax)}
|
||||
@@ -148,11 +147,11 @@ export function Modal(props: ModalProps) {
|
||||
|
||||
<div className={styles["modal-content"]}>{props.children}</div>
|
||||
|
||||
<div className={`${styles["modal-footer"]} new-footer`}>
|
||||
<div className={styles["modal-footer"]}>
|
||||
{props.footer}
|
||||
<div className={styles["modal-actions"]}>
|
||||
{props.actions?.map((action, i) => (
|
||||
<div key={i} className={`${styles["modal-action"]} new-btn`}>
|
||||
<div key={i} className={styles["modal-action"]}>
|
||||
{action}
|
||||
</div>
|
||||
))}
|
||||
|
@@ -21,7 +21,6 @@ declare global {
|
||||
ENABLE_BALANCE_QUERY?: string; // allow user to query balance or not
|
||||
DISABLE_FAST_LINK?: string; // disallow parse settings from url or not
|
||||
CUSTOM_MODELS?: string; // to control custom models
|
||||
DEFAULT_MODEL?: string; // to cnntrol default model in every new chat window
|
||||
|
||||
// azure only
|
||||
AZURE_URL?: string; // https://{azure-url}/openai/deployments/{deploy-name}
|
||||
@@ -60,14 +59,12 @@ export const getServerSideConfig = () => {
|
||||
|
||||
const disableGPT4 = !!process.env.DISABLE_GPT4;
|
||||
let customModels = process.env.CUSTOM_MODELS ?? "";
|
||||
let defaultModel = process.env.DEFAULT_MODEL ?? "";
|
||||
|
||||
if (disableGPT4) {
|
||||
if (customModels) customModels += ",";
|
||||
customModels += DEFAULT_MODELS.filter((m) => m.name.startsWith("gpt-4"))
|
||||
.map((m) => "-" + m.name)
|
||||
.join(",");
|
||||
if (defaultModel.startsWith("gpt-4")) defaultModel = "";
|
||||
}
|
||||
|
||||
const isAzure = !!process.env.AZURE_URL;
|
||||
@@ -82,10 +79,6 @@ export const getServerSideConfig = () => {
|
||||
`[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
||||
);
|
||||
|
||||
const whiteWebDevEndpoints = (process.env.WHITE_WEBDEV_ENDPOINTS ?? "").split(
|
||||
",",
|
||||
);
|
||||
|
||||
return {
|
||||
baseUrl: process.env.BASE_URL,
|
||||
apiKey,
|
||||
@@ -119,7 +112,5 @@ export const getServerSideConfig = () => {
|
||||
hideBalanceQuery: !process.env.ENABLE_BALANCE_QUERY,
|
||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||
customModels,
|
||||
defaultModel,
|
||||
whiteWebDevEndpoints,
|
||||
};
|
||||
};
|
||||
|
299
app/constant.ts
@@ -49,17 +49,10 @@ export enum StoreKey {
|
||||
Sync = "sync",
|
||||
}
|
||||
|
||||
export const NARROW_SIDEBAR_WIDTH = 100;
|
||||
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 340;
|
||||
export const MAX_SIDEBAR_WIDTH = 440;
|
||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
||||
export const MAX_SIDEBAR_WIDTH = 500;
|
||||
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 NARROW_SIDEBAR_WIDTH = 100;
|
||||
|
||||
export const ACCESS_CODE_PREFIX = "nk-";
|
||||
|
||||
@@ -105,25 +98,19 @@ export const Azure = {
|
||||
|
||||
export const Google = {
|
||||
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
||||
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||
VisionChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||
ChatPath: "v1beta/models/gemini-pro:generateContent",
|
||||
VisionChatPath: "v1beta/models/gemini-pro-vision:generateContent",
|
||||
|
||||
// /api/openai/v1/chat/completions
|
||||
};
|
||||
|
||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||
// export const DEFAULT_SYSTEM_TEMPLATE = `
|
||||
// You are ChatGPT, a large language model trained by {{ServiceProvider}}.
|
||||
// Knowledge cutoff: {{cutoff}}
|
||||
// Current model: {{model}}
|
||||
// Current time: {{time}}
|
||||
// Latex inline: $x^2$
|
||||
// Latex block: $$e=mc^2$$
|
||||
// `;
|
||||
export const DEFAULT_SYSTEM_TEMPLATE = `
|
||||
You are ChatGPT, a large language model trained by {{ServiceProvider}}.
|
||||
Knowledge cutoff: {{cutoff}}
|
||||
Current model: {{model}}
|
||||
Current time: {{time}}
|
||||
Latex inline: \\(x^2\\)
|
||||
Latex inline: $x^2$
|
||||
Latex block: $$e=mc^2$$
|
||||
`;
|
||||
|
||||
@@ -132,8 +119,6 @@ export const GEMINI_SUMMARIZE_MODEL = "gemini-pro";
|
||||
|
||||
export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
default: "2021-09",
|
||||
"gpt-4-turbo": "2023-12",
|
||||
"gpt-4-turbo-2024-04-09": "2023-12",
|
||||
"gpt-4-turbo-preview": "2023-12",
|
||||
"gpt-4-1106-preview": "2023-04",
|
||||
"gpt-4-0125-preview": "2023-12",
|
||||
@@ -141,89 +126,235 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
||||
// After improvements,
|
||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||
"gemini-pro": "2023-12",
|
||||
"gemini-pro-vision": "2023-12",
|
||||
};
|
||||
|
||||
const openaiModels = [
|
||||
"gpt-3.5-turbo",
|
||||
"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",
|
||||
];
|
||||
|
||||
const googleModels = [
|
||||
"gemini-1.0-pro",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-pro-vision",
|
||||
];
|
||||
|
||||
const anthropicModels = [
|
||||
"claude-instant-1.2",
|
||||
"claude-2.0",
|
||||
"claude-2.1",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
];
|
||||
|
||||
export const DEFAULT_MODELS = [
|
||||
...openaiModels.map((name) => ({
|
||||
name,
|
||||
{
|
||||
name: "gpt-4",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
})),
|
||||
...googleModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gpt-4-0314",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-0613",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-32k",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-32k-0314",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-32k-0613",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-turbo-preview",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-1106-preview",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-0125-preview",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-4-vision-preview",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-0125",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-0301",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-0613",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-1106",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-16k",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gpt-3.5-turbo-16k-0613",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "openai",
|
||||
providerName: "OpenAI",
|
||||
providerType: "openai",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gemini-pro",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "google",
|
||||
providerName: "Google",
|
||||
providerType: "google",
|
||||
},
|
||||
})),
|
||||
...anthropicModels.map((name) => ({
|
||||
name,
|
||||
},
|
||||
{
|
||||
name: "gemini-pro-vision",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "google",
|
||||
providerName: "Google",
|
||||
providerType: "google",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude-instant-1.2",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "claude-2.0",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude-2.1",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude-3-opus-20240229",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude-3-sonnet-20240229",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude-3-haiku-20240307",
|
||||
available: true,
|
||||
provider: {
|
||||
id: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
providerType: "anthropic",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CHAT_PAGE_SIZE = 15;
|
||||
export const MAX_RENDER_MSG_COUNT = 45;
|
||||
|
||||
// some famous webdav endpoints
|
||||
export const internalWhiteWebDavEndpoints = [
|
||||
"https://dav.jianguoyun.com/dav/",
|
||||
"https://dav.dropdav.com/",
|
||||
"https://dav.box.com/dav",
|
||||
"https://nanao.teracloud.jp/dav/",
|
||||
"https://webdav.4shared.com/",
|
||||
"https://dav.idrivesync.com",
|
||||
"https://webdav.yandex.com",
|
||||
"https://app.koofr.net/dav/Koofr",
|
||||
];
|
||||
|
||||
export const SIDEBAR_ID = "sidebar";
|
||||
|
@@ -1,301 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
useChatStore,
|
||||
BOT_HELLO,
|
||||
createMessage,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
ModelType,
|
||||
} from "@/app/store";
|
||||
import Locale from "@/app/locales";
|
||||
import { Selector, showConfirm, showToast } 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 { useAllModels } from "@/app/utils/hooks";
|
||||
import useRows from "@/app/hooks/useRows";
|
||||
import SessionConfigModel from "./components/SessionConfigModal";
|
||||
import useScrollToBottom from "@/app/hooks/useScrollToBottom";
|
||||
|
||||
function _Chat() {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputPanelRef = useRef<ChatInputPanelInstance | null>(null);
|
||||
|
||||
const [hitBottom, setHitBottom] = useState(true);
|
||||
|
||||
const [attachImages, setAttachImages] = useState<string[]>([]);
|
||||
|
||||
// auto grow input
|
||||
const { measure, inputRows } = useRows({
|
||||
inputRef,
|
||||
});
|
||||
|
||||
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(scrollRef);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(measure, [userInput]);
|
||||
|
||||
useEffect(() => {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
|
||||
session.messages.forEach((m) => {
|
||||
// check if should stop all stale messages
|
||||
if (m.isError || new Date(m.date).getTime() < stopTiming) {
|
||||
if (m.streaming) {
|
||||
m.streaming = false;
|
||||
}
|
||||
|
||||
if (m.content.length === 0) {
|
||||
m.isError = true;
|
||||
m.content = prettyObject({
|
||||
error: true,
|
||||
message: "empty response",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// auto sync mask config from global config
|
||||
if (session.mask.syncGlobalConfig) {
|
||||
console.log("[Mask] syncing from global, name = ", session.mask.name);
|
||||
session.mask.modelConfig = { ...config.modelConfig };
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const context: RenderMessage[] = useMemo(() => {
|
||||
return session.mask.hideContext ? [] : session.mask.context.slice();
|
||||
}, [session.mask.context, session.mask.hideContext]);
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
if (
|
||||
context.length === 0 &&
|
||||
session.messages.at(0)?.content !== BOT_HELLO.content
|
||||
) {
|
||||
const copiedHello = Object.assign({}, BOT_HELLO);
|
||||
if (!accessStore.isAuthorized()) {
|
||||
copiedHello.content = Locale.Error.Unauthorized;
|
||||
}
|
||||
context.push(copiedHello);
|
||||
}
|
||||
|
||||
// preview messages
|
||||
const renderMessages = useMemo(() => {
|
||||
return context
|
||||
.concat(session.messages as RenderMessage[])
|
||||
.concat(
|
||||
isLoading
|
||||
? [
|
||||
{
|
||||
...createMessage({
|
||||
role: "assistant",
|
||||
content: "……",
|
||||
}),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
.concat(
|
||||
userInput.length > 0 && config.sendPreviewBubble
|
||||
? [
|
||||
{
|
||||
...createMessage(
|
||||
{
|
||||
role: "user",
|
||||
content: userInput,
|
||||
},
|
||||
{
|
||||
customId: "typing",
|
||||
},
|
||||
),
|
||||
preview: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}, [
|
||||
config.sendPreviewBubble,
|
||||
context,
|
||||
isLoading,
|
||||
session.messages,
|
||||
userInput,
|
||||
]);
|
||||
|
||||
const [msgRenderIndex, _setMsgRenderIndex] = useState(
|
||||
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
|
||||
);
|
||||
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
useCommand({
|
||||
fill: setUserInput,
|
||||
submit: (text) => {
|
||||
chatInputPanelRef.current?.doSubmit(text);
|
||||
},
|
||||
code: (text) => {
|
||||
if (accessStore.disableFastLink) return;
|
||||
console.log("[Command] got code from url: ", text);
|
||||
showConfirm(Locale.URLCommand.Code + `code = ${text}`).then((res) => {
|
||||
if (res) {
|
||||
accessStore.update((access) => (access.accessCode = text));
|
||||
}
|
||||
});
|
||||
},
|
||||
settings: (text) => {
|
||||
if (accessStore.disableFastLink) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as {
|
||||
key?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
console.log("[Command] got settings from url: ", payload);
|
||||
|
||||
if (payload.key || payload.url) {
|
||||
showConfirm(
|
||||
Locale.URLCommand.Settings +
|
||||
`\n${JSON.stringify(payload, null, 4)}`,
|
||||
).then((res) => {
|
||||
if (!res) return;
|
||||
if (payload.key) {
|
||||
accessStore.update(
|
||||
(access) => (access.openaiApiKey = payload.key!),
|
||||
);
|
||||
}
|
||||
if (payload.url) {
|
||||
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("[Command] failed to get settings from url: ", text);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// edit / insert message modal
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||
|
||||
// remember unfinished input
|
||||
useEffect(() => {
|
||||
// try to load from local storage
|
||||
const key = UNFINISHED_INPUT(session.id);
|
||||
const mayBeUnfinishedInput = localStorage.getItem(key);
|
||||
if (mayBeUnfinishedInput && userInput.length === 0) {
|
||||
setUserInput(mayBeUnfinishedInput);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
const dom = inputRef.current;
|
||||
return () => {
|
||||
localStorage.setItem(key, dom?.value ?? "");
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const chatinputPanelProps = {
|
||||
inputRef,
|
||||
isMobileScreen,
|
||||
renderMessages,
|
||||
attachImages,
|
||||
userInput,
|
||||
hitBottom,
|
||||
inputRows,
|
||||
setAttachImages,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
showChatSetting: setShowPromptModal,
|
||||
_setMsgRenderIndex,
|
||||
scrollDomToBottom,
|
||||
setAutoScroll,
|
||||
};
|
||||
|
||||
const chatMessagePanelProps = {
|
||||
scrollRef,
|
||||
inputRef,
|
||||
isMobileScreen,
|
||||
msgRenderIndex,
|
||||
userInput,
|
||||
context,
|
||||
renderMessages,
|
||||
setAutoScroll,
|
||||
setMsgRenderIndex: chatInputPanelRef.current?.setMsgRenderIndex,
|
||||
setHitBottom,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-col overflow-hidden bg-chat-panel
|
||||
max-md:absolute max-md:h-[100vh] max-md:w-[100%]
|
||||
md:h-[100%] md:mr-2.5 md:rounded-md
|
||||
`}
|
||||
key={session.id}
|
||||
>
|
||||
<ChatHeader
|
||||
setIsEditingMessage={setIsEditingMessage}
|
||||
setShowExport={setShowExport}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
|
||||
<ChatMessagePanel {...chatMessagePanelProps} />
|
||||
|
||||
<ChatInputPanel ref={chatInputPanelRef} {...chatinputPanelProps} />
|
||||
|
||||
{showExport && (
|
||||
<ExportMessageModal onClose={() => setShowExport(false)} />
|
||||
)}
|
||||
|
||||
{isEditingMessage && (
|
||||
<EditMessageModal
|
||||
onClose={() => {
|
||||
setIsEditingMessage(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PromptToast showToast={!hitBottom} setShowModal={setShowPromptModal} />
|
||||
|
||||
{showPromptModal && (
|
||||
<SessionConfigModel onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const chatStore = useChatStore();
|
||||
const sessionIndex = chatStore.currentSessionIndex;
|
||||
return <_Chat key={sessionIndex}></_Chat>;
|
||||
}
|
@@ -1,277 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ModelType, Theme, useAppConfig } from "@/app/store/config";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { ChatControllerPool } from "@/app/client/controller";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { isVisionModel } from "@/app/utils";
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
|
||||
import BottomIcon from "@/app/icons/bottom.svg";
|
||||
import StopIcon from "@/app/icons/pause.svg";
|
||||
import LoadingButtonIcon from "@/app/icons/loading.svg";
|
||||
import PromptIcon from "@/app/icons/comandIcon.svg";
|
||||
import MaskIcon from "@/app/icons/maskIcon.svg";
|
||||
import BreakIcon from "@/app/icons/eraserIcon.svg";
|
||||
import SettingsIcon from "@/app/icons/configIcon.svg";
|
||||
import ImageIcon from "@/app/icons/uploadImgIcon.svg";
|
||||
import AddCircleIcon from "@/app/icons/addCircle.svg";
|
||||
|
||||
import Popover from "@/app/components/Popover";
|
||||
import ModelSelect from "./ModelSelect";
|
||||
|
||||
export interface Action {
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
isShow: boolean;
|
||||
render?: (key: string) => JSX.Element;
|
||||
icon?: JSX.Element;
|
||||
placement: "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatActions(props: {
|
||||
uploadImage: () => void;
|
||||
setAttachImages: (images: string[]) => void;
|
||||
setUploading: (uploading: boolean) => void;
|
||||
showChatSetting: () => void;
|
||||
scrollToBottom: () => void;
|
||||
showPromptHints: () => void;
|
||||
hitBottom: boolean;
|
||||
uploading: boolean;
|
||||
isMobileScreen: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// switch themes
|
||||
const theme = config.theme;
|
||||
function nextTheme() {
|
||||
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
|
||||
const themeIndex = themes.indexOf(theme);
|
||||
const nextIndex = (themeIndex + 1) % themes.length;
|
||||
const nextTheme = themes[nextIndex];
|
||||
config.update((config) => (config.theme = nextTheme));
|
||||
}
|
||||
|
||||
// stop all responses
|
||||
const couldStop = ChatControllerPool.hasPending();
|
||||
const stopAll = () => ChatControllerPool.stopAll();
|
||||
|
||||
// switch model
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
const allModels = useAllModels();
|
||||
const models = useMemo(
|
||||
() => allModels.filter((m) => m.available),
|
||||
[allModels],
|
||||
);
|
||||
const [showUploadImage, setShowUploadImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const show = isVisionModel(currentModel);
|
||||
setShowUploadImage(show);
|
||||
if (!show) {
|
||||
props.setAttachImages([]);
|
||||
props.setUploading(false);
|
||||
}
|
||||
|
||||
// if current model is not available
|
||||
// switch to first available model
|
||||
const isUnavaliableModel = !models.some((m) => m.name === currentModel);
|
||||
if (isUnavaliableModel && models.length > 0) {
|
||||
const nextModel = models[0].name as ModelType;
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.mask.modelConfig.model = nextModel),
|
||||
);
|
||||
showToast(nextModel);
|
||||
}
|
||||
}, [chatStore, currentModel, models]);
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
onClick: stopAll,
|
||||
text: Locale.Chat.InputActions.Stop,
|
||||
isShow: couldStop,
|
||||
icon: <StopIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
text: currentModel,
|
||||
isShow: !props.isMobileScreen,
|
||||
render: (key: string) => <ModelSelect key={key} />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: props.scrollToBottom,
|
||||
text: Locale.Chat.InputActions.ToBottom,
|
||||
isShow: !props.hitBottom,
|
||||
icon: <BottomIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: props.uploadImage,
|
||||
text: Locale.Chat.InputActions.UploadImage,
|
||||
isShow: showUploadImage,
|
||||
icon: props.uploading ? <LoadingButtonIcon /> : <ImageIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
// {
|
||||
// onClick: nextTheme,
|
||||
// text: Locale.Chat.InputActions.Theme[theme],
|
||||
// isShow: true,
|
||||
// icon: (
|
||||
// <>
|
||||
// {theme === Theme.Auto ? (
|
||||
// <AutoIcon />
|
||||
// ) : theme === Theme.Light ? (
|
||||
// <LightIcon />
|
||||
// ) : theme === Theme.Dark ? (
|
||||
// <DarkIcon />
|
||||
// ) : null}
|
||||
// </>
|
||||
// ),
|
||||
// placement: "left",
|
||||
// },
|
||||
{
|
||||
onClick: props.showPromptHints,
|
||||
text: Locale.Chat.InputActions.Prompt,
|
||||
isShow: true,
|
||||
icon: <PromptIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
navigate(Path.Masks);
|
||||
},
|
||||
text: Locale.Chat.InputActions.Masks,
|
||||
isShow: true,
|
||||
icon: <MaskIcon />,
|
||||
placement: "left",
|
||||
},
|
||||
{
|
||||
onClick: () => {
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
if (session.clearContextIndex === session.messages.length) {
|
||||
session.clearContextIndex = undefined;
|
||||
} else {
|
||||
session.clearContextIndex = session.messages.length;
|
||||
session.memoryPrompt = ""; // will clear memory
|
||||
}
|
||||
});
|
||||
},
|
||||
text: Locale.Chat.InputActions.Clear,
|
||||
isShow: true,
|
||||
icon: <BreakIcon />,
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
onClick: props.showChatSetting,
|
||||
text: Locale.Chat.InputActions.Settings,
|
||||
isShow: true,
|
||||
icon: <SettingsIcon />,
|
||||
placement: "right",
|
||||
},
|
||||
];
|
||||
|
||||
if (props.isMobileScreen) {
|
||||
const content = (
|
||||
<div className="w-[100%]">
|
||||
{actions
|
||||
.filter((v) => v.isShow && v.icon)
|
||||
.map((act) => {
|
||||
return (
|
||||
<div
|
||||
key={act.text}
|
||||
className={`flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer follow-parent-svg default-icon-color`}
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
<div className="flex-1 font-common text-actions-popover-menu-item">
|
||||
{act.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
placement="rt"
|
||||
noArrow
|
||||
popoverClassName="border border-chat-actions-popover-mobile rounded-md shadow-chat-actions-popover-mobile w-actions-popover bg-chat-actions-popover-panel-mobile "
|
||||
className=" cursor-pointer follow-parent-svg default-icon-color"
|
||||
>
|
||||
<AddCircleIcon />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const popoverClassName = `bg-chat-actions-btn-popover px-3 py-2.5 text-text-chat-actions-btn-popover text-sm-title rounded-md`;
|
||||
|
||||
return (
|
||||
<div className={`flex gap-2 item-center ${props.className}`}>
|
||||
{actions
|
||||
.filter((v) => v.placement === "left" && v.isShow)
|
||||
.map((act, ind) => {
|
||||
if (act.render) {
|
||||
return (
|
||||
<div className={`${act.className ?? ""}`} key={act.text}>
|
||||
{act.render(act.text)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
key={act.text}
|
||||
content={act.text}
|
||||
popoverClassName={`${popoverClassName}`}
|
||||
placement={ind ? "t" : "lt"}
|
||||
className={`${act.className ?? ""}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer h-[32px] w-[32px] flex items-center justify-center transition duration-300 ease-in-out
|
||||
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
|
||||
follow-parent-svg default-icon-color
|
||||
`}
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1"></div>
|
||||
{actions
|
||||
.filter((v) => v.placement === "right" && v.isShow)
|
||||
.map((act, ind, arr) => {
|
||||
return (
|
||||
<Popover
|
||||
key={act.text}
|
||||
content={act.text}
|
||||
popoverClassName={`${popoverClassName}`}
|
||||
placement={ind === arr.length - 1 ? "rt" : "t"}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer h-[32px] w-[32px] flex items-center transition duration-300 ease-in-out justify-center
|
||||
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
|
||||
follow-parent-svg default-icon-color
|
||||
`}
|
||||
onClick={act.onClick}
|
||||
>
|
||||
{act.icon}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,91 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Locale from "@/app/locales";
|
||||
import { Path } from "@/app/constant";
|
||||
import { DEFAULT_TOPIC, useChatStore } from "@/app/store/chat";
|
||||
|
||||
import LogIcon from "@/app/icons/logIcon.svg";
|
||||
import GobackIcon from "@/app/icons/goback.svg";
|
||||
import ShareIcon from "@/app/icons/shareIcon.svg";
|
||||
import ModelSelect from "./ModelSelect";
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
isMobileScreen: boolean;
|
||||
setIsEditingMessage: (v: boolean) => void;
|
||||
setShowExport: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ChatHeader(props: ChatHeaderProps) {
|
||||
const { isMobileScreen, setIsEditingMessage, setShowExport } = props;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
absolute w-[100%] backdrop-blur-[30px] z-20 flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap
|
||||
sm:border-b sm:border-chat-header-bottom
|
||||
max-md:h-menu-title-mobile
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
className={`absolute z-[-1] top-0 left-0 w-[100%] h-[100%] opacity-85 backdrop-blur-[20px] sm:bg-chat-panel-header-mask bg-chat-panel-header-mobile flex flex-0 justify-between items-center gap-chat-header-gap`}
|
||||
>
|
||||
{" "}
|
||||
</div>
|
||||
|
||||
{isMobileScreen ? (
|
||||
<div
|
||||
className=" cursor-pointer follow-parent-svg default-icon-color"
|
||||
onClick={() => navigate(Path.Home)}
|
||||
>
|
||||
<GobackIcon />
|
||||
</div>
|
||||
) : (
|
||||
<LogIcon />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex-1
|
||||
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
|
||||
md:mr-4
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
line-clamp-1 cursor-pointer text-text-chat-header-title text-chat-header-title font-common
|
||||
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5
|
||||
`}
|
||||
onClickCapture={() => setIsEditingMessage(true)}
|
||||
>
|
||||
{!session.topic ? DEFAULT_TOPIC : session.topic}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
text-text-chat-header-subtitle text-sm
|
||||
max-md:text-sm-mobile-tab max-md:leading-4
|
||||
`}
|
||||
>
|
||||
{isMobileScreen ? (
|
||||
<ModelSelect />
|
||||
) : (
|
||||
Locale.Chat.SubTitle(session.messages.length)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className=" cursor-pointer hover:bg-hovered-btn p-1.5 rounded-action-btn follow-parent-svg default-icon-color"
|
||||
onClick={() => {
|
||||
setShowExport(true);
|
||||
}}
|
||||
>
|
||||
<ShareIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,322 +0,0 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import useUploadImage from "@/app/hooks/useUploadImage";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import useSubmitHandler from "@/app/hooks/useSubmitHandler";
|
||||
import { CHAT_PAGE_SIZE, LAST_INPUT_KEY, Path } from "@/app/constant";
|
||||
import { ChatCommandPrefix, useChatCommand } from "@/app/command";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { usePromptStore } from "@/app/store/prompt";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import usePaste from "@/app/hooks/usePaste";
|
||||
|
||||
import { ChatActions } from "./ChatActions";
|
||||
import PromptHints, { RenderPompt } from "./PromptHint";
|
||||
|
||||
// import CEIcon from "@/app/icons/command&enterIcon.svg";
|
||||
// import EnterIcon from "@/app/icons/enterIcon.svg";
|
||||
import SendIcon from "@/app/icons/sendIcon.svg";
|
||||
|
||||
import Btn from "@/app/components/Btn";
|
||||
import Thumbnail from "@/app/components/ThumbnailImg";
|
||||
|
||||
export interface ChatInputPanelProps {
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isMobileScreen: boolean;
|
||||
renderMessages: any[];
|
||||
attachImages: string[];
|
||||
userInput: string;
|
||||
hitBottom: boolean;
|
||||
inputRows: number;
|
||||
setAttachImages: (imgs: string[]) => void;
|
||||
setUserInput: (v: string) => void;
|
||||
setIsLoading: (value: boolean) => void;
|
||||
showChatSetting: (value: boolean) => void;
|
||||
_setMsgRenderIndex: (value: number) => void;
|
||||
setAutoScroll: (value: boolean) => void;
|
||||
scrollDomToBottom: () => void;
|
||||
}
|
||||
|
||||
export interface ChatInputPanelInstance {
|
||||
setUploading: (v: boolean) => void;
|
||||
doSubmit: (userInput: string) => void;
|
||||
setMsgRenderIndex: (v: number) => void;
|
||||
}
|
||||
|
||||
// only search prompts when user input is short
|
||||
const SEARCH_TEXT_LIMIT = 30;
|
||||
|
||||
export default forwardRef<ChatInputPanelInstance, ChatInputPanelProps>(
|
||||
function ChatInputPanel(props, ref) {
|
||||
const {
|
||||
attachImages,
|
||||
inputRef,
|
||||
setAttachImages,
|
||||
userInput,
|
||||
isMobileScreen,
|
||||
setUserInput,
|
||||
setIsLoading,
|
||||
showChatSetting,
|
||||
renderMessages,
|
||||
_setMsgRenderIndex,
|
||||
hitBottom,
|
||||
inputRows,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
} = props;
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [promptHints, setPromptHints] = useState<RenderPompt[]>([]);
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { uploadImage } = useUploadImage(attachImages, {
|
||||
emitImages: setAttachImages,
|
||||
setUploading,
|
||||
});
|
||||
const { submitKey, shouldSubmit } = useSubmitHandler();
|
||||
|
||||
const autoFocus = !isMobileScreen; // wont auto focus on mobile screen
|
||||
|
||||
// chat commands shortcuts
|
||||
const chatCommands = useChatCommand({
|
||||
new: () => chatStore.newSession(),
|
||||
newm: () => navigate(Path.NewChat),
|
||||
prev: () => chatStore.nextSession(-1),
|
||||
next: () => chatStore.nextSession(1),
|
||||
clear: () =>
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = session.messages.length),
|
||||
),
|
||||
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
|
||||
});
|
||||
|
||||
// prompt hints
|
||||
const promptStore = usePromptStore();
|
||||
const onSearch = useDebouncedCallback(
|
||||
(text: string) => {
|
||||
const matchedPrompts = promptStore.search(text);
|
||||
setPromptHints(matchedPrompts);
|
||||
},
|
||||
100,
|
||||
{ leading: true, trailing: true },
|
||||
);
|
||||
|
||||
// check if should send message
|
||||
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// if ArrowUp and no userInput, fill with last input
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
userInput.length <= 0 &&
|
||||
!(e.metaKey || e.altKey || e.ctrlKey)
|
||||
) {
|
||||
setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (shouldSubmit(e) && promptHints.length === 0) {
|
||||
doSubmit(userInput);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onPromptSelect = (prompt: RenderPompt) => {
|
||||
setTimeout(() => {
|
||||
setPromptHints([]);
|
||||
|
||||
const matchedChatCommand = chatCommands.match(prompt.content);
|
||||
if (matchedChatCommand.matched) {
|
||||
// if user is selecting a chat command, just trigger it
|
||||
matchedChatCommand.invoke();
|
||||
setUserInput("");
|
||||
} else {
|
||||
// or fill the prompt
|
||||
setUserInput(prompt.content);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const doSubmit = (userInput: string) => {
|
||||
if (userInput.trim() === "") return;
|
||||
const matchCommand = chatCommands.match(userInput);
|
||||
if (matchCommand.matched) {
|
||||
setUserInput("");
|
||||
setPromptHints([]);
|
||||
matchCommand.invoke();
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
chatStore
|
||||
.onUserInput(userInput, attachImages)
|
||||
.then(() => setIsLoading(false));
|
||||
setAttachImages([]);
|
||||
localStorage.setItem(LAST_INPUT_KEY, userInput);
|
||||
setUserInput("");
|
||||
setPromptHints([]);
|
||||
if (!isMobileScreen) inputRef.current?.focus();
|
||||
setAutoScroll(true);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setUploading,
|
||||
doSubmit,
|
||||
setMsgRenderIndex,
|
||||
}));
|
||||
|
||||
function scrollToBottom() {
|
||||
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
||||
scrollDomToBottom();
|
||||
}
|
||||
|
||||
const onInput = (text: string) => {
|
||||
setUserInput(text);
|
||||
const n = text.trim().length;
|
||||
|
||||
// clear search results
|
||||
if (n === 0) {
|
||||
setPromptHints([]);
|
||||
} else if (text.startsWith(ChatCommandPrefix)) {
|
||||
setPromptHints(chatCommands.search(text));
|
||||
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
|
||||
// check if need to trigger auto completion
|
||||
if (text.startsWith("/")) {
|
||||
let searchText = text.slice(1);
|
||||
onSearch(searchText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function setMsgRenderIndex(newIndex: number) {
|
||||
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
|
||||
newIndex = Math.max(0, newIndex);
|
||||
_setMsgRenderIndex(newIndex);
|
||||
}
|
||||
|
||||
const { handlePaste } = usePaste(attachImages, {
|
||||
emitImages: setAttachImages,
|
||||
setUploading,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative w-[100%] box-border
|
||||
max-md:rounded-tl-md max-md:rounded-tr-md
|
||||
md:border-t md:border-chat-input-top
|
||||
`}
|
||||
>
|
||||
<PromptHints
|
||||
prompts={promptHints}
|
||||
onPromptSelect={onPromptSelect}
|
||||
className=" border-chat-input-top"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex
|
||||
max-md:flex-row-reverse max-md:items-center max-md:gap-2 max-md:p-3
|
||||
md:flex-col md:px-5 md:pb-5
|
||||
`}
|
||||
>
|
||||
<ChatActions
|
||||
uploadImage={uploadImage}
|
||||
setAttachImages={setAttachImages}
|
||||
setUploading={setUploading}
|
||||
showChatSetting={() => showChatSetting(true)}
|
||||
scrollToBottom={scrollToBottom}
|
||||
hitBottom={hitBottom}
|
||||
uploading={uploading}
|
||||
showPromptHints={() => {
|
||||
// Click again to close
|
||||
if (promptHints.length > 0) {
|
||||
setPromptHints([]);
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.focus();
|
||||
setUserInput("/");
|
||||
onSearch("");
|
||||
}}
|
||||
className={`
|
||||
md:py-2.5
|
||||
`}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
<label
|
||||
className={`
|
||||
cursor-text flex flex-col bg-chat-panel-input-hood border border-chat-input-hood
|
||||
focus-within:border-chat-input-hood-focus sm:focus-within:shadow-chat-input-hood-focus-shadow
|
||||
rounded-chat-input p-3 gap-3 max-md:flex-1
|
||||
md:rounded-md md:p-4 md:gap-4
|
||||
`}
|
||||
htmlFor="chat-input"
|
||||
>
|
||||
{attachImages.length != 0 && (
|
||||
<div className={`flex gap-2`}>
|
||||
{attachImages.map((image, index) => {
|
||||
return (
|
||||
<Thumbnail
|
||||
key={index}
|
||||
deleteImage={() => {
|
||||
setAttachImages(
|
||||
attachImages.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
image={image}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
id="chat-input"
|
||||
ref={inputRef}
|
||||
className={`
|
||||
leading-[19px] flex-1 focus:outline-none focus:shadow-none focus:border-none resize-none bg-inherit text-text-input
|
||||
max-md:h-chat-input-mobile
|
||||
md:min-h-chat-input
|
||||
`}
|
||||
placeholder={
|
||||
isMobileScreen
|
||||
? Locale.Chat.Input(submitKey, isMobileScreen)
|
||||
: undefined
|
||||
}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
value={userInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={scrollToBottom}
|
||||
onClick={scrollToBottom}
|
||||
onPaste={handlePaste}
|
||||
rows={inputRows}
|
||||
autoFocus={autoFocus}
|
||||
style={{
|
||||
fontSize: config.fontSize,
|
||||
}}
|
||||
/>
|
||||
{!isMobileScreen && (
|
||||
<div className="flex items-center justify-center text-sm gap-3">
|
||||
<div className="flex-1"> </div>
|
||||
<div className="text-text-chat-input-placeholder font-common line-clamp-1">
|
||||
{Locale.Chat.Input(submitKey)}
|
||||
</div>
|
||||
<Btn
|
||||
className="min-w-[77px]"
|
||||
icon={<SendIcon />}
|
||||
text={Locale.Chat.Send}
|
||||
disabled={!userInput.length}
|
||||
type="primary"
|
||||
onClick={() => doSubmit(userInput)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
@@ -1,246 +0,0 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||
import { CHAT_PAGE_SIZE } from "@/app/constant";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import { getMessageTextContent, selectOrCopy } from "@/app/utils";
|
||||
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
|
||||
import { Avatar } from "@/app/components/emoji";
|
||||
import { MaskAvatar } from "@/app/components/mask";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import ClearContextDivider from "./ClearContextDivider";
|
||||
import dynamic from "next/dynamic";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import MessageActions, { RenderMessage } from "./MessageActions";
|
||||
import Imgs from "@/app/components/Imgs";
|
||||
|
||||
export type { RenderMessage };
|
||||
|
||||
export interface ChatMessagePanelProps {
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isMobileScreen: boolean;
|
||||
msgRenderIndex: number;
|
||||
userInput: string;
|
||||
context: any[];
|
||||
renderMessages: RenderMessage[];
|
||||
scrollDomToBottom: () => void;
|
||||
setAutoScroll?: (value: boolean) => void;
|
||||
setMsgRenderIndex?: (newIndex: number) => void;
|
||||
setHitBottom?: (value: boolean) => void;
|
||||
setUserInput?: (v: string) => void;
|
||||
setIsLoading?: (value: boolean) => void;
|
||||
setShowPromptModal?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
let MarkdownLoadedCallback: () => void;
|
||||
|
||||
const Markdown = dynamic(
|
||||
async () => {
|
||||
const bundle = await import("@/app/components/markdown");
|
||||
|
||||
if (MarkdownLoadedCallback) {
|
||||
MarkdownLoadedCallback();
|
||||
}
|
||||
return bundle.Markdown;
|
||||
},
|
||||
{
|
||||
loading: () => <LoadingIcon />,
|
||||
},
|
||||
);
|
||||
|
||||
export default function ChatMessagePanel(props: ChatMessagePanelProps) {
|
||||
const {
|
||||
scrollRef,
|
||||
inputRef,
|
||||
setAutoScroll,
|
||||
setMsgRenderIndex,
|
||||
isMobileScreen,
|
||||
msgRenderIndex,
|
||||
setHitBottom,
|
||||
setUserInput,
|
||||
userInput,
|
||||
context,
|
||||
renderMessages,
|
||||
setIsLoading,
|
||||
setShowPromptModal,
|
||||
scrollDomToBottom,
|
||||
} = props;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const config = useAppConfig();
|
||||
const fontSize = config.fontSize;
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
containerRef: scrollRef,
|
||||
delay: 0,
|
||||
offsetDistance: 20,
|
||||
});
|
||||
|
||||
// clear context index = context length + index in messages
|
||||
const clearContextIndex =
|
||||
(session.clearContextIndex ?? -1) >= 0
|
||||
? session.clearContextIndex! + context.length - msgRenderIndex
|
||||
: -1;
|
||||
|
||||
if (!MarkdownLoadedCallback) {
|
||||
MarkdownLoadedCallback = () => {
|
||||
window.setTimeout(scrollDomToBottom, 100);
|
||||
};
|
||||
}
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const endRenderIndex = Math.min(
|
||||
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
||||
renderMessages.length,
|
||||
);
|
||||
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
||||
}, [msgRenderIndex, renderMessages]);
|
||||
|
||||
const onChatBodyScroll = (e: HTMLElement) => {
|
||||
const bottomHeight = e.scrollTop + e.clientHeight;
|
||||
const edgeThreshold = e.clientHeight;
|
||||
|
||||
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
|
||||
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
|
||||
const isHitBottom =
|
||||
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
|
||||
|
||||
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
|
||||
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
|
||||
|
||||
if (isTouchTopEdge && !isTouchBottomEdge) {
|
||||
setMsgRenderIndex?.(prevPageMsgIndex);
|
||||
} else if (isTouchBottomEdge) {
|
||||
setMsgRenderIndex?.(nextPageMsgIndex);
|
||||
}
|
||||
|
||||
setHitBottom?.(isHitBottom);
|
||||
setAutoScroll?.(isHitBottom);
|
||||
};
|
||||
|
||||
const onRightClick = (e: any, message: ChatMessage) => {
|
||||
// copy to clipboard
|
||||
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
|
||||
if (userInput.length === 0) {
|
||||
setUserInput?.(getMessageTextContent(message));
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pt-[80px] relative flex-1 overscroll-y-none overflow-y-auto overflow-x-hidden px-3 pb-6 md:bg-chat-panel-message bg-chat-panel-message-mobile`}
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
onMouseDown={() => inputRef.current?.blur()}
|
||||
onTouchStart={() => {
|
||||
inputRef.current?.blur();
|
||||
setAutoScroll?.(false);
|
||||
}}
|
||||
>
|
||||
{messages.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
const isContext = i < context.length;
|
||||
|
||||
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
||||
|
||||
const actionsBarPosition =
|
||||
position?.id === message.id &&
|
||||
position?.poi.overlapPositions[Orientation.bottom]
|
||||
? "bottom-[calc(100%-0.25rem)]"
|
||||
: "top-[calc(100%-0.25rem)]";
|
||||
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<div
|
||||
className={`flex mt-6 gap-2 ${isUser ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className={`relative flex-0`}>
|
||||
{isUser ? (
|
||||
<Avatar avatar={config.avatar} />
|
||||
) : (
|
||||
<>
|
||||
{["system"].includes(message.role) ? (
|
||||
<Avatar avatar="2699-fe0f" />
|
||||
) : (
|
||||
<MaskAvatar
|
||||
avatar={session.mask.avatar}
|
||||
model={message.model || session.mask.modelConfig.model}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`group relative flex ${
|
||||
isUser ? "flex-row-reverse" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={` pointer-events-none text-text-chat-message-date text-right font-common whitespace-nowrap transition-all duration-500 text-sm absolute z-1 ${
|
||||
isUser ? "right-0" : "left-0"
|
||||
} bottom-[100%] hidden group-hover:block`}
|
||||
>
|
||||
{isContext
|
||||
? Locale.Chat.IsContext
|
||||
: message.date.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 select-text break-words font-common text-sm-title ${
|
||||
isUser
|
||||
? "rounded-user-message bg-chat-panel-message-user"
|
||||
: "rounded-bot-message bg-chat-panel-message-bot"
|
||||
} box-border peer py-2 px-3`}
|
||||
onPointerMoveCapture={(e) =>
|
||||
getRelativePosition(e.currentTarget, message.id)
|
||||
}
|
||||
>
|
||||
<Markdown
|
||||
content={getMessageTextContent(message)}
|
||||
loading={
|
||||
(message.preview || message.streaming) &&
|
||||
message.content.length === 0 &&
|
||||
!isUser
|
||||
}
|
||||
onContextMenu={(e) => onRightClick(e, message)}
|
||||
onDoubleClickCapture={() => {
|
||||
if (!isMobileScreen) return;
|
||||
setUserInput?.(getMessageTextContent(message));
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
parentRef={scrollRef}
|
||||
defaultShow={i >= messages.length - 6}
|
||||
className={`leading-6 max-w-message-width ${
|
||||
isUser
|
||||
? " text-text-chat-message-markdown-user"
|
||||
: "text-text-chat-message-markdown-bot"
|
||||
}`}
|
||||
/>
|
||||
<Imgs message={message} />
|
||||
</div>
|
||||
<MessageActions
|
||||
className={actionsBarPosition}
|
||||
message={message}
|
||||
inputRef={inputRef}
|
||||
isUser={isUser}
|
||||
isContext={isContext}
|
||||
setIsLoading={setIsLoading}
|
||||
setShowPromptModal={setShowPromptModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,46 +0,0 @@
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
|
||||
export default function ClearContextDivider() {
|
||||
const chatStore = useChatStore();
|
||||
const { isMobileScreen } = useAppConfig();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-6 mb-8 flex items-center justify-center gap-2.5 max-md:cursor-pointer`}
|
||||
onClick={() => {
|
||||
if (!isMobileScreen) {
|
||||
return;
|
||||
}
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = undefined),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
|
||||
<div className="flex items-center justify-between gap-1 text-sm">
|
||||
<div className={`text-text-chat-panel-message-clear`}>
|
||||
{Locale.Context.Clear}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
text-text-chat-panel-message-clear-revert underline font-common
|
||||
md:cursor-pointer
|
||||
`}
|
||||
onClick={() => {
|
||||
if (isMobileScreen) {
|
||||
return;
|
||||
}
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.clearContextIndex = undefined),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Locale.Context.Revert}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { List, ListItem, Modal } from "@/app/components/ui-lib";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { ContextPrompts } from "@/app/components/mask";
|
||||
|
||||
import CancelIcon from "@/app/icons/cancel.svg";
|
||||
import ConfirmIcon from "@/app/icons/confirm.svg";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export function EditMessageModal(props: { onClose: () => void }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const [messages, setMessages] = useState(session.messages.slice());
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Chat.EditMessage.Title}
|
||||
onClose={props.onClose}
|
||||
actions={[
|
||||
<IconButton
|
||||
text={Locale.UI.Cancel}
|
||||
icon={<CancelIcon />}
|
||||
key="cancel"
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
}}
|
||||
/>,
|
||||
<IconButton
|
||||
type="primary"
|
||||
text={Locale.UI.Confirm}
|
||||
icon={<ConfirmIcon />}
|
||||
key="ok"
|
||||
onClick={() => {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.messages = messages),
|
||||
);
|
||||
props.onClose();
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Chat.EditMessage.Topic.Title}
|
||||
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={session.topic}
|
||||
onChange={(e) =>
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.topic = e || ""),
|
||||
)
|
||||
}
|
||||
className=" text-center"
|
||||
></Input>
|
||||
</ListItem>
|
||||
</List>
|
||||
<ContextPrompts
|
||||
context={messages}
|
||||
updateContext={(updater) => {
|
||||
const newMessages = messages.slice();
|
||||
updater(newMessages);
|
||||
setMessages(newMessages);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,295 +0,0 @@
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import StopIcon from "@/app/icons/pause.svg";
|
||||
import DeleteRequestIcon from "@/app/icons/deleteRequestIcon.svg";
|
||||
import RetryRequestIcon from "@/app/icons/retryRequestIcon.svg";
|
||||
import CopyRequestIcon from "@/app/icons/copyRequestIcon.svg";
|
||||
import EditRequestIcon from "@/app/icons/editRequestIcon.svg";
|
||||
import PinRequestIcon from "@/app/icons/pinRequestIcon.svg";
|
||||
import { showPrompt, showToast } from "@/app/components/ui-lib";
|
||||
import {
|
||||
copyToClipboard,
|
||||
getMessageImages,
|
||||
getMessageTextContent,
|
||||
} from "@/app/utils";
|
||||
import { MultimodalContent } from "@/app/client/api";
|
||||
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||
import ActionsBar from "@/app/components/ActionsBar";
|
||||
import { ChatControllerPool } from "@/app/client/controller";
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type RenderMessage = ChatMessage & { preview?: boolean };
|
||||
|
||||
export interface MessageActionsProps {
|
||||
message: RenderMessage;
|
||||
isUser: boolean;
|
||||
isContext: boolean;
|
||||
showActions?: boolean;
|
||||
inputRef: RefObject<HTMLTextAreaElement>;
|
||||
className?: string;
|
||||
setIsLoading?: (value: boolean) => void;
|
||||
setShowPromptModal?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const genActionsShema = (
|
||||
message: RenderMessage,
|
||||
{
|
||||
onEdit,
|
||||
onCopy,
|
||||
onPinMessage,
|
||||
onDelete,
|
||||
onResend,
|
||||
onUserStop,
|
||||
}: Record<
|
||||
| "onEdit"
|
||||
| "onCopy"
|
||||
| "onPinMessage"
|
||||
| "onDelete"
|
||||
| "onResend"
|
||||
| "onUserStop",
|
||||
(message: RenderMessage) => void
|
||||
>,
|
||||
) => {
|
||||
const className =
|
||||
" !p-1 hover:bg-chat-message-actions-btn-hovered !rounded-actions-bar-btn ";
|
||||
return [
|
||||
{
|
||||
id: "Edit",
|
||||
icons: <EditRequestIcon />,
|
||||
title: "Edit",
|
||||
className,
|
||||
onClick: () => onEdit(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Copy,
|
||||
icons: <CopyRequestIcon />,
|
||||
title: Locale.Chat.Actions.Copy,
|
||||
className,
|
||||
onClick: () => onCopy(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Pin,
|
||||
icons: <PinRequestIcon />,
|
||||
title: Locale.Chat.Actions.Pin,
|
||||
className,
|
||||
onClick: () => onPinMessage(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Delete,
|
||||
icons: <DeleteRequestIcon />,
|
||||
title: Locale.Chat.Actions.Delete,
|
||||
className,
|
||||
onClick: () => onDelete(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Retry,
|
||||
icons: <RetryRequestIcon />,
|
||||
title: Locale.Chat.Actions.Retry,
|
||||
className,
|
||||
onClick: () => onResend(message),
|
||||
},
|
||||
{
|
||||
id: Locale.Chat.Actions.Stop,
|
||||
icons: <StopIcon />,
|
||||
title: Locale.Chat.Actions.Stop,
|
||||
className,
|
||||
onClick: () => onUserStop(message),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
enum GroupType {
|
||||
"streaming" = "streaming",
|
||||
"isContext" = "isContext",
|
||||
"normal" = "normal",
|
||||
}
|
||||
|
||||
const groupsTypes = {
|
||||
[GroupType.streaming]: [[Locale.Chat.Actions.Stop]],
|
||||
[GroupType.isContext]: [["Edit"]],
|
||||
[GroupType.normal]: [
|
||||
[
|
||||
Locale.Chat.Actions.Retry,
|
||||
"Edit",
|
||||
Locale.Chat.Actions.Copy,
|
||||
Locale.Chat.Actions.Pin,
|
||||
Locale.Chat.Actions.Delete,
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default function MessageActions(props: MessageActionsProps) {
|
||||
const {
|
||||
className,
|
||||
message,
|
||||
isUser,
|
||||
isContext,
|
||||
showActions = true,
|
||||
setIsLoading,
|
||||
inputRef,
|
||||
setShowPromptModal,
|
||||
} = props;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
|
||||
const deleteMessage = (msgId?: string) => {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) =>
|
||||
(session.messages = session.messages.filter((m) => m.id !== msgId)),
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (message: ChatMessage) => {
|
||||
deleteMessage(message.id);
|
||||
};
|
||||
|
||||
const onResend = (message: ChatMessage) => {
|
||||
// when it is resending a message
|
||||
// 1. for a user's message, find the next bot response
|
||||
// 2. for a bot's message, find the last user's input
|
||||
// 3. delete original user input and bot's message
|
||||
// 4. resend the user's input
|
||||
|
||||
const resendingIndex = session.messages.findIndex(
|
||||
(m) => m.id === message.id,
|
||||
);
|
||||
|
||||
if (resendingIndex < 0 || resendingIndex >= session.messages.length) {
|
||||
console.error("[Chat] failed to find resending message", message);
|
||||
return;
|
||||
}
|
||||
|
||||
let userMessage: ChatMessage | undefined;
|
||||
let botMessage: ChatMessage | undefined;
|
||||
|
||||
if (message.role === "assistant") {
|
||||
// if it is resending a bot's message, find the user input for it
|
||||
botMessage = message;
|
||||
for (let i = resendingIndex; i >= 0; i -= 1) {
|
||||
if (session.messages[i].role === "user") {
|
||||
userMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (message.role === "user") {
|
||||
// if it is resending a user's input, find the bot's response
|
||||
userMessage = message;
|
||||
for (let i = resendingIndex; i < session.messages.length; i += 1) {
|
||||
if (session.messages[i].role === "assistant") {
|
||||
botMessage = session.messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessage === undefined) {
|
||||
console.error("[Chat] failed to resend", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// delete the original messages
|
||||
deleteMessage(userMessage.id);
|
||||
deleteMessage(botMessage?.id);
|
||||
|
||||
// resend the message
|
||||
setIsLoading?.(true);
|
||||
const textContent = getMessageTextContent(userMessage);
|
||||
const images = getMessageImages(userMessage);
|
||||
chatStore
|
||||
.onUserInput(textContent, images)
|
||||
.then(() => setIsLoading?.(false));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onPinMessage = (message: ChatMessage) => {
|
||||
chatStore.updateCurrentSession((session) =>
|
||||
session.mask.context.push(message),
|
||||
);
|
||||
|
||||
showToast(Locale.Chat.Actions.PinToastContent, {
|
||||
text: Locale.Chat.Actions.PinToastAction,
|
||||
onClick: () => {
|
||||
setShowPromptModal?.(true);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// stop response
|
||||
const onUserStop = (message: ChatMessage) => {
|
||||
ChatControllerPool.stop(session.id, message.id);
|
||||
};
|
||||
|
||||
const onEdit = async () => {
|
||||
const newMessage = await showPrompt(
|
||||
Locale.Chat.Actions.Edit,
|
||||
getMessageTextContent(message),
|
||||
10,
|
||||
);
|
||||
let newContent: string | MultimodalContent[] = newMessage;
|
||||
const images = getMessageImages(message);
|
||||
if (images.length > 0) {
|
||||
newContent = [{ type: "text", text: newMessage }];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
newContent.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: images[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
const m = session.mask.context
|
||||
.concat(session.messages)
|
||||
.find((m) => m.id === message.id);
|
||||
if (m) {
|
||||
m.content = newContent;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onCopy = () => copyToClipboard(getMessageTextContent(message));
|
||||
|
||||
const groupsType = [
|
||||
message.streaming && GroupType.streaming,
|
||||
isContext && GroupType.isContext,
|
||||
GroupType.normal,
|
||||
].find((i) => i) as GroupType;
|
||||
|
||||
return (
|
||||
showActions && (
|
||||
<div
|
||||
className={`
|
||||
absolute z-10 w-[100%]
|
||||
${isUser ? "right-0" : "left-0"}
|
||||
transition-all duration-300
|
||||
opacity-0
|
||||
pointer-events-none
|
||||
group-hover:opacity-100
|
||||
group-hover:pointer-events-auto
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<ActionsBar
|
||||
actionsShema={genActionsShema(message, {
|
||||
onCopy,
|
||||
onDelete,
|
||||
onPinMessage,
|
||||
onEdit,
|
||||
onResend,
|
||||
onUserStop,
|
||||
})}
|
||||
groups={groupsTypes[groupsType]}
|
||||
className={`
|
||||
float-right flex flex-row gap-1 p-1
|
||||
bg-chat-message-actions
|
||||
rounded-md
|
||||
shadow-message-actions-bar
|
||||
dark:bg-none
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
@@ -1,159 +0,0 @@
|
||||
import Popover from "@/app/components/Popover";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import useRelativePosition, {
|
||||
Orientation,
|
||||
} from "@/app/hooks/useRelativePosition";
|
||||
import Locale from "@/app/locales";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import { ModelType, useAppConfig } from "@/app/store/config";
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import BottomArrow from "@/app/icons/downArrowLgIcon.svg";
|
||||
import BottomArrowMobile from "@/app/icons/bottomArrow.svg";
|
||||
import Modal, { TriggerProps } from "@/app/components/Modal";
|
||||
|
||||
import Selected from "@/app/icons/selectedIcon.svg";
|
||||
|
||||
const ModelSelect = () => {
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
const chatStore = useChatStore();
|
||||
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||
const allModels = useAllModels();
|
||||
const models = useMemo(() => {
|
||||
const filteredModels = allModels.filter((m) => m.available);
|
||||
const defaultModel = filteredModels.find((m) => m.isDefault);
|
||||
|
||||
if (defaultModel) {
|
||||
const arr = [
|
||||
defaultModel,
|
||||
...filteredModels.filter((m) => m !== defaultModel),
|
||||
];
|
||||
return arr;
|
||||
} else {
|
||||
return filteredModels;
|
||||
}
|
||||
}, [allModels]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { position, getRelativePosition } = useRelativePosition({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
const contentRef = useMemo<{ current: HTMLDivElement | null }>(() => {
|
||||
return {
|
||||
current: null,
|
||||
};
|
||||
}, []);
|
||||
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const autoScrollToSelectedModal = () => {
|
||||
window.setTimeout(() => {
|
||||
const distanceToParent = selectedItemRef.current?.offsetTop || 0;
|
||||
const childHeight = selectedItemRef.current?.offsetHeight || 0;
|
||||
const parentHeight = contentRef.current?.offsetHeight || 0;
|
||||
const distanceToParentCenter =
|
||||
distanceToParent + childHeight / 2 - parentHeight / 2;
|
||||
|
||||
if (distanceToParentCenter > 0 && contentRef.current) {
|
||||
contentRef.current.scrollTop = distanceToParentCenter;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const content: TriggerProps["content"] = ({ close }) => (
|
||||
<div
|
||||
className={`flex flex-col gap-1 overflow-x-hidden relative text-sm-title`}
|
||||
>
|
||||
{models?.map((o) => (
|
||||
<div
|
||||
key={o.displayName}
|
||||
className={`flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer`}
|
||||
onClick={() => {
|
||||
close();
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
session.mask.modelConfig.model = o.name as ModelType;
|
||||
session.mask.syncGlobalConfig = false;
|
||||
});
|
||||
showToast(o.name);
|
||||
}}
|
||||
ref={currentModel === o.name ? selectedItemRef : undefined}
|
||||
>
|
||||
<div className={`flex-1 text-text-select`}>{o.name}</div>
|
||||
<div
|
||||
className={currentModel === o.name ? "opacity-100" : "opacity-0"}
|
||||
>
|
||||
<Selected />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobileScreen) {
|
||||
return (
|
||||
<Modal.Trigger
|
||||
content={(e) => (
|
||||
<div className="h-[100%] overflow-y-auto" ref={contentRef}>
|
||||
{content(e)}
|
||||
</div>
|
||||
)}
|
||||
type="bottom-drawer"
|
||||
onOpen={(e) => {
|
||||
if (e) {
|
||||
autoScrollToSelectedModal();
|
||||
getRelativePosition(rootRef.current!, "");
|
||||
}
|
||||
}}
|
||||
title={Locale.Chat.SelectModel}
|
||||
headerBordered
|
||||
noFooter
|
||||
modelClassName="h-model-bottom-drawer"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer text-text-modal-select"
|
||||
ref={rootRef}
|
||||
>
|
||||
{currentModel}
|
||||
<BottomArrowMobile />
|
||||
</div>
|
||||
</Modal.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<div className="max-h-chat-actions-select-model-popover overflow-y-auto">
|
||||
{content({ close: () => {} })}
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
noArrow
|
||||
placement={
|
||||
position?.poi.relativePosition[1] !== Orientation.bottom ? "lb" : "lt"
|
||||
}
|
||||
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-model-select-popover-panel w-[280px]"
|
||||
onShow={(e) => {
|
||||
if (e) {
|
||||
autoScrollToSelectedModal();
|
||||
getRelativePosition(rootRef.current!, "");
|
||||
}
|
||||
}}
|
||||
getPopoverPanelRef={(ref) => (contentRef.current = ref.current)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center gap-1 cursor-pointer rounded-chat-model-select pl-3 pr-2.5 py-2 font-common leading-4 bg-chat-actions-select-model hover:bg-chat-actions-select-model-hover"
|
||||
ref={rootRef}
|
||||
>
|
||||
<div className="line-clamp-1 max-w-chat-actions-select-model text-sm-title text-text-modal-select">
|
||||
{currentModel}
|
||||
</div>
|
||||
<BottomArrow />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelect;
|
@@ -1,96 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Prompt } from "@/app/store/prompt";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
import useShowPromptHint from "@/app/hooks/useShowPromptHint";
|
||||
|
||||
export type RenderPompt = Pick<Prompt, "title" | "content">;
|
||||
|
||||
export default function PromptHints(props: {
|
||||
prompts: RenderPompt[];
|
||||
onPromptSelect: (prompt: RenderPompt) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const noPrompts = props.prompts.length === 0;
|
||||
|
||||
const [selectIndex, setSelectIndex] = useState(0);
|
||||
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { internalPrompts, notShowPrompt } = useShowPromptHint({ ...props });
|
||||
|
||||
useEffect(() => {
|
||||
setSelectIndex(0);
|
||||
}, [props.prompts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
// arrow up / down to select prompt
|
||||
const changeIndex = (delta: number) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.max(
|
||||
0,
|
||||
Math.min(props.prompts.length - 1, selectIndex + delta),
|
||||
);
|
||||
setSelectIndex(nextIndex);
|
||||
selectedRef.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
changeIndex(1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
changeIndex(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
const selectedPrompt = props.prompts.at(selectIndex);
|
||||
if (selectedPrompt) {
|
||||
props.onPromptSelect(selectedPrompt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.prompts.length, selectIndex]);
|
||||
|
||||
if (!internalPrompts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 shadow-prompt-hint-container rounded-none flex flex-col-reverse overflow-x-hidden
|
||||
${
|
||||
notShowPrompt
|
||||
? "max-h-[0vh] border-none"
|
||||
: "border-b pt-2.5 max-h-[50vh]"
|
||||
}
|
||||
${props.className}
|
||||
`}
|
||||
>
|
||||
{internalPrompts.map((prompt, i) => (
|
||||
<div
|
||||
ref={i === selectIndex ? selectedRef : null}
|
||||
className={
|
||||
styles["prompt-hint"] +
|
||||
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
||||
}
|
||||
key={prompt.title + i.toString()}
|
||||
onClick={() => props.onPromptSelect(prompt)}
|
||||
onMouseEnter={() => setSelectIndex(i)}
|
||||
>
|
||||
<div className={styles["hint-title"]}>{prompt.title}</div>
|
||||
<div className={styles["hint-content"]}>{prompt.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,32 +0,0 @@
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import BrainIcon from "@/app/icons/brain.svg";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
|
||||
export default function PromptToast(props: {
|
||||
showToast?: boolean;
|
||||
setShowModal: (_: boolean) => void;
|
||||
}) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const context = session.mask.context;
|
||||
|
||||
return (
|
||||
<div className={styles["prompt-toast"]} key="prompt-toast">
|
||||
{props.showToast && (
|
||||
<div
|
||||
className={styles["prompt-toast-inner"] + " clickable"}
|
||||
role="button"
|
||||
onClick={() => props.setShowModal(true)}
|
||||
>
|
||||
<BrainIcon />
|
||||
<span className={styles["prompt-toast-content"]}>
|
||||
{Locale.Context.Toast(context.length)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,77 +0,0 @@
|
||||
import { Modal, showConfirm } from "@/app/components/ui-lib";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useMaskStore } from "@/app/store/mask";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { Path } from "@/app/constant";
|
||||
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import MaskConfig from "@/app/containers/Settings/components/MaskConfig";
|
||||
import { ListItem } from "@/app/components/List";
|
||||
|
||||
export default function SessionConfigModel(props: { onClose: () => void }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const maskStore = useMaskStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Context.Edit}
|
||||
onClose={() => props.onClose()}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="reset"
|
||||
icon={<ResetIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Config.Reset}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Memory.ResetConfirm)) {
|
||||
chatStore.updateCurrentSession(
|
||||
(session) => (session.memoryPrompt = ""),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
icon={<CopyIcon />}
|
||||
bordered
|
||||
text={Locale.Chat.Config.SaveAs}
|
||||
onClick={() => {
|
||||
navigate(Path.Masks);
|
||||
setTimeout(() => {
|
||||
maskStore.create(session.mask);
|
||||
}, 500);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<MaskConfig
|
||||
mask={session.mask}
|
||||
updateMask={(updater) => {
|
||||
const mask = { ...session.mask };
|
||||
updater(mask);
|
||||
chatStore.updateCurrentSession((session) => (session.mask = mask));
|
||||
}}
|
||||
shouldSyncFromGlobal
|
||||
extraListItems={
|
||||
session.mask.modelConfig.sendMemory ? (
|
||||
<ListItem
|
||||
className="copyable"
|
||||
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
||||
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||
></ListItem>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
></MaskConfig>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,184 +0,0 @@
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Path } from "@/app/constant";
|
||||
import { Mask } from "@/app/store/mask";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
import DeleteChatIcon from "@/app/icons/deleteChatIcon.svg";
|
||||
|
||||
import { getTime } from "@/app/utils";
|
||||
import DeleteIcon from "@/app/icons/deleteIcon.svg";
|
||||
import LogIcon from "@/app/icons/logIcon.svg";
|
||||
|
||||
import HoverPopover from "@/app/components/HoverPopover";
|
||||
import Popover from "@/app/components/Popover";
|
||||
|
||||
export default function SessionItem(props: {
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
title: string;
|
||||
count: number;
|
||||
time: string;
|
||||
selected: boolean;
|
||||
id: string;
|
||||
index: number;
|
||||
narrow?: boolean;
|
||||
mask: Mask;
|
||||
isMobileScreen: boolean;
|
||||
}) {
|
||||
const draggableRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (props.selected && draggableRef.current) {
|
||||
draggableRef.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [props.selected]);
|
||||
|
||||
const { pathname: currentPath } = useLocation();
|
||||
|
||||
return (
|
||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={`
|
||||
group/chat-menu-list relative flex p-3 items-center gap-2 self-stretch rounded-md mb-2
|
||||
border
|
||||
transition-colors duration-300 ease-in-out
|
||||
bg-chat-menu-session-unselected-mobile border-chat-menu-session-unselected-mobile
|
||||
md:bg-chat-menu-session-unselected md:border-chat-menu-session-unselected
|
||||
${
|
||||
props.selected &&
|
||||
(currentPath === Path.Chat || currentPath === Path.Home)
|
||||
? `
|
||||
md:!bg-chat-menu-session-selected md:!border-chat-menu-session-selected
|
||||
!bg-chat-menu-session-selected-mobile !border-chat-menu-session-selected-mobile
|
||||
`
|
||||
: `md:hover:bg-chat-menu-session-hovered md:hover:chat-menu-session-hovered`
|
||||
}
|
||||
`}
|
||||
onClick={props.onClick}
|
||||
ref={(ele) => {
|
||||
draggableRef.current = ele;
|
||||
provided.innerRef(ele);
|
||||
}}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
|
||||
props.count,
|
||||
)}`}
|
||||
>
|
||||
<div className=" flex-shrink-0">
|
||||
<LogIcon />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className={`flex justify-between items-center`}>
|
||||
<div
|
||||
className={` text-text-chat-menu-item-title text-sm-title line-clamp-1 flex-1`}
|
||||
>
|
||||
{props.title}
|
||||
</div>
|
||||
<div
|
||||
className={`text-text-chat-menu-item-time text-sm group-hover/chat-menu-list:opacity-0 pl-3 hidden md:block`}
|
||||
>
|
||||
{getTime(props.time)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-text-chat-menu-item-description text-sm`}>
|
||||
{Locale.ChatItem.ChatItemCount(props.count)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-text-chat-menu-item-time text-sm pl-3 block md:hidden`}
|
||||
>
|
||||
{getTime(props.time)}
|
||||
</div>
|
||||
{props.isMobileScreen ? (
|
||||
<Popover
|
||||
content={
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
|
||||
follow-parent-svg
|
||||
fill-none
|
||||
text-text-chat-menu-item-delete
|
||||
`}
|
||||
onClickCapture={(e) => {
|
||||
props.onDelete?.();
|
||||
}}
|
||||
>
|
||||
<DeleteChatIcon />
|
||||
<div className="flex-1 font-common text-actions-popover-menu-item ">
|
||||
{Locale.Chat.Actions.Delete}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
popoverClassName={`
|
||||
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
|
||||
`}
|
||||
noArrow
|
||||
placement="r"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer rounded-chat-img
|
||||
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
|
||||
md:group-hover/chat-menu-list:pointer-events-auto
|
||||
md:group-hover/chat-menu-list:opacity-100
|
||||
md:hover:bg-select-hover
|
||||
follow-parent-svg
|
||||
fill-none
|
||||
text-text-chat-menu-item-time
|
||||
`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<HoverPopover
|
||||
content={
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
|
||||
follow-parent-svg
|
||||
fill-none
|
||||
text-text-chat-menu-item-delete
|
||||
`}
|
||||
onClickCapture={(e) => {
|
||||
props.onDelete?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DeleteChatIcon />
|
||||
<div className="flex-1 font-common text-actions-popover-menu-item text-text-chat-menu-item-delete">
|
||||
{Locale.Chat.Actions.Delete}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
popoverClassName={`
|
||||
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
|
||||
`}
|
||||
noArrow
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
cursor-pointer rounded-chat-img
|
||||
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
|
||||
md:group-hover/chat-menu-list:pointer-events-auto
|
||||
md:group-hover/chat-menu-list:opacity-100
|
||||
md:hover:bg-select-hover
|
||||
`}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
</HoverPopover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
@@ -1,609 +0,0 @@
|
||||
@import "~@/app/styles/animation.scss";
|
||||
|
||||
.attach-images {
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attach-image {
|
||||
cursor: default;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--white);
|
||||
|
||||
.attach-image-mask {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: all ease 0.2s;
|
||||
}
|
||||
|
||||
.attach-image-mask:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-image {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
float: right;
|
||||
background-color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.chat-input-action {
|
||||
display: inline-flex;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
padding: 4px 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: width ease 0.3s;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
width: var(--icon-width);
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
transition: all ease 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--delay: 0.5s;
|
||||
width: var(--full-width);
|
||||
transition-delay: var(--delay);
|
||||
|
||||
.text {
|
||||
transition-delay: var(--delay);
|
||||
opacity: 1;
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.text,
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-toast {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
.prompt-toast-inner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
border: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 10px 20px;
|
||||
border-radius: 100px;
|
||||
|
||||
animation: slide-in-from-top ease 0.3s;
|
||||
|
||||
.prompt-toast-content {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.section-title-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt {
|
||||
.context-prompt-insert {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
opacity: 0.2;
|
||||
transition: all ease 0.3s;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
.context-drag {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.context-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.context-role {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-delete-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-prompt-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.memory-prompt {
|
||||
margin: 20px 0;
|
||||
|
||||
.memory-prompt-content {
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-context {
|
||||
margin: 20px 0 0 0;
|
||||
padding: 4px 0;
|
||||
|
||||
border-top: var(--border-in-light);
|
||||
border-bottom: var(--border-in-light);
|
||||
box-shadow: var(--card-shadow) inset;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: var(--black);
|
||||
transition: all ease 0.3s;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
$linear: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0));
|
||||
mask-image: $linear;
|
||||
|
||||
@mixin show {
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
transition: all ease 0.3s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@mixin hide {
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
transition: all ease 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-tips {
|
||||
@include show;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-revert-btn {
|
||||
color: var(--primary);
|
||||
@include hide;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--primary);
|
||||
|
||||
.clear-context-tips {
|
||||
@include hide;
|
||||
}
|
||||
|
||||
.clear-context-revert-btn {
|
||||
@include show;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
// height: 100%;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
padding-bottom: 40px;
|
||||
position: relative;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.chat-body-main-title {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.chat-body-title {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
&:last-child {
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.chat-message-header {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-header {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.chat-message-actions {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
transition: all ease 0.3s;
|
||||
transform: scale(0.9) translateY(5px);
|
||||
margin: 0 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-container {
|
||||
max-width: var(--message-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
&:hover {
|
||||
.chat-message-edit {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-message-actions {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-avatar {
|
||||
position: relative;
|
||||
|
||||
.chat-message-edit {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all ease 0.3s;
|
||||
|
||||
button {
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Specific styles for iOS devices */
|
||||
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.chat-message-edit {
|
||||
top: -8%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-status {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chat-message-item {
|
||||
// box-sizing: border-box;
|
||||
// max-width: 100%;
|
||||
// margin-top: 10px;
|
||||
// border-radius: 10px;
|
||||
// background-color: rgba(0, 0, 0, 0.05);
|
||||
// padding: 10px;
|
||||
// font-size: 14px;
|
||||
// user-select: text;
|
||||
// word-break: break-word;
|
||||
// border: var(--border-in-light);
|
||||
// position: relative;
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-message-item-images {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-content: left;
|
||||
grid-gap: 10px;
|
||||
grid-template-columns: repeat(var(--image-count), auto);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
object-fit: cover;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.chat-message-item-image,
|
||||
.chat-message-item-image-multi {
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $calc-image-width;
|
||||
height: $calc-image-width;
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(100vw/3*2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
||||
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $image-width;
|
||||
height: $image-width;
|
||||
max-width: $max-image-width;
|
||||
max-height: $max-image-width;
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||
}
|
||||
}
|
||||
|
||||
// .chat-message-action-date {
|
||||
// // font-size: 12px;
|
||||
// // opacity: 0.2;
|
||||
// // white-space: nowrap;
|
||||
// // transition: all ease 0.6s;
|
||||
// // color: var(--black);
|
||||
// // text-align: right;
|
||||
// // width: 100%;
|
||||
// // box-sizing: border-box;
|
||||
// // padding-right: 10px;
|
||||
// // pointer-events: none;
|
||||
// // z-index: 1;
|
||||
// }
|
||||
|
||||
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||
background-color: var(--second);
|
||||
|
||||
&:hover {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-panel {
|
||||
// position: relative;
|
||||
// width: 100%;
|
||||
// padding: 20px;
|
||||
// padding-top: 10px;
|
||||
// box-sizing: border-box;
|
||||
// flex-direction: column;
|
||||
// border-top: var(--border-in-light);
|
||||
// box-shadow: var(--card-shadow);
|
||||
|
||||
.chat-input-actions {
|
||||
.chat-input-action {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin single-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.prompt-hint {
|
||||
color:var(--btn-default-text);
|
||||
padding: 6px 10px;
|
||||
border: transparent 1px solid;
|
||||
margin: 4px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hint-title {
|
||||
font-size: 12px;
|
||||
font-weight: bolder;
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
.hint-content {
|
||||
font-size: 12px;
|
||||
|
||||
@include single-line();
|
||||
}
|
||||
|
||||
&-selected,
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
// .chat-input-panel-inner {
|
||||
// cursor: text;
|
||||
// display: flex;
|
||||
// flex: 1;
|
||||
// border-radius: 10px;
|
||||
// border: var(--border-in-light);
|
||||
// }
|
||||
|
||||
.chat-input-panel-inner-attach {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.chat-input-panel-inner:has(.chat-input:focus) {
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
font-family: inherit;
|
||||
padding: 10px 90px 10px 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
bottom: 32px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.chat-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
bottom: 30px;
|
||||
}
|
||||
}
|
@@ -1,146 +0,0 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
OnDragEndResponder,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { useAppConfig, useChatStore } from "@/app/store";
|
||||
|
||||
import Locale from "@/app/locales";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Path } from "@/app/constant";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import AddIcon from "@/app/icons/addIcon.svg";
|
||||
import NextChatTitle from "@/app/icons/nextchatTitle.svg";
|
||||
|
||||
import MenuLayout from "@/app/components/MenuLayout";
|
||||
import Panel from "./ChatPanel";
|
||||
import Modal from "@/app/components/Modal";
|
||||
import SessionItem from "./components/SessionItem";
|
||||
|
||||
export default MenuLayout(function SessionList(props) {
|
||||
const { setShowPanel } = props;
|
||||
|
||||
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
|
||||
(state) => [
|
||||
state.sessions,
|
||||
state.currentSessionIndex,
|
||||
state.selectSession,
|
||||
state.moveSession,
|
||||
],
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { pathname: currentPath } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setShowPanel?.(currentPath === Path.Chat);
|
||||
}, [currentPath]);
|
||||
|
||||
const onDragEnd: OnDragEndResponder = (result) => {
|
||||
const { destination, source } = result;
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
moveSession(source.index, destination.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
h-[100%] flex flex-col
|
||||
md:px-0
|
||||
`}
|
||||
>
|
||||
<div data-tauri-drag-region>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between
|
||||
py-6 max-md:box-content max-md:h-0
|
||||
md:py-7
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="">
|
||||
<NextChatTitle />
|
||||
</div>
|
||||
<div
|
||||
className=" cursor-pointer"
|
||||
onClick={() => {
|
||||
if (config.dontShowMaskSplashScreen) {
|
||||
chatStore.newSession();
|
||||
navigate(Path.Chat);
|
||||
} else {
|
||||
navigate(Path.NewChat);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AddIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pb-3 text-sm sm:text-sm-mobile text-text-chat-header-subtitle`}
|
||||
>
|
||||
Build your own AI assistant.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 overflow-y-auto max-md:pb-chat-panel-mobile `}>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="chat-list">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`w-[100%]`}
|
||||
>
|
||||
{sessions.map((item, i) => (
|
||||
<SessionItem
|
||||
title={item.topic}
|
||||
time={new Date(item.lastUpdate).toLocaleString()}
|
||||
count={item.messages.length}
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
index={i}
|
||||
selected={i === selectedIndex}
|
||||
onClick={() => {
|
||||
navigate(Path.Chat);
|
||||
selectSession(i);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
await Modal.warn({
|
||||
okText: Locale.ChatItem.DeleteOkBtn,
|
||||
cancelText: Locale.ChatItem.DeleteCancelBtn,
|
||||
title: Locale.ChatItem.DeleteTitle,
|
||||
content: Locale.ChatItem.DeleteContent,
|
||||
})
|
||||
) {
|
||||
chatStore.deleteSession(i);
|
||||
}
|
||||
}}
|
||||
mask={item.mask}
|
||||
isMobileScreen={isMobileScreen}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, Panel);
|
@@ -1,137 +0,0 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useAccessStore, useAppConfig } from "@/app/store";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import { Path } from "@/app/constant";
|
||||
import List from "@/app/components/List";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import Card from "@/app/components/Card";
|
||||
import SettingHeader from "./components/SettingHeader";
|
||||
import { MenuWrapperInspectProps } from "@/app/components/MenuLayout";
|
||||
import SyncItems from "./components/SyncItems";
|
||||
import DangerItems from "./components/DangerItems";
|
||||
import AppSetting from "./components/AppSetting";
|
||||
import MaskSetting from "./components/MaskSetting";
|
||||
import PromptSetting from "./components/PromptSetting";
|
||||
import ProviderSetting from "./components/ProviderSetting";
|
||||
import ModelConfigList from "./components/ModelSetting";
|
||||
|
||||
export default function Settings(props: MenuWrapperInspectProps) {
|
||||
const { setShowPanel, id } = props;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const accessStore = useAccessStore();
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
useEffect(() => {
|
||||
const keydownEvent = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
};
|
||||
if (clientConfig?.isApp) {
|
||||
// Force to set custom endpoint to true if it's app
|
||||
accessStore.update((state) => {
|
||||
state.useCustomConfig = true;
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", keydownEvent);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", keydownEvent);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
|
||||
const cardClassName = "mb-6 md:mb-8 last:mb-0";
|
||||
|
||||
const itemMap = {
|
||||
[Locale.Settings.GeneralSettings]: (
|
||||
<>
|
||||
<Card className={cardClassName} title={Locale.Settings.Basic.Title}>
|
||||
<AppSetting />
|
||||
</Card>
|
||||
|
||||
<Card className={cardClassName} title={Locale.Settings.Mask.Title}>
|
||||
<MaskSetting />
|
||||
</Card>
|
||||
<Card className={cardClassName} title={Locale.Settings.Prompt.Title}>
|
||||
<PromptSetting />
|
||||
</Card>
|
||||
<Card className={cardClassName} title={Locale.Settings.Provider.Title}>
|
||||
<ProviderSetting />
|
||||
</Card>
|
||||
|
||||
<Card className={cardClassName} title={Locale.Settings.Danger.Title}>
|
||||
<DangerItems />
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
[Locale.Settings.ModelSettings]: (
|
||||
<Card className={cardClassName} title={Locale.Settings.Models.Title}>
|
||||
<List
|
||||
widgetStyle={{
|
||||
// selectClassName: "min-w-select-mobile-lg",
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ModelConfigList
|
||||
modelConfig={config.modelConfig}
|
||||
updateConfig={(updater) => {
|
||||
const modelConfig = { ...config.modelConfig };
|
||||
updater(modelConfig);
|
||||
config.update((config) => (config.modelConfig = modelConfig));
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
</Card>
|
||||
),
|
||||
[Locale.Settings.DataSettings]: (
|
||||
<Card className={cardClassName} title={Locale.Settings.Sync.Title}>
|
||||
<SyncItems />
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex flex-col overflow-hidden bg-settings-panel
|
||||
h-setting-panel-mobile
|
||||
md:h-[100%] md:mr-2.5 md:rounded-md
|
||||
`}
|
||||
>
|
||||
<SettingHeader
|
||||
isMobileScreen={isMobileScreen}
|
||||
goback={() => setShowPanel?.(false)}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
max-md:w-[100%]
|
||||
px-4 py-5
|
||||
md:px-6 md:py-8
|
||||
flex items-start justify-center
|
||||
overflow-y-auto
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-full
|
||||
max-w-screen-md
|
||||
!overflow-x-hidden
|
||||
overflow-y-auto
|
||||
`}
|
||||
>
|
||||
{itemMap[id] || null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,200 +0,0 @@
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarPicker } from "@/app/components/emoji";
|
||||
import { Popover } from "@/app/components/ui-lib";
|
||||
import Locale, {
|
||||
ALL_LANG_OPTIONS,
|
||||
AllLangs,
|
||||
changeLang,
|
||||
getLang,
|
||||
} from "@/app/locales";
|
||||
import Link from "next/link";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { useUpdateStore } from "@/app/store/update";
|
||||
import {
|
||||
SubmitKey,
|
||||
Theme,
|
||||
ThemeConfig,
|
||||
useAppConfig,
|
||||
} from "@/app/store/config";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { RELEASE_URL, UPDATE_URL } from "@/app/constant";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Select from "@/app/components/Select";
|
||||
import SlideRange from "@/app/components/SlideRange";
|
||||
import Switch from "@/app/components/Switch";
|
||||
|
||||
export interface AppSettingProps {}
|
||||
|
||||
export default function AppSetting(props: AppSettingProps) {
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const updateStore = useUpdateStore();
|
||||
const config = useAppConfig();
|
||||
const { update: updateConfig, isMobileScreen } = config;
|
||||
|
||||
const currentVersion = updateStore.formatVersion(updateStore.version);
|
||||
const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
|
||||
const hasNewVersion = currentVersion !== remoteId;
|
||||
const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
|
||||
|
||||
function checkUpdate(force = false) {
|
||||
setCheckingUpdate(true);
|
||||
updateStore.getLatestVersion(force).then(() => {
|
||||
setCheckingUpdate(false);
|
||||
});
|
||||
|
||||
console.log("[Update] local version ", updateStore.version);
|
||||
console.log("[Update] remote version ", updateStore.remoteVersion);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// checks per minutes
|
||||
checkUpdate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<List
|
||||
widgetStyle={{
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ListItem title={Locale.Settings.Avatar}>
|
||||
<Popover
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
content={
|
||||
<AvatarPicker
|
||||
onEmojiClick={(avatar: string) => {
|
||||
updateConfig((config) => (config.avatar = avatar));
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
open={showEmojiPicker}
|
||||
>
|
||||
<div
|
||||
className={styles.avatar}
|
||||
onClick={() => {
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
}}
|
||||
>
|
||||
<Avatar avatar={config.avatar} />
|
||||
</div>
|
||||
</Popover>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
|
||||
subTitle={
|
||||
checkingUpdate
|
||||
? Locale.Settings.Update.IsChecking
|
||||
: hasNewVersion
|
||||
? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
|
||||
: Locale.Settings.Update.IsLatest
|
||||
}
|
||||
>
|
||||
{checkingUpdate ? (
|
||||
<LoadingIcon />
|
||||
) : hasNewVersion ? (
|
||||
<Link href={updateUrl} target="_blank" className="link">
|
||||
{Locale.Settings.Update.GoToUpdate}
|
||||
</Link>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<ResetIcon />}
|
||||
text={Locale.Settings.Update.CheckUpdate}
|
||||
onClick={() => checkUpdate(true)}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.SendKey}>
|
||||
<Select
|
||||
value={config.submitKey}
|
||||
options={Object.values(SubmitKey).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
onSelect={(v) => {
|
||||
updateConfig((config) => (config.submitKey = v));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Theme}>
|
||||
<Select
|
||||
value={config.theme}
|
||||
options={Object.entries(ThemeConfig).map(([k, t]) => ({
|
||||
value: k as Theme,
|
||||
label: t.title,
|
||||
icon: <t.icon />,
|
||||
}))}
|
||||
onSelect={(e) => {
|
||||
updateConfig((config) => (config.theme = e));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Lang.Name}>
|
||||
<Select
|
||||
value={getLang()}
|
||||
options={AllLangs.map((lang) => ({
|
||||
value: lang,
|
||||
label: ALL_LANG_OPTIONS[lang],
|
||||
}))}
|
||||
onSelect={(e) => {
|
||||
changeLang(e);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.FontSize.Title}
|
||||
subTitle={Locale.Settings.FontSize.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={config.fontSize}
|
||||
range={{
|
||||
start: 12,
|
||||
stroke: 28,
|
||||
}}
|
||||
step={1}
|
||||
onSlide={(e) => updateConfig((config) => (config.fontSize = e))}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.AutoGenerateTitle.Title}
|
||||
subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.enableAutoGenerateTitle}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.enableAutoGenerateTitle = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.SendPreviewBubble.Title}
|
||||
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.sendPreviewBubble}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.sendPreviewBubble = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
@@ -1,153 +0,0 @@
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { showConfirm } from "@/app/components/ui-lib";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAccessStore } from "@/app/store/access";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { OPENAI_BASE_URL, ServiceProvider } from "@/app/constant";
|
||||
import { useUpdateStore } from "@/app/store/update";
|
||||
|
||||
import ResetIcon from "@/app/icons/reload.svg";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Input from "@/app/components/Input";
|
||||
import Btn from "@/app/components/Btn";
|
||||
|
||||
export default function DangerItems() {
|
||||
const chatStore = useChatStore();
|
||||
const appConfig = useAppConfig();
|
||||
const accessStore = useAccessStore();
|
||||
const updateStore = useUpdateStore();
|
||||
const { isMobileScreen } = appConfig;
|
||||
|
||||
const enabledAccessControl = useMemo(
|
||||
() => accessStore.enabledAccessControl(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
|
||||
const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
|
||||
|
||||
const shouldHideBalanceQuery = useMemo(() => {
|
||||
const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
|
||||
return (
|
||||
accessStore.hideBalanceQuery ||
|
||||
isOpenAiUrl ||
|
||||
accessStore.provider === ServiceProvider.Azure
|
||||
);
|
||||
}, [
|
||||
accessStore.hideBalanceQuery,
|
||||
accessStore.openaiUrl,
|
||||
accessStore.provider,
|
||||
]);
|
||||
|
||||
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||
const usage = {
|
||||
used: updateStore.used,
|
||||
subscription: updateStore.subscription,
|
||||
};
|
||||
|
||||
function checkUsage(force = false) {
|
||||
if (shouldHideBalanceQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingUsage(true);
|
||||
updateStore.updateUsage(force).finally(() => {
|
||||
setLoadingUsage(false);
|
||||
});
|
||||
}
|
||||
|
||||
const showUsage = accessStore.isAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
showUsage && checkUsage();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<List
|
||||
widgetStyle={{
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
rangeNextLine: isMobileScreen,
|
||||
inputNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
{showAccessCode && (
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.AccessCode.Title}
|
||||
subTitle={Locale.Settings.Access.AccessCode.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.accessCode}
|
||||
type="password"
|
||||
placeholder={Locale.Settings.Access.AccessCode.Placeholder}
|
||||
onChange={(e) => {
|
||||
accessStore.update((access) => (access.accessCode = e));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{!shouldHideBalanceQuery && !clientConfig?.isApp ? (
|
||||
<ListItem
|
||||
title={Locale.Settings.Usage.Title}
|
||||
subTitle={
|
||||
showUsage
|
||||
? loadingUsage
|
||||
? Locale.Settings.Usage.IsChecking
|
||||
: Locale.Settings.Usage.SubTitle(
|
||||
usage?.used ?? "[?]",
|
||||
usage?.subscription ?? "[?]",
|
||||
)
|
||||
: Locale.Settings.Usage.NoAccess
|
||||
}
|
||||
>
|
||||
{!showUsage || loadingUsage ? (
|
||||
<div />
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<ResetIcon />}
|
||||
text={Locale.Settings.Usage.Check}
|
||||
onClick={() => checkUsage(true)}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Danger.Reset.Title}
|
||||
subTitle={Locale.Settings.Danger.Reset.SubTitle}
|
||||
>
|
||||
<Btn
|
||||
text={Locale.Settings.Danger.Reset.Action}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
|
||||
appConfig.reset();
|
||||
}
|
||||
}}
|
||||
type="danger"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Danger.Clear.Title}
|
||||
subTitle={Locale.Settings.Danger.Clear.SubTitle}
|
||||
>
|
||||
<Btn
|
||||
text={Locale.Settings.Danger.Clear.Action}
|
||||
onClick={async () => {
|
||||
if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
|
||||
chatStore.clearAllData();
|
||||
}
|
||||
}}
|
||||
type="danger"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
@@ -1,162 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import { ContextPrompts, MaskAvatar } from "@/app/components/mask";
|
||||
import { Path } from "@/app/constant";
|
||||
import { ModelConfig, useAppConfig } from "@/app/store/config";
|
||||
import { Mask } from "@/app/store/mask";
|
||||
import { Updater } from "@/app/typing";
|
||||
import { copyToClipboard } from "@/app/utils";
|
||||
import Locale from "@/app/locales";
|
||||
import { Popover, showConfirm } from "@/app/components/ui-lib";
|
||||
import { AvatarPicker } from "@/app/components/emoji";
|
||||
import ModelSetting from "@/app/containers/Settings/components/ModelSetting";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export default function MaskConfig(props: {
|
||||
mask: Mask;
|
||||
updateMask: Updater<Mask>;
|
||||
extraListItems?: JSX.Element;
|
||||
readonly?: boolean;
|
||||
shouldSyncFromGlobal?: boolean;
|
||||
}) {
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
const updateConfig = (updater: (config: ModelConfig) => void) => {
|
||||
if (props.readonly) return;
|
||||
|
||||
const config = { ...props.mask.modelConfig };
|
||||
updater(config);
|
||||
props.updateMask((mask) => {
|
||||
mask.modelConfig = config;
|
||||
// if user changed current session mask, it will disable auto sync
|
||||
mask.syncGlobalConfig = false;
|
||||
});
|
||||
};
|
||||
|
||||
const copyMaskLink = () => {
|
||||
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
|
||||
copyToClipboard(maskLink);
|
||||
};
|
||||
|
||||
const globalConfig = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = globalConfig;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextPrompts
|
||||
context={props.mask.context}
|
||||
updateContext={(updater) => {
|
||||
const context = props.mask.context.slice();
|
||||
updater(context);
|
||||
props.updateMask((mask) => (mask.context = context));
|
||||
}}
|
||||
/>
|
||||
|
||||
<List
|
||||
widgetStyle={{
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ListItem title={Locale.Mask.Config.Avatar}>
|
||||
<Popover
|
||||
content={
|
||||
<AvatarPicker
|
||||
onEmojiClick={(emoji) => {
|
||||
props.updateMask((mask) => (mask.avatar = emoji));
|
||||
setShowPicker(false);
|
||||
}}
|
||||
></AvatarPicker>
|
||||
}
|
||||
open={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
>
|
||||
<div
|
||||
onClick={() => setShowPicker(true)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<MaskAvatar
|
||||
avatar={props.mask.avatar}
|
||||
model={props.mask.modelConfig.model}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Mask.Config.Name}>
|
||||
<Input
|
||||
type="text"
|
||||
value={props.mask.name}
|
||||
onChange={(e) =>
|
||||
props.updateMask((mask) => {
|
||||
mask.name = e;
|
||||
})
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.HideContext.Title}
|
||||
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={!!props.mask.hideContext}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.hideContext = e;
|
||||
});
|
||||
}}
|
||||
></Switch>
|
||||
</ListItem>
|
||||
|
||||
{!props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Share.Title}
|
||||
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||
>
|
||||
<IconButton
|
||||
icon={<CopyIcon />}
|
||||
text={Locale.Mask.Config.Share.Action}
|
||||
onClick={copyMaskLink}
|
||||
/>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
{props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Sync.Title}
|
||||
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={!!props.mask.syncGlobalConfig}
|
||||
onChange={async (e) => {
|
||||
const checked = e;
|
||||
if (
|
||||
checked &&
|
||||
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
||||
) {
|
||||
props.updateMask((mask) => {
|
||||
mask.syncGlobalConfig = checked;
|
||||
mask.modelConfig = { ...globalConfig.modelConfig };
|
||||
});
|
||||
} else if (!checked) {
|
||||
props.updateMask((mask) => {
|
||||
mask.syncGlobalConfig = checked;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
<ModelSetting
|
||||
modelConfig={{ ...props.mask.modelConfig }}
|
||||
updateConfig={updateConfig}
|
||||
/>
|
||||
{props.extraListItems}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,39 +0,0 @@
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
|
||||
export interface MaskSettingProps {}
|
||||
|
||||
export default function MaskSetting(props: MaskSettingProps) {
|
||||
const config = useAppConfig();
|
||||
const updateConfig = config.update;
|
||||
|
||||
return (
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Settings.Mask.Splash.Title}
|
||||
subTitle={Locale.Settings.Mask.Splash.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={!config.dontShowMaskSplashScreen}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.dontShowMaskSplashScreen = !e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Mask.Builtin.Title}
|
||||
subTitle={Locale.Settings.Mask.Builtin.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.hideBuiltinMasks}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.hideBuiltinMasks = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
@@ -1,220 +0,0 @@
|
||||
import { ListItem } from "@/app/components/List";
|
||||
import {
|
||||
ModalConfigValidator,
|
||||
ModelConfig,
|
||||
useAppConfig,
|
||||
} from "@/app/store/config";
|
||||
import { useAllModels } from "@/app/utils/hooks";
|
||||
import Locale from "@/app/locales";
|
||||
import Select from "@/app/components/Select";
|
||||
import SlideRange from "@/app/components/SlideRange";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export default function ModelSetting(props: {
|
||||
modelConfig: ModelConfig;
|
||||
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||
}) {
|
||||
const allModels = useAllModels();
|
||||
const { isMobileScreen } = useAppConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Model}>
|
||||
<Select
|
||||
value={props.modelConfig.model}
|
||||
options={allModels
|
||||
.filter((v) => v.available)
|
||||
.map((v) => ({
|
||||
value: v.name,
|
||||
label: `${v.displayName}(${v.provider?.providerName})`,
|
||||
}))}
|
||||
onSelect={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.model = ModalConfigValidator.model(e)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Temperature.Title}
|
||||
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.temperature}
|
||||
range={{
|
||||
start: 0,
|
||||
stroke: 1,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.temperature = ModalConfigValidator.temperature(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.TopP.Title}
|
||||
subTitle={Locale.Settings.TopP.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.top_p ?? 1}
|
||||
range={{
|
||||
start: 0,
|
||||
stroke: 1,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) => (config.top_p = ModalConfigValidator.top_p(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.MaxTokens.Title}
|
||||
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1024}
|
||||
max={512000}
|
||||
value={props.modelConfig.max_tokens}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.max_tokens = ModalConfigValidator.max_tokens(e)),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
|
||||
{props.modelConfig.model.startsWith("gemini") ? null : (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.PresencePenalty.Title}
|
||||
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.presence_penalty}
|
||||
range={{
|
||||
start: -2,
|
||||
stroke: 4,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.presence_penalty =
|
||||
ModalConfigValidator.presence_penalty(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.FrequencyPenalty.Title}
|
||||
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.frequency_penalty}
|
||||
range={{
|
||||
start: -2,
|
||||
stroke: 4,
|
||||
}}
|
||||
step={0.1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig(
|
||||
(config) =>
|
||||
(config.frequency_penalty =
|
||||
ModalConfigValidator.frequency_penalty(e)),
|
||||
);
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={props.modelConfig.enableInjectSystemPrompts}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.enableInjectSystemPrompts = e),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.InputTemplate.Title}
|
||||
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||
nextline={isMobileScreen}
|
||||
validator={(v: string) => {
|
||||
if (!v.includes("{{input}}")) {
|
||||
return {
|
||||
error: true,
|
||||
message: Locale.Settings.InputTemplate.Error,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: false };
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={props.modelConfig.template}
|
||||
onChange={(e = "") =>
|
||||
props.updateConfig((config) => (config.template = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
<ListItem
|
||||
title={Locale.Settings.HistoryCount.Title}
|
||||
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||
>
|
||||
<SlideRange
|
||||
value={props.modelConfig.historyMessageCount}
|
||||
range={{
|
||||
start: 0,
|
||||
stroke: 64,
|
||||
}}
|
||||
step={1}
|
||||
onSlide={(e) => {
|
||||
props.updateConfig((config) => (config.historyMessageCount = e));
|
||||
}}
|
||||
></SlideRange>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.CompressThreshold.Title}
|
||||
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={500}
|
||||
max={4000}
|
||||
value={props.modelConfig.compressMessageLengthThreshold}
|
||||
onChange={(e) =>
|
||||
props.updateConfig(
|
||||
(config) => (config.compressMessageLengthThreshold = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||
<Switch
|
||||
value={props.modelConfig.sendMemory}
|
||||
onChange={(e) =>
|
||||
props.updateConfig((config) => (config.sendMemory = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import UserPromptModal from "./UserPromptModal";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import { SearchService, usePromptStore } from "@/app/store/prompt";
|
||||
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Btn from "@/app/components/Btn";
|
||||
|
||||
import EditIcon from "@/app/icons/editIcon.svg";
|
||||
|
||||
export interface PromptSettingProps {}
|
||||
|
||||
export default function PromptSetting(props: PromptSettingProps) {
|
||||
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const config = useAppConfig();
|
||||
const updateConfig = config.update;
|
||||
|
||||
const builtinCount = SearchService.count.builtin;
|
||||
|
||||
const promptStore = usePromptStore();
|
||||
const customCount = promptStore.getUserPrompts().length ?? 0;
|
||||
|
||||
const textStyle = " !text-sm";
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Settings.Prompt.Disable.Title}
|
||||
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={config.disablePromptHint}
|
||||
onChange={(e) =>
|
||||
updateConfig((config) => (config.disablePromptHint = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Prompt.List}
|
||||
subTitle={Locale.Settings.Prompt.ListCount(builtinCount, customCount)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Btn
|
||||
onClick={() => setShowPromptModal(true)}
|
||||
text={
|
||||
<span className={textStyle}>{Locale.Settings.Prompt.Edit}</span>
|
||||
}
|
||||
prefixIcon={config.isMobileScreen ? undefined : <EditIcon />}
|
||||
></Btn>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
{shouldShowPromptModal && (
|
||||
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,283 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
Google,
|
||||
OPENAI_BASE_URL,
|
||||
ServiceProvider,
|
||||
SlotID,
|
||||
} from "@/app/constant";
|
||||
import Locale from "@/app/locales";
|
||||
import { useAccessStore } from "@/app/store/access";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { useAppConfig } from "@/app/store/config";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Select from "@/app/components/Select";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
export default function ProviderSetting() {
|
||||
const accessStore = useAccessStore();
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||
|
||||
return (
|
||||
<List
|
||||
id={SlotID.CustomModel}
|
||||
widgetStyle={{
|
||||
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||
inputClassName: "md:min-w-select",
|
||||
rangeClassName: "md:min-w-select",
|
||||
inputNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
{!accessStore.hideUserApiKey && (
|
||||
<>
|
||||
{
|
||||
// Conditionally render the following ListItem based on clientConfig.isApp
|
||||
!clientConfig?.isApp && ( // only show if isApp is false
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.CustomEndpoint.Title}
|
||||
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={accessStore.useCustomConfig}
|
||||
onChange={(e) =>
|
||||
accessStore.update((access) => (access.useCustomConfig = e))
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
{accessStore.useCustomConfig && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Provider.Title}
|
||||
subTitle={Locale.Settings.Access.Provider.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={accessStore.provider}
|
||||
onSelect={(e) => {
|
||||
accessStore.update((access) => (access.provider = e));
|
||||
}}
|
||||
options={Object.entries(ServiceProvider).map(([k, v]) => ({
|
||||
value: v,
|
||||
label: k,
|
||||
}))}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{accessStore.provider === ServiceProvider.OpenAI && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
|
||||
subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.openaiUrl}
|
||||
placeholder={OPENAI_BASE_URL}
|
||||
onChange={(e = "") =>
|
||||
accessStore.update((access) => (access.openaiUrl = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.OpenAI.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.openaiApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.OpenAI.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.openaiApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
{accessStore.provider === ServiceProvider.Azure && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Azure.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Azure.Endpoint.SubTitle +
|
||||
Azure.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.azureUrl}
|
||||
placeholder={Azure.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update((access) => (access.azureUrl = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Azure.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.azureApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.Azure.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.azureApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Azure.ApiVerion.Title}
|
||||
subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.azureApiVersion}
|
||||
placeholder="2023-08-01-preview"
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.azureApiVersion = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
{accessStore.provider === ServiceProvider.Google && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Google.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Google.Endpoint.SubTitle +
|
||||
Google.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.googleUrl}
|
||||
placeholder={Google.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update((access) => (access.googleUrl = e))
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Google.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.googleApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.Google.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.googleApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Google.ApiVersion.Title}
|
||||
subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.googleApiVersion}
|
||||
placeholder="2023-08-01-preview"
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.googleApiVersion = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
{accessStore.provider === ServiceProvider.Anthropic && (
|
||||
<>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Anthropic.Endpoint.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
|
||||
Anthropic.ExampleEndpoint
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.anthropicUrl}
|
||||
placeholder={Anthropic.ExampleEndpoint}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicUrl = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
|
||||
subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
|
||||
>
|
||||
<Input
|
||||
value={accessStore.anthropicApiKey}
|
||||
type="password"
|
||||
placeholder={
|
||||
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
|
||||
}
|
||||
onChange={(e) => {
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicApiKey = e),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
|
||||
subTitle={
|
||||
Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={accessStore.anthropicApiVersion}
|
||||
placeholder={Anthropic.Vision}
|
||||
onChange={(e) =>
|
||||
accessStore.update(
|
||||
(access) => (access.anthropicApiVersion = e),
|
||||
)
|
||||
}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Access.CustomModel.Title}
|
||||
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.customModels}
|
||||
placeholder="model1,model2,model3"
|
||||
onChange={(e) => config.update((config) => (config.customModels = e))}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
}
|
@@ -1,47 +0,0 @@
|
||||
import Locale from "@/app/locales";
|
||||
import GobackIcon from "@/app/icons/goback.svg";
|
||||
|
||||
export interface ChatHeaderProps {
|
||||
isMobileScreen: boolean;
|
||||
goback: () => void;
|
||||
}
|
||||
|
||||
export default function SettingHeader(props: ChatHeaderProps) {
|
||||
const { isMobileScreen, goback } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap border-b border-settings-header
|
||||
max-md:h-menu-title-mobile max-md:bg-settings-header-mobile
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{isMobileScreen ? (
|
||||
<div
|
||||
className="absolute left-4 top-[50%] translate-y-[-50%] cursor-pointer"
|
||||
onClick={() => goback()}
|
||||
>
|
||||
<GobackIcon />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`
|
||||
flex-1
|
||||
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
|
||||
md:mr-4
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
line-clamp-1 cursor-pointer text-text-settings-panel-header-title text-chat-header-title font-common
|
||||
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5 !font-medium
|
||||
`}
|
||||
>
|
||||
{Locale.Settings.Title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,199 +0,0 @@
|
||||
import { Modal } from "@/app/components/ui-lib";
|
||||
import { useSyncStore } from "@/app/store/sync";
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
import { ProviderType } from "@/app/utils/cloud";
|
||||
import { STORAGE_KEY } from "@/app/constant";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import ConnectionIcon from "@/app/icons/connection.svg";
|
||||
import CloudSuccessIcon from "@/app/icons/cloud-success.svg";
|
||||
import CloudFailIcon from "@/app/icons/cloud-fail.svg";
|
||||
import ConfirmIcon from "@/app/icons/confirm.svg";
|
||||
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Switch from "@/app/components/Switch";
|
||||
import Select from "@/app/components/Select";
|
||||
import Input from "@/app/components/Input";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
|
||||
function CheckButton() {
|
||||
const syncStore = useSyncStore();
|
||||
|
||||
const couldCheck = useMemo(() => {
|
||||
return syncStore.cloudSync();
|
||||
}, [syncStore]);
|
||||
|
||||
const [checkState, setCheckState] = useState<
|
||||
"none" | "checking" | "success" | "failed"
|
||||
>("none");
|
||||
|
||||
async function check() {
|
||||
setCheckState("checking");
|
||||
const valid = await syncStore.check();
|
||||
setCheckState(valid ? "success" : "failed");
|
||||
}
|
||||
|
||||
if (!couldCheck) return null;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
text={Locale.Settings.Sync.Config.Modal.Check}
|
||||
bordered
|
||||
onClick={check}
|
||||
icon={
|
||||
checkState === "none" ? (
|
||||
<ConnectionIcon />
|
||||
) : checkState === "checking" ? (
|
||||
<LoadingIcon />
|
||||
) : checkState === "success" ? (
|
||||
<CloudSuccessIcon />
|
||||
) : checkState === "failed" ? (
|
||||
<CloudFailIcon />
|
||||
) : (
|
||||
<ConnectionIcon />
|
||||
)
|
||||
}
|
||||
></IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SyncConfigModal(props: { onClose?: () => void }) {
|
||||
const syncStore = useSyncStore();
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Sync.Config.Modal.Title}
|
||||
onClose={() => props.onClose?.()}
|
||||
actions={[
|
||||
<CheckButton key="check" />,
|
||||
<IconButton
|
||||
key="confirm"
|
||||
onClick={props.onClose}
|
||||
icon={<ConfirmIcon />}
|
||||
bordered
|
||||
text={Locale.UI.Confirm}
|
||||
/>,
|
||||
]}
|
||||
className="!bg-modal-mask active-new"
|
||||
>
|
||||
<List
|
||||
widgetStyle={{
|
||||
rangeNextLine: isMobileScreen,
|
||||
}}
|
||||
>
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.SyncType.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
|
||||
>
|
||||
<Select
|
||||
value={syncStore.provider}
|
||||
options={Object.entries(ProviderType).map(([k, v]) => ({
|
||||
value: v,
|
||||
label: k,
|
||||
}))}
|
||||
onSelect={(v) => {
|
||||
syncStore.update((config) => (config.provider = v));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.Proxy.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
|
||||
>
|
||||
<Switch
|
||||
value={syncStore.useProxy}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.useProxy = e));
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{syncStore.useProxy ? (
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.Config.ProxyUrl.Title}
|
||||
subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.proxyUrl}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.proxyUrl = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
) : null}
|
||||
|
||||
{syncStore.provider === ProviderType.WebDAV && (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.webdav.endpoint}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.webdav.endpoint = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.webdav.username}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.webdav.username = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
|
||||
<Input
|
||||
value={syncStore.webdav.password}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.webdav.password = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{syncStore.provider === ProviderType.UpStash && (
|
||||
<>
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.upstash.endpoint}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.upstash.endpoint = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
|
||||
<Input
|
||||
type="text"
|
||||
value={syncStore.upstash.username}
|
||||
placeholder={STORAGE_KEY}
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.upstash.username = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
|
||||
<Input
|
||||
value={syncStore.upstash.apiKey}
|
||||
type="password"
|
||||
onChange={(e) => {
|
||||
syncStore.update((config) => (config.upstash.apiKey = e));
|
||||
}}
|
||||
></Input>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,112 +0,0 @@
|
||||
import ConfigIcon from "@/app/icons/configIcon2.svg";
|
||||
import ExportIcon from "@/app/icons/exportIcon.svg";
|
||||
import ImportIcon from "@/app/icons/importIcon.svg";
|
||||
import SyncIcon from "@/app/icons/syncIcon.svg";
|
||||
|
||||
import { showToast } from "@/app/components/ui-lib";
|
||||
import { useChatStore } from "@/app/store/chat";
|
||||
import { useMaskStore } from "@/app/store/mask";
|
||||
import { usePromptStore } from "@/app/store/prompt";
|
||||
import { useSyncStore } from "@/app/store/sync";
|
||||
import { useMemo, useState } from "react";
|
||||
import Locale from "@/app/locales";
|
||||
|
||||
import SyncConfigModal from "./SyncConfigModal";
|
||||
import List, { ListItem } from "@/app/components/List";
|
||||
import Btn from "@/app/components/Btn";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
|
||||
export default function SyncItems() {
|
||||
const syncStore = useSyncStore();
|
||||
const chatStore = useChatStore();
|
||||
const promptStore = usePromptStore();
|
||||
const maskStore = useMaskStore();
|
||||
const couldSync = useMemo(() => {
|
||||
return syncStore.cloudSync();
|
||||
}, [syncStore]);
|
||||
|
||||
const { isMobileScreen } = useAppConfig();
|
||||
|
||||
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
|
||||
|
||||
const stateOverview = useMemo(() => {
|
||||
const sessions = chatStore.sessions;
|
||||
const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
|
||||
|
||||
return {
|
||||
chat: sessions.length,
|
||||
message: messageCount,
|
||||
prompt: Object.keys(promptStore.prompts).length,
|
||||
mask: Object.keys(maskStore.masks).length,
|
||||
};
|
||||
}, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
|
||||
|
||||
const textStyle = "!text-sm";
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.CloudState}
|
||||
subTitle={
|
||||
syncStore.lastProvider
|
||||
? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
|
||||
syncStore.lastProvider
|
||||
}]`
|
||||
: Locale.Settings.Sync.NotSyncYet
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Btn
|
||||
onClick={() => {
|
||||
setShowSyncConfigModal(true);
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Config}</span>}
|
||||
prefixIcon={isMobileScreen ? undefined : <ConfigIcon />}
|
||||
></Btn>
|
||||
{couldSync && (
|
||||
<Btn
|
||||
onClick={async () => {
|
||||
try {
|
||||
await syncStore.sync();
|
||||
showToast(Locale.Settings.Sync.Success);
|
||||
} catch (e) {
|
||||
showToast(Locale.Settings.Sync.Fail);
|
||||
console.error("[Sync]", e);
|
||||
}
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Sync}</span>}
|
||||
prefixIcon={<SyncIcon />}
|
||||
></Btn>
|
||||
)}
|
||||
</div>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Settings.Sync.LocalState}
|
||||
subTitle={Locale.Settings.Sync.Overview(stateOverview)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Btn
|
||||
onClick={() => {
|
||||
syncStore.export();
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Export}</span>}
|
||||
prefixIcon={<ExportIcon />}
|
||||
></Btn>
|
||||
<Btn
|
||||
onClick={async () => {
|
||||
syncStore.import();
|
||||
}}
|
||||
text={<span className={textStyle}>{Locale.UI.Import}</span>}
|
||||
prefixIcon={<ImportIcon />}
|
||||
></Btn>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{showSyncConfigModal && (
|
||||
<SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Prompt, SearchService, usePromptStore } from "@/app/store/prompt";
|
||||
import { Input as Textarea, Modal } from "@/app/components/ui-lib";
|
||||
import Locale from "@/app/locales";
|
||||
import { IconButton } from "@/app/components/button";
|
||||
|
||||
import AddIcon from "@/app/icons/add.svg";
|
||||
import CopyIcon from "@/app/icons/copy.svg";
|
||||
import ClearIcon from "@/app/icons/clear.svg";
|
||||
import EditIcon from "@/app/icons/edit.svg";
|
||||
import EyeIcon from "@/app/icons/eye.svg";
|
||||
|
||||
import styles from "../index.module.scss";
|
||||
import { copyToClipboard } from "@/app/utils";
|
||||
import Input from "@/app/components/Input";
|
||||
|
||||
function EditPromptModal(props: { id: string; onClose: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
const prompt = promptStore.get(props.id);
|
||||
|
||||
return prompt ? (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Prompt.EditModal.Title}
|
||||
onClose={props.onClose}
|
||||
actions={[
|
||||
<IconButton
|
||||
key=""
|
||||
onClick={props.onClose}
|
||||
text={Locale.UI.Confirm}
|
||||
bordered
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<div className={styles["edit-prompt-modal"]}>
|
||||
<Input
|
||||
type="text"
|
||||
value={prompt.title}
|
||||
readOnly={!prompt.isUser}
|
||||
className={styles["edit-prompt-title"]}
|
||||
onChange={(e) =>
|
||||
promptStore.updatePrompt(props.id, (prompt) => (prompt.title = e))
|
||||
}
|
||||
></Input>
|
||||
<Textarea
|
||||
value={prompt.content}
|
||||
readOnly={!prompt.isUser}
|
||||
className={styles["edit-prompt-content"]}
|
||||
rows={10}
|
||||
onInput={(e) =>
|
||||
promptStore.updatePrompt(
|
||||
props.id,
|
||||
(prompt) => (prompt.content = e.currentTarget.value),
|
||||
)
|
||||
}
|
||||
></Textarea>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default function UserPromptModal(props: { onClose?: () => void }) {
|
||||
const promptStore = usePromptStore();
|
||||
const userPrompts = promptStore.getUserPrompts();
|
||||
const builtinPrompts = SearchService.builtinPrompts;
|
||||
const allPrompts = userPrompts.concat(builtinPrompts);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
|
||||
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
|
||||
|
||||
const [editingPromptId, setEditingPromptId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchInput.length > 0) {
|
||||
const searchResult = SearchService.search(searchInput);
|
||||
setSearchPrompts(searchResult);
|
||||
} else {
|
||||
setSearchPrompts([]);
|
||||
}
|
||||
}, [searchInput]);
|
||||
|
||||
return (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Settings.Prompt.Modal.Title}
|
||||
onClose={() => props.onClose?.()}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="add"
|
||||
onClick={() => {
|
||||
const promptId = promptStore.add({
|
||||
id: nanoid(),
|
||||
createdAt: Date.now(),
|
||||
title: "Empty Prompt",
|
||||
content: "Empty Prompt Content",
|
||||
});
|
||||
setEditingPromptId(promptId);
|
||||
}}
|
||||
icon={<AddIcon />}
|
||||
bordered
|
||||
text={Locale.Settings.Prompt.Modal.Add}
|
||||
/>,
|
||||
]}
|
||||
// className="!bg-modal-mask"
|
||||
>
|
||||
<div className={styles["user-prompt-modal"]}>
|
||||
<Input
|
||||
type="text"
|
||||
className={styles["user-prompt-search"]}
|
||||
placeholder={Locale.Settings.Prompt.Modal.Search}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e)}
|
||||
></Input>
|
||||
|
||||
<div className={styles["user-prompt-list"]}>
|
||||
{prompts.map((v, _) => (
|
||||
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
|
||||
<div className={styles["user-prompt-header"]}>
|
||||
<div className={styles["user-prompt-title"]}>{v.title}</div>
|
||||
<div className={styles["user-prompt-content"] + " one-line"}>
|
||||
{v.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["user-prompt-buttons"]}>
|
||||
{v.isUser && (
|
||||
<IconButton
|
||||
icon={<ClearIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => promptStore.remove(v.id!)}
|
||||
/>
|
||||
)}
|
||||
{v.isUser ? (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => setEditingPromptId(v.id)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => setEditingPromptId(v.id)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={<CopyIcon />}
|
||||
className={styles["user-prompt-button"]}
|
||||
onClick={() => copyToClipboard(v.content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{editingPromptId !== undefined && (
|
||||
<EditPromptModal
|
||||
id={editingPromptId!}
|
||||
onClose={() => setEditingPromptId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,69 +0,0 @@
|
||||
.avatar {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.edit-prompt-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.edit-prompt-title {
|
||||
max-width: unset;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.edit-prompt-content {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.user-prompt-modal {
|
||||
min-height: 40vh;
|
||||
|
||||
.user-prompt-search {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.user-prompt-list {
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
|
||||
.user-prompt-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: var(--border-in-light);
|
||||
}
|
||||
|
||||
.user-prompt-header {
|
||||
max-width: calc(100% - 100px);
|
||||
|
||||
.user-prompt-title {
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
font-weight: bold;
|
||||
}
|
||||
.user-prompt-content {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-prompt-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 2px;
|
||||
|
||||
.user-prompt-button {
|
||||
//height: 100%;
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
import Locale from "@/app/locales";
|
||||
import MenuLayout from "@/app/components/MenuLayout";
|
||||
|
||||
import Panel from "./SettingPanel";
|
||||
|
||||
import GotoIcon from "@/app/icons/goto.svg";
|
||||
import { useAppConfig } from "@/app/store";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const list = [
|
||||
{
|
||||
id: Locale.Settings.GeneralSettings,
|
||||
title: Locale.Settings.GeneralSettings,
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
id: Locale.Settings.ModelSettings,
|
||||
title: Locale.Settings.ModelSettings,
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
id: Locale.Settings.DataSettings,
|
||||
title: Locale.Settings.DataSettings,
|
||||
icon: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default MenuLayout(function SettingList(props) {
|
||||
const { setShowPanel, setExternalProps } = props;
|
||||
const config = useAppConfig();
|
||||
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
const [selected, setSelected] = useState(list[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
setExternalProps?.(list[0]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
max-md:h-[100%] max-md:mx-[-1rem] max-md:py-6 max-md:px-4 max-md:bg-settings-menu-mobile
|
||||
md:pt-7
|
||||
`}
|
||||
>
|
||||
<div data-tauri-drag-region>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between
|
||||
max-md:h-menu-title-mobile
|
||||
md:pb-5 md:px-4
|
||||
`}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="text-setting-title text-text-settings-menu-title font-common !font-bold">
|
||||
{Locale.Settings.Title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col gap-2 overflow-y-auto overflow-x-hidden w-[100%]`}
|
||||
>
|
||||
{list.map((i) => (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`
|
||||
p-4 font-common text-setting-items font-normal text-text-settings-menu-item-title
|
||||
cursor-pointer
|
||||
border
|
||||
rounded-md
|
||||
border-transparent
|
||||
${
|
||||
selected === i.id && !isMobileScreen
|
||||
? `!bg-chat-menu-session-selected !border-chat-menu-session-selected !font-medium`
|
||||
: `hover:bg-chat-menu-session-unselected hover:border-chat-menu-session-unselected`
|
||||
}
|
||||
|
||||
flex justify-between items-center
|
||||
max-md:bg-settings-menu-item-mobile
|
||||
`}
|
||||
onClick={() => {
|
||||
setShowPanel?.(true);
|
||||
setExternalProps?.(i);
|
||||
setSelected(i.id);
|
||||
}}
|
||||
>
|
||||
{i.title}
|
||||
{i.icon}
|
||||
{isMobileScreen && <GotoIcon />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, Panel);
|
@@ -1,124 +0,0 @@
|
||||
import GitHubIcon from "@/app/icons/githubIcon.svg";
|
||||
import DiscoverIcon from "@/app/icons/discoverActive.svg";
|
||||
import DiscoverInactiveIcon from "@/app/icons/discoverInactive.svg";
|
||||
import DiscoverMobileActive from "@/app/icons/discoverMobileActive.svg";
|
||||
import DiscoverMobileInactive from "@/app/icons/discoverMobileInactive.svg";
|
||||
import SettingIcon from "@/app/icons/settingActive.svg";
|
||||
import SettingInactiveIcon from "@/app/icons/settingInactive.svg";
|
||||
import SettingMobileActive from "@/app/icons/settingMobileActive.svg";
|
||||
import SettingMobileInactive from "@/app/icons/settingMobileInactive.svg";
|
||||
import AssistantActiveIcon from "@/app/icons/assistantActive.svg";
|
||||
import AssistantInactiveIcon from "@/app/icons/assistantInactive.svg";
|
||||
import AssistantMobileActive from "@/app/icons/assistantMobileActive.svg";
|
||||
import AssistantMobileInactive from "@/app/icons/assistantMobileInactive.svg";
|
||||
|
||||
import { useAppConfig } from "@/app/store";
|
||||
import { Path, REPO_URL } from "@/app/constant";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import useHotKey from "@/app/hooks/useHotKey";
|
||||
import ActionsBar from "@/app/components/ActionsBar";
|
||||
|
||||
export function SideBar(props: { className?: string }) {
|
||||
const navigate = useNavigate();
|
||||
const loc = useLocation();
|
||||
|
||||
const config = useAppConfig();
|
||||
const { isMobileScreen } = config;
|
||||
|
||||
useHotKey();
|
||||
|
||||
let selectedTab: string;
|
||||
|
||||
switch (loc.pathname) {
|
||||
case Path.Masks:
|
||||
case Path.NewChat:
|
||||
selectedTab = Path.Masks;
|
||||
break;
|
||||
case Path.Settings:
|
||||
selectedTab = Path.Settings;
|
||||
break;
|
||||
default:
|
||||
selectedTab = Path.Home;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex h-[100%]
|
||||
max-md:flex-col-reverse max-md:w-[100%]
|
||||
md:relative
|
||||
`}
|
||||
>
|
||||
<ActionsBar
|
||||
inMobile={isMobileScreen}
|
||||
actionsShema={[
|
||||
{
|
||||
id: Path.Masks,
|
||||
icons: {
|
||||
active: <DiscoverIcon />,
|
||||
inactive: <DiscoverInactiveIcon />,
|
||||
mobileActive: <DiscoverMobileActive />,
|
||||
mobileInactive: <DiscoverMobileInactive />,
|
||||
},
|
||||
title: "Discover",
|
||||
activeClassName: "shadow-sidebar-btn-shadow",
|
||||
className: "mb-4 hover:bg-sidebar-btn-hovered",
|
||||
},
|
||||
{
|
||||
id: Path.Home,
|
||||
icons: {
|
||||
active: <AssistantActiveIcon />,
|
||||
inactive: <AssistantInactiveIcon />,
|
||||
mobileActive: <AssistantMobileActive />,
|
||||
mobileInactive: <AssistantMobileInactive />,
|
||||
},
|
||||
title: "Assistant",
|
||||
activeClassName: "shadow-sidebar-btn-shadow",
|
||||
className: "mb-4 hover:bg-sidebar-btn-hovered",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
icons: <GitHubIcon />,
|
||||
className: "!p-2 mb-3 hover:bg-sidebar-btn-hovered",
|
||||
},
|
||||
{
|
||||
id: Path.Settings,
|
||||
icons: {
|
||||
active: <SettingIcon />,
|
||||
inactive: <SettingInactiveIcon />,
|
||||
mobileActive: <SettingMobileActive />,
|
||||
mobileInactive: <SettingMobileInactive />,
|
||||
},
|
||||
className: "!p-2 hover:bg-sidebar-btn-hovered",
|
||||
title: "Settrings",
|
||||
},
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
if (id === "github") {
|
||||
return window.open(REPO_URL, "noopener noreferrer");
|
||||
}
|
||||
if (id !== Path.Masks) {
|
||||
return navigate(id);
|
||||
}
|
||||
if (config.dontShowMaskSplashScreen !== true) {
|
||||
navigate(Path.NewChat, { state: { fromHome: true } });
|
||||
} else {
|
||||
navigate(Path.Masks, { state: { fromHome: true } });
|
||||
}
|
||||
}}
|
||||
groups={{
|
||||
normal: [
|
||||
[Path.Home, Path.Masks],
|
||||
["github", Path.Settings],
|
||||
],
|
||||
mobile: [[Path.Home, Path.Masks, Path.Settings]],
|
||||
}}
|
||||
selected={selectedTab}
|
||||
className={`
|
||||
max-md:bg-sidebar-mobile max-md:h-mobile max-md:justify-around
|
||||
2xl:px-5 xl:px-4 md:px-2 md:py-6 md:flex-col
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
require("../polyfill");
|
||||
|
||||
import { HashRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import { useState, useEffect, useLayoutEffect } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Path } from "@/app/constant";
|
||||
import { ErrorBoundary } from "@/app/components/error";
|
||||
import { getISOLang } from "@/app/locales";
|
||||
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
|
||||
import { AuthPage } from "@/app/components/auth";
|
||||
import { getClientConfig } from "@/app/config/client";
|
||||
import { useAccessStore, useAppConfig } from "@/app/store";
|
||||
import { useLoadData } from "@/app/hooks/useLoadData";
|
||||
import Loading from "@/app/components/Loading";
|
||||
import Screen from "@/app/components/Screen";
|
||||
import { SideBar } from "./Sidebar";
|
||||
import GlobalLoading from "@/app/components/GlobalLoading";
|
||||
import { MOBILE_MAX_WIDTH } from "../hooks/useListenWinResize";
|
||||
|
||||
const Settings = dynamic(
|
||||
async () => await import("@/app/containers/Settings"),
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const Chat = dynamic(async () => await import("@/app/containers/Chat"), {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const NewChat = dynamic(
|
||||
async () => (await import("@/app/components/new-chat")).NewChat,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const MaskPage = dynamic(
|
||||
async () => (await import("@/app/components/mask")).MaskPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
function useHtmlLang() {
|
||||
useEffect(() => {
|
||||
const lang = getISOLang();
|
||||
const htmlLang = document.documentElement.lang;
|
||||
|
||||
if (lang !== htmlLang) {
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
const useHasHydrated = () => {
|
||||
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
return hasHydrated;
|
||||
};
|
||||
|
||||
const loadAsyncGoogleFont = () => {
|
||||
const linkEl = document.createElement("link");
|
||||
const proxyFontUrl = "/google-fonts";
|
||||
const remoteFontUrl = "https://fonts.googleapis.com";
|
||||
const googleFontUrl =
|
||||
getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
|
||||
linkEl.rel = "stylesheet";
|
||||
linkEl.href =
|
||||
googleFontUrl +
|
||||
"/css2?family=" +
|
||||
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
|
||||
"&display=swap";
|
||||
document.head.appendChild(linkEl);
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
useSwitchTheme();
|
||||
useLoadData();
|
||||
useHtmlLang();
|
||||
const config = useAppConfig();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[Config] got config from build time", getClientConfig());
|
||||
useAccessStore.getState().fetch();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
loadAsyncGoogleFont();
|
||||
config.update(
|
||||
(config) =>
|
||||
(config.isMobileScreen = window.innerWidth <= MOBILE_MAX_WIDTH),
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (!useHasHydrated()) {
|
||||
return <GlobalLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route
|
||||
path={Path.NewChat}
|
||||
element={
|
||||
<NewChat
|
||||
className={`
|
||||
md:w-[100%] px-1
|
||||
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
|
||||
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={Path.Masks}
|
||||
element={
|
||||
<MaskPage
|
||||
className={`
|
||||
md:w-[100%]
|
||||
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
|
||||
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
|
||||
`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Screen>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
@@ -1,59 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}, []);
|
||||
}
|
@@ -1,8 +0,0 @@
|
||||
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;
|
||||
}
|
@@ -1,72 +0,0 @@
|
||||
import { compressImage, isVisionModel } from "@/app/utils";
|
||||
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<string[]>([]);
|
||||
const optionsRef = useRef<UseUploadImageOptions>({});
|
||||
const chatStoreRef = useRef<typeof chatStore | undefined>();
|
||||
|
||||
attachImagesRef.current = attachImages;
|
||||
optionsRef.current = options;
|
||||
chatStoreRef.current = chatStore;
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
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<string[]>((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,
|
||||
};
|
||||
}
|
@@ -1,104 +0,0 @@
|
||||
import { RefObject, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export interface Options {
|
||||
containerRef?: RefObject<HTMLElement | null>;
|
||||
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<Orientation, boolean>;
|
||||
relativePosition: [X, Y];
|
||||
};
|
||||
}
|
||||
|
||||
export default function useRelativePosition({
|
||||
containerRef = { current: window.document.body },
|
||||
delay = 100,
|
||||
offsetDistance = 0,
|
||||
}: Options) {
|
||||
const [position, setPosition] = useState<Position | undefined>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
@@ -1,39 +0,0 @@
|
||||
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<HTMLTextAreaElement>;
|
||||
}) {
|
||||
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,
|
||||
};
|
||||
}
|
@@ -1,61 +0,0 @@
|
||||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
) {
|
||||
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<typeof autoScroll>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
@@ -1,29 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useShowPromptHint<RenderPompt>(props: {
|
||||
prompts: RenderPompt[];
|
||||
}) {
|
||||
const [internalPrompts, setInternalPrompts] = useState<RenderPompt[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
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<HTMLTextAreaElement>) => {
|
||||
// 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,
|
||||
};
|
||||
}
|
@@ -1,48 +0,0 @@
|
||||
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]);
|
||||
}
|
@@ -1,69 +0,0 @@
|
||||
import { compressImage } from "@/app/utils";
|
||||
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<string[]>([]);
|
||||
const optionsRef = useRef<UseUploadImageOptions>({});
|
||||
|
||||
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<string[]>((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,
|
||||
};
|
||||
}
|
@@ -1,43 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function useWindowSize(callback?: (size: Size) => void) {
|
||||
const callbackRef = useRef<typeof callback>();
|
||||
|
||||
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;
|
||||
}
|
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.1001 12.0001C1.1001 6.00304 6.00304 1.1001 12.0001 1.1001C17.9972 1.1001 22.9001 6.00304 22.9001 12.0001C22.9001 17.9972 17.9972 22.9001 12.0001 22.9001C6.00304 22.9001 1.1001 17.9972 1.1001 12.0001ZM12.0001 2.9001C6.99715 2.9001 2.9001 6.99715 2.9001 12.0001C2.9001 17.003 6.99715 21.1001 12.0001 21.1001C17.003 21.1001 21.1001 17.003 21.1001 12.0001C21.1001 6.99715 17.003 2.9001 12.0001 2.9001Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.1001 12.0001C7.1001 11.503 7.50304 11.1001 8.0001 11.1001H16.0001C16.4972 11.1001 16.9001 11.503 16.9001 12.0001C16.9001 12.4972 16.4972 12.9001 16.0001 12.9001H8.0001C7.50304 12.9001 7.1001 12.4972 7.1001 12.0001Z" fill="#606078"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0001 7.1001C12.4972 7.1001 12.9001 7.50304 12.9001 8.0001V16.0001C12.9001 16.4972 12.4972 16.9001 12.0001 16.9001C11.503 16.9001 11.1001 16.4972 11.1001 16.0001V8.0001C11.1001 7.50304 11.503 7.1001 12.0001 7.1001Z" fill="#606078"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.91663 0.666504C4.36028 0.666504 0.666626 4.36015 0.666626 8.9165V15.5832C0.666626 16.4576 1.37551 17.1665 2.24996 17.1665H8.91663C13.473 17.1665 17.1666 13.4729 17.1666 8.9165C17.1666 4.36015 13.473 0.666504 8.91663 0.666504ZM8.50004 5.25C8.91425 5.25 9.25004 5.58579 9.25004 6V8.16667H11.4167C11.8309 8.16667 12.1667 8.50245 12.1667 8.91667C12.1667 9.33088 11.8309 9.66667 11.4167 9.66667H9.25004V11.8333C9.25004 12.2475 8.91425 12.5833 8.50004 12.5833C8.08583 12.5833 7.75004 12.2475 7.75004 11.8333V9.66667H5.58333C5.16912 9.66667 4.83333 9.33088 4.83333 8.91667C4.83333 8.50245 5.16912 8.16667 5.58333 8.16667H7.75004V6C7.75004 5.58579 8.08583 5.25 8.50004 5.25Z" fill="#2E42F3"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 839 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C2 2.89543 2.89543 2 4 2H12H12.1639V2.00132C17.6112 2.08887 22 6.5319 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 11.947 2.00041 11.8941 2.00123 11.8413H2V4ZM7.57373 9.78713C7.57373 9.10809 8.1242 8.55762 8.80324 8.55762C9.48228 8.55762 10.0327 9.10809 10.0327 9.78713V11.2625C10.0327 11.9416 9.48228 12.492 8.80324 12.492C8.1242 12.492 7.57373 11.9416 7.57373 11.2625V9.78713ZM13.9673 9.78713C13.9673 9.10809 14.5178 8.55762 15.1968 8.55762C15.8758 8.55762 16.4263 9.10809 16.4263 9.78713V11.2625C16.4263 11.9416 15.8758 12.492 15.1968 12.492C14.5178 12.492 13.9673 11.9416 13.9673 11.2625V9.78713Z" fill="url(#paint0_linear_434_27904)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_434_27904" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E5E6FF"/>
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 1021 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C2 2.89543 2.89543 2 4 2H12H12.1639V2.00132C17.6112 2.08887 22 6.5319 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 11.947 2.00041 11.8941 2.00123 11.8413H2V4ZM7.57373 9.78713C7.57373 9.10809 8.1242 8.55762 8.80324 8.55762C9.48228 8.55762 10.0327 9.10809 10.0327 9.78713V11.2625C10.0327 11.9416 9.48228 12.492 8.80324 12.492C8.1242 12.492 7.57373 11.9416 7.57373 11.2625V9.78713ZM13.9673 9.78713C13.9673 9.10809 14.5178 8.55762 15.1968 8.55762C15.8758 8.55762 16.4263 9.10809 16.4263 9.78713V11.2625C16.4263 11.9416 15.8758 12.492 15.1968 12.492C14.5178 12.492 13.9673 11.9416 13.9673 11.2625V9.78713Z" fill="#A5A5B3"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 792 B |
@@ -1,9 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 4C2 2.89543 2.89543 2 4 2H12H12.1639V2.00132C17.6112 2.08887 22 6.5319 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 11.947 2.00041 11.8941 2.00123 11.8413H2V4ZM7.57373 9.78713C7.57373 9.10809 8.1242 8.55762 8.80324 8.55762C9.48228 8.55762 10.0327 9.10809 10.0327 9.78713V11.2625C10.0327 11.9416 9.48228 12.492 8.80324 12.492C8.1242 12.492 7.57373 11.9416 7.57373 11.2625V9.78713ZM13.9673 9.78713C13.9673 9.10809 14.5178 8.55762 15.1968 8.55762C15.8758 8.55762 16.4263 9.10809 16.4263 9.78713V11.2625C16.4263 11.9416 15.8758 12.492 15.1968 12.492C14.5178 12.492 13.9673 11.9416 13.9673 11.2625V9.78713Z" fill="url(#paint0_linear_460_34351)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_460_34351" x1="2" y1="12" x2="22.022" y2="12" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2A33FF"/>
|
||||
<stop offset="0.997219" stop-color="#7963FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,7 +0,0 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="path-1-inside-1_769_8890" fill="white">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.5 2C3.39543 2 2.5 2.89543 2.5 4V11.8413H2.50123C2.50041 11.8941 2.5 11.947 2.5 12C2.5 17.5228 6.97715 22 12.5 22C18.0228 22 22.5 17.5228 22.5 12C22.5 6.5319 18.1112 2.08887 12.6639 2.00132V2H12.5H4.5Z"/>
|
||||
</mask>
|
||||
<path d="M2.5 11.8413H0.7V13.6413H2.5V11.8413ZM2.50123 11.8413L4.30102 11.8693L4.32946 10.0413H2.50123V11.8413ZM12.6639 2.00132H10.8639V3.77262L12.635 3.80108L12.6639 2.00132ZM12.6639 2H14.4639V0.2H12.6639V2ZM4.3 4C4.3 3.88954 4.38954 3.8 4.5 3.8V0.2C2.40132 0.2 0.7 1.90131 0.7 4H4.3ZM4.3 11.8413V4H0.7V11.8413H4.3ZM2.50123 10.0413H2.5V13.6413H2.50123V10.0413ZM4.3 12C4.3 11.9563 4.30034 11.9128 4.30102 11.8693L0.701452 11.8133C0.700485 11.8754 0.7 11.9377 0.7 12H4.3ZM12.5 20.2C7.97126 20.2 4.3 16.5287 4.3 12H0.7C0.7 18.517 5.98304 23.8 12.5 23.8V20.2ZM20.7 12C20.7 16.5287 17.0287 20.2 12.5 20.2V23.8C19.017 23.8 24.3 18.517 24.3 12H20.7ZM12.635 3.80108C17.1011 3.87287 20.7 7.51632 20.7 12H24.3C24.3 5.54748 19.1212 0.30487 12.6929 0.201549L12.635 3.80108ZM10.8639 2V2.00132H14.4639V2H10.8639ZM12.5 3.8H12.6639V0.2H12.5V3.8ZM4.5 3.8H12.5V0.2H4.5V3.8Z" fill="#A5A5B3" mask="url(#path-1-inside-1_769_8890)"/>
|
||||
<path opacity="0.89" fill-rule="evenodd" clip-rule="evenodd" d="M9.30324 8.55762C8.6242 8.55762 8.07373 9.10809 8.07373 9.78713V11.2625C8.07373 11.9416 8.6242 12.492 9.30324 12.492C9.98228 12.492 10.5327 11.9416 10.5327 11.2625V9.78713C10.5327 9.10809 9.98228 8.55762 9.30324 8.55762ZM15.6968 8.55762C15.0178 8.55762 14.4673 9.10809 14.4673 9.78713V11.2625C14.4673 11.9416 15.0178 12.492 15.6968 12.492C16.3758 12.492 16.9263 11.9416 16.9263 11.2625V9.78713C16.9263 9.10809 16.3758 8.55762 15.6968 8.55762Z" fill="#A5A5B3"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.37893 2.95457C7.26177 2.83741 7.07182 2.83741 6.95466 2.95457L4.5237 5.38553C4.51068 5.39855 4.48958 5.39855 4.47656 5.38553L2.0456 2.95457C1.92844 2.83741 1.73849 2.83741 1.62133 2.95457C1.50417 3.07172 1.50417 3.26167 1.62133 3.37883L4.0523 5.8098C4.29963 6.05713 4.70063 6.05713 4.94796 5.8098L7.37893 3.37883C7.49609 3.26167 7.49609 3.07172 7.37893 2.95457Z" fill="#88889A"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 531 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="28" viewBox="0 0 16 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1486 20.0156C13.388 20.255 13.7762 20.255 14.0156 20.0156C14.255 19.7762 14.255 19.388 14.0156 19.1486L8.86702 14L14.0157 8.85133C14.2551 8.6119 14.2551 8.22371 14.0157 7.98428C13.7762 7.74486 13.3881 7.74486 13.1486 7.98428L7.99998 13.1329L2.8513 7.98426C2.61187 7.74483 2.22368 7.74483 1.98426 7.98426C1.74483 8.22368 1.74483 8.61187 1.98426 8.8513L7.13294 14L1.98432 19.1486C1.74489 19.388 1.74489 19.7762 1.98432 20.0156C2.22374 20.2551 2.61193 20.2551 2.85136 20.0156L7.99998 14.867L13.1486 20.0156Z" fill="#606078"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 679 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 1.75C3.21079 1.75 1.75 3.21079 1.75 5C1.75 6.78921 3.21079 8.25 5 8.25H6.75V11.75H5C3.21079 11.75 1.75 13.2108 1.75 15C1.75 16.7892 3.21079 18.25 5 18.25C6.78921 18.25 8.25 16.7892 8.25 15V13.25H11.75V15C11.75 16.7892 13.2108 18.25 15 18.25C16.7892 18.25 18.25 16.7892 18.25 15C18.25 13.2108 16.7892 11.75 15 11.75H13.25V8.25H15C16.7892 8.25 18.25 6.78921 18.25 5C18.25 3.21079 16.7892 1.75 15 1.75C13.2108 1.75 11.75 3.21079 11.75 5V6.75H8.25V5C8.25 3.21079 6.78921 1.75 5 1.75ZM3.25 5C3.25 4.03921 4.03921 3.25 5 3.25C5.96079 3.25 6.75 4.03921 6.75 5V6.75H5C4.03921 6.75 3.25 5.96079 3.25 5ZM8.25 8.25V11.75H11.75V8.25H8.25ZM5 13.25C4.03921 13.25 3.25 14.0392 3.25 15C3.25 15.9608 4.03921 16.75 5 16.75C5.96079 16.75 6.75 15.9608 6.75 15V13.25H5ZM15 3.25C14.0392 3.25 13.25 4.03921 13.25 5V6.75H15C15.9608 6.75 16.75 5.96079 16.75 5C16.75 4.03921 15.9608 3.25 15 3.25ZM13.25 15V13.25H15C15.9608 13.25 16.75 14.0392 16.75 15C16.75 15.9608 15.9608 16.75 15 16.75C14.0392 16.75 13.25 15.9608 13.25 15Z" fill="#606078"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="36" height="20" viewBox="0 0 36 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="20" rx="4" fill="#F0F0F3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 7.04688C5.5 6.19256 6.19256 5.5 7.04688 5.5C7.90119 5.5 8.59375 6.19256 8.59375 7.04688V7.65625H11.4062V7.04688C11.4062 6.19255 12.0988 5.5 12.9531 5.5C13.8074 5.5 14.5 6.19256 14.5 7.04688C14.5 7.90118 13.8074 8.59375 12.9531 8.59375H12.3438V11.3594H12.9531C13.8074 11.3594 14.5 12.0519 14.5 12.9062C14.5 13.7606 13.8074 14.4531 12.9531 14.4531C12.0988 14.4531 11.4062 13.7606 11.4062 12.9062V12.2969H8.59375V12.9062C8.59375 13.7606 7.90119 14.4531 7.04688 14.4531C6.19255 14.4531 5.5 13.7606 5.5 12.9062C5.5 12.0519 6.19256 11.3594 7.04688 11.3594H7.65625V8.59375H7.04688C6.19256 8.59375 5.5 7.90119 5.5 7.04688ZM7.04688 6.4375C6.71033 6.4375 6.4375 6.71033 6.4375 7.04688C6.4375 7.38342 6.71033 7.65625 7.04688 7.65625H7.65625V7.04688C7.65625 6.71033 7.38342 6.4375 7.04688 6.4375ZM7.04688 12.2969C6.71032 12.2969 6.4375 12.5697 6.4375 12.9062C6.4375 13.2428 6.71033 13.5156 7.04688 13.5156C7.38342 13.5156 7.65625 13.2428 7.65625 12.9062V12.2969H7.04688ZM8.59375 11.3594V8.59375H11.4062V11.3594H8.59375ZM12.9531 6.4375C12.6166 6.4375 12.3438 6.71033 12.3438 7.04688V7.65625H12.9531C13.2897 7.65625 13.5625 7.38342 13.5625 7.04688C13.5625 6.71032 13.2897 6.4375 12.9531 6.4375ZM12.3438 12.9062V12.2969H12.9531C13.2897 12.2969 13.5625 12.5697 13.5625 12.9062C13.5625 13.2428 13.2897 13.5156 12.9531 13.5156C12.6166 13.5156 12.3438 13.2428 12.3438 12.9062Z" fill="#88889A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.0156 12.8125C22.0156 13.0714 22.2255 13.2812 22.4844 13.2812H27.5938C29.4577 13.2812 30.9688 11.7702 30.9688 9.90625C30.9688 8.04229 29.4577 6.53125 27.5938 6.53125H22.4844C22.2255 6.53125 22.0156 6.74112 22.0156 7C22.0156 7.25888 22.2255 7.46875 22.4844 7.46875H27.5938C28.9399 7.46875 30.0313 8.56006 30.0313 9.90625C30.0313 11.2524 28.9399 12.3438 27.5938 12.3438H22.4844C22.2255 12.3438 22.0156 12.5536 22.0156 12.8125Z" fill="#88889A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.6215 14.8224C23.7996 14.6344 23.7916 14.3378 23.6036 14.1597L22.1816 12.8125L21.5368 13.4931L22.9589 14.8403C23.1468 15.0183 23.4435 15.0103 23.6215 14.8224ZM21.5368 13.4931C21.1465 13.1233 21.1465 12.5017 21.5368 12.1319L22.9589 10.7847C23.1468 10.6067 23.4435 10.6147 23.6215 10.8026C23.7996 10.9906 23.7916 11.2872 23.6036 11.4653L22.1816 12.8125L21.5368 13.4931Z" fill="#88889A"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 2.5 KiB |