mirror of
https://github.com/Yidadaa/ChatGPT-Next-Web.git
synced 2025-08-31 19:49:03 +08:00
Compare commits
35 Commits
v2.12.3
...
refactor/n
Author | SHA1 | Date | |
---|---|---|---|
|
0c53579996 | ||
|
00b1a9781d | ||
|
240d330001 | ||
|
4e4431339f | ||
|
fa2f8c66d1 | ||
|
32f62d70af | ||
|
68f0fa917f | ||
|
8a14cb19a9 | ||
|
3d99965a8f | ||
|
4d5a9476b6 | ||
|
15d6ed252f | ||
|
ecf6cc27d6 | ||
|
cadd2558fd | ||
|
c3d91bf0cd | ||
|
996537d262 | ||
|
5ea6206319 | ||
|
8c28c408d8 | ||
|
c34b8ab919 | ||
|
9f4813326c | ||
|
9569888b0e | ||
|
1a636b0f50 | ||
|
48e8c0a194 | ||
|
59583e53bd | ||
|
bb7422c526 | ||
|
c99086447e | ||
|
f7074bba8c | ||
|
4400392c0c | ||
|
4a5465f884 | ||
|
37cc87531c | ||
|
1074fffe79 | ||
|
3d0a98d5d2 | ||
|
b3559f99a2 | ||
|
51a1d9f92a | ||
|
3fc9b91bf1 | ||
|
0a8e5d6734 |
@@ -1,4 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"plugins": ["prettier"]
|
"plugins": [
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"legacyDecorators": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["globals.css"]
|
||||||
}
|
}
|
||||||
|
102
app/(app)/chat/layout.tsx
Normal file
102
app/(app)/chat/layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
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 { useRef, useState } from "react";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
import React from "react";
|
||||||
|
import { AuthPage } from "@/app/components/auth";
|
||||||
|
import { SideBar } from "@/app/containers/Sidebar";
|
||||||
|
import Screen from "@/app/components/Screen";
|
||||||
|
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
|
||||||
|
import Chat from "@/app/containers/Chat/ChatPanel";
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [externalProps, setExternalProps] = useState({});
|
||||||
|
const config = useAppConfig();
|
||||||
|
useSwitchTheme();
|
||||||
|
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
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</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}
|
||||||
|
/> */}
|
||||||
|
{/* {children} */}
|
||||||
|
<Chat></Chat>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
137
app/(app)/chat/page.tsx
Normal file
137
app/(app)/chat/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
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 { useRouter, usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import AddIcon from "@/app/icons/addIcon.svg";
|
||||||
|
import NextChatTitle from "@/app/icons/nextchatTitle.svg";
|
||||||
|
|
||||||
|
import Modal from "@/app/components/Modal";
|
||||||
|
import SessionItem from "@/app/containers/Chat/components/SessionItem";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [sessions, selectedIndex, selectSession, moveSession] = useChatStore(
|
||||||
|
(state) => [
|
||||||
|
state.sessions,
|
||||||
|
state.currentSessionIndex,
|
||||||
|
state.selectSession,
|
||||||
|
state.moveSession,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
21
app/(app)/layout.tsx
Normal file
21
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AuthPage } from "@/app/components/auth";
|
||||||
|
import { SideBar } from "@/app/containers/Sidebar";
|
||||||
|
import Screen from "@/app/components/Screen";
|
||||||
|
|
||||||
|
export interface MenuWrapperInspectProps {
|
||||||
|
setExternalProps?: (v: Record<string, any>) => void;
|
||||||
|
setShowPanel?: (v: boolean) => void;
|
||||||
|
showPanel?: boolean;
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
|
||||||
|
{children}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
4
app/(app)/settings/layout.tsx
Normal file
4
app/(app)/settings/layout.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
3
app/(app)/settings/page.tsx
Normal file
3
app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <></>;
|
||||||
|
}
|
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { STORAGE_KEY, internalAllowedWebDavEndpoints } from "../../../constant";
|
import { STORAGE_KEY, internalWhiteWebDavEndpoints } from "../../../constant";
|
||||||
import { getServerSideConfig } from "@/app/config/server";
|
import { getServerSideConfig } from "@/app/config/server";
|
||||||
|
|
||||||
const config = getServerSideConfig();
|
const config = getServerSideConfig();
|
||||||
|
|
||||||
const mergedAllowedWebDavEndpoints = [
|
const mergedWhiteWebDavEndpoints = [
|
||||||
...internalAllowedWebDavEndpoints,
|
...internalWhiteWebDavEndpoints,
|
||||||
...config.allowedWebDevEndpoints,
|
...config.whiteWebDevEndpoints,
|
||||||
].filter((domain) => Boolean(domain.trim()));
|
].filter((domain) => Boolean(domain.trim()));
|
||||||
|
|
||||||
async function handle(
|
async function handle(
|
||||||
@@ -24,9 +24,7 @@ async function handle(
|
|||||||
|
|
||||||
// Validate the endpoint to prevent potential SSRF attacks
|
// Validate the endpoint to prevent potential SSRF attacks
|
||||||
if (
|
if (
|
||||||
!mergedAllowedWebDavEndpoints.some(
|
!mergedWhiteWebDavEndpoints.some((white) => endpoint?.startsWith(white))
|
||||||
(allowedEndpoint) => endpoint?.startsWith(allowedEndpoint),
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
@@ -161,13 +161,6 @@ export class ClaudeApi implements LLMApi {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prompt[0]?.role === "assistant") {
|
|
||||||
prompt.unshift({
|
|
||||||
role: "user",
|
|
||||||
content: ";",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestBody: AnthropicChatRequest = {
|
const requestBody: AnthropicChatRequest = {
|
||||||
messages: prompt,
|
messages: prompt,
|
||||||
stream: shouldStream,
|
stream: shouldStream,
|
||||||
|
@@ -21,10 +21,11 @@ export class GeminiProApi implements LLMApi {
|
|||||||
}
|
}
|
||||||
async chat(options: ChatOptions): Promise<void> {
|
async chat(options: ChatOptions): Promise<void> {
|
||||||
// const apiClient = this;
|
// const apiClient = this;
|
||||||
|
const visionModel = isVisionModel(options.config.model);
|
||||||
let multimodal = false;
|
let multimodal = false;
|
||||||
const messages = options.messages.map((v) => {
|
const messages = options.messages.map((v) => {
|
||||||
let parts: any[] = [{ text: getMessageTextContent(v) }];
|
let parts: any[] = [{ text: getMessageTextContent(v) }];
|
||||||
if (isVisionModel(options.config.model)) {
|
if (visionModel) {
|
||||||
const images = getMessageImages(v);
|
const images = getMessageImages(v);
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
multimodal = true;
|
multimodal = true;
|
||||||
@@ -116,12 +117,17 @@ export class GeminiProApi implements LLMApi {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
options.onController?.(controller);
|
options.onController?.(controller);
|
||||||
try {
|
try {
|
||||||
|
let googleChatPath = visionModel
|
||||||
|
? Google.VisionChatPath(modelConfig.model)
|
||||||
|
: Google.ChatPath(modelConfig.model);
|
||||||
|
let chatPath = this.path(googleChatPath);
|
||||||
|
|
||||||
// let baseUrl = accessStore.googleUrl;
|
// let baseUrl = accessStore.googleUrl;
|
||||||
|
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
baseUrl = isApp
|
baseUrl = isApp
|
||||||
? DEFAULT_API_HOST + "/api/proxy/google/" + Google.ChatPath(modelConfig.model)
|
? DEFAULT_API_HOST + "/api/proxy/google/" + googleChatPath
|
||||||
: this.path(Google.ChatPath(modelConfig.model));
|
: chatPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isApp) {
|
if (isApp) {
|
||||||
@@ -139,7 +145,6 @@ export class GeminiProApi implements LLMApi {
|
|||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
REQUEST_TIMEOUT_MS,
|
REQUEST_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldStream) {
|
if (shouldStream) {
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
let remainText = "";
|
let remainText = "";
|
||||||
|
@@ -129,7 +129,7 @@ export class ChatGPTApi implements LLMApi {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// add max_tokens to vision model
|
// add max_tokens to vision model
|
||||||
if (visionModel && modelConfig.model.includes("preview")) {
|
if (visionModel) {
|
||||||
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
requestPayload["max_tokens"] = Math.max(modelConfig.max_tokens, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
// import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import Locale from "./locales";
|
import Locale from "./locales";
|
||||||
|
|
||||||
type Command = (param: string) => void;
|
type Command = (param: string) => void;
|
||||||
@@ -14,22 +15,23 @@ interface Commands {
|
|||||||
export function useCommand(commands: Commands = {}) {
|
export function useCommand(commands: Commands = {}) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
// fixme: update commands
|
||||||
let shouldUpdate = false;
|
// useEffect(() => {
|
||||||
searchParams.forEach((param, name) => {
|
// let shouldUpdate = false;
|
||||||
const commandName = name as keyof Commands;
|
// searchParams.forEach((param, name) => {
|
||||||
if (typeof commands[commandName] === "function") {
|
// const commandName = name as keyof Commands;
|
||||||
commands[commandName]!(param);
|
// if (typeof commands[commandName] === "function") {
|
||||||
searchParams.delete(name);
|
// commands[commandName]!(param);
|
||||||
shouldUpdate = true;
|
// searchParams.delete(name);
|
||||||
}
|
// shouldUpdate = true;
|
||||||
});
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
if (shouldUpdate) {
|
// if (shouldUpdate) {
|
||||||
setSearchParams(searchParams);
|
// setSearchParams(searchParams);
|
||||||
}
|
// }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams, commands]);
|
// }, [searchParams, commands]);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatCommands {
|
interface ChatCommands {
|
||||||
|
123
app/components/ActionsBar/index.tsx
Normal file
123
app/components/ActionsBar/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { isValidElement } from "react";
|
||||||
|
|
||||||
|
type IconMap = {
|
||||||
|
active?: JSX.Element;
|
||||||
|
inactive?: JSX.Element;
|
||||||
|
mobileActive?: JSX.Element;
|
||||||
|
mobileInactive?: JSX.Element;
|
||||||
|
};
|
||||||
|
interface Action {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
icons: JSX.Element | IconMap;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
activeClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Groups = {
|
||||||
|
normal: string[][];
|
||||||
|
mobile: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ActionsBarProps {
|
||||||
|
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>;
|
||||||
|
}
|
78
app/components/Btn/index.tsx
Normal file
78
app/components/Btn/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export type ButtonType = "primary" | "danger" | null;
|
||||||
|
|
||||||
|
export interface BtnProps {
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
prefixIcon?: JSX.Element;
|
||||||
|
type?: ButtonType;
|
||||||
|
text?: React.ReactNode;
|
||||||
|
bordered?: boolean;
|
||||||
|
shadow?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Btn(props: BtnProps) {
|
||||||
|
const {
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
tabIndex,
|
||||||
|
autoFocus,
|
||||||
|
prefixIcon,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
let btnClassName;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "primary":
|
||||||
|
btnClassName = `${
|
||||||
|
disabled
|
||||||
|
? "bg-primary-btn-disabled dark:opacity-30 dark:text-primary-btn-disabled-dark"
|
||||||
|
: "bg-primary-btn shadow-btn"
|
||||||
|
} text-text-btn-primary `;
|
||||||
|
break;
|
||||||
|
case "danger":
|
||||||
|
btnClassName = `bg-danger-btn text-text-btn-danger hover:bg-hovered-danger-btn`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
btnClassName = `bg-default-btn text-text-btn-default hover:bg-hovered-btn`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
${className ?? ""}
|
||||||
|
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn transition-all duration-300 select-none
|
||||||
|
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
|
||||||
|
${btnClassName}
|
||||||
|
follow-parent-svg
|
||||||
|
`}
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
role="button"
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
>
|
||||||
|
{prefixIcon && (
|
||||||
|
<div className={`flex items-center justify-center`}>{prefixIcon}</div>
|
||||||
|
)}
|
||||||
|
{text && (
|
||||||
|
<div className={`font-common text-sm-title leading-4 line-clamp-1`}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
32
app/components/Card/index.tsx
Normal file
32
app/components/Card/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card(props: CardProps) {
|
||||||
|
const { className, children, title } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
capitalize !font-semibold text-sm-mobile font-weight-setting-card-title text-text-card-title
|
||||||
|
mb-3
|
||||||
|
|
||||||
|
ml-3
|
||||||
|
md:ml-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`px-4 py-1 rounded-lg bg-card ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
18
app/components/GlobalLoading/index.tsx
Normal file
18
app/components/GlobalLoading/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import BotIcon from "@/app/icons/bot.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
|
||||||
|
export default function GloablLoading({
|
||||||
|
noLogo,
|
||||||
|
}: {
|
||||||
|
noLogo?: boolean;
|
||||||
|
useSkeleton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center items-center w-[100%] h-[100%]`}
|
||||||
|
>
|
||||||
|
{!noLogo && <BotIcon />}
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
39
app/components/HoverPopover/index.tsx
Normal file
39
app/components/HoverPopover/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as HoverCard from "@radix-ui/react-hover-card";
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
content?: JSX.Element | string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
arrowClassName?: string;
|
||||||
|
popoverClassName?: string;
|
||||||
|
noArrow?: boolean;
|
||||||
|
align?: ComponentProps<typeof HoverCard.Content>["align"];
|
||||||
|
openDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HoverPopover(props: PopoverProps) {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
arrowClassName,
|
||||||
|
popoverClassName,
|
||||||
|
noArrow = false,
|
||||||
|
align,
|
||||||
|
openDelay = 300,
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<HoverCard.Root openDelay={openDelay}>
|
||||||
|
<HoverCard.Trigger asChild>{children}</HoverCard.Trigger>
|
||||||
|
<HoverCard.Portal>
|
||||||
|
<HoverCard.Content
|
||||||
|
className={`${popoverClassName}`}
|
||||||
|
sideOffset={5}
|
||||||
|
align={align}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
{!noArrow && <HoverCard.Arrow className={`${arrowClassName}`} />}
|
||||||
|
</HoverCard.Content>
|
||||||
|
</HoverCard.Portal>
|
||||||
|
</HoverCard.Root>
|
||||||
|
);
|
||||||
|
}
|
42
app/components/Imgs/index.tsx
Normal file
42
app/components/Imgs/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { getMessageImages } from "@/app/utils";
|
||||||
|
import { RequestMessage } from "@/app/client/api";
|
||||||
|
|
||||||
|
interface ImgsProps {
|
||||||
|
message: RequestMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Imgs(props: ImgsProps) {
|
||||||
|
const { message } = props;
|
||||||
|
const imgSrcs = getMessageImages(message);
|
||||||
|
|
||||||
|
if (imgSrcs.length < 1) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgVars = {
|
||||||
|
"--imgs-width": `calc(var(--max-message-width) - ${
|
||||||
|
imgSrcs.length - 1
|
||||||
|
}*0.25rem)`,
|
||||||
|
"--img-width": `calc(var(--imgs-width)/ ${imgSrcs.length})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-[100%] mt-[0.625rem] flex gap-1`}
|
||||||
|
style={imgVars as CSSProperties}
|
||||||
|
>
|
||||||
|
{imgSrcs.map((image, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex-1 min-w-[var(--img-width)] pb-[var(--img-width)] object-cover bg-cover bg-no-repeat bg-center box-border rounded-chat-img"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${image})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
88
app/components/Input/index.tsx
Normal file
88
app/components/Input/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import PasswordVisible from "@/app/icons/passwordVisible.svg";
|
||||||
|
import PasswordInvisible from "@/app/icons/passwordInvisible.svg";
|
||||||
|
import {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import List, { ListContext } from "@/app/components/List";
|
||||||
|
|
||||||
|
export interface CommonInputProps
|
||||||
|
extends Omit<
|
||||||
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
|
"onChange" | "type" | "value"
|
||||||
|
> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberInputProps {
|
||||||
|
onChange?: (v: number) => void;
|
||||||
|
type?: "number";
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextInputProps {
|
||||||
|
onChange?: (v: string) => void;
|
||||||
|
type?: "text" | "password";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputProps {
|
||||||
|
onChange?: ((v: string) => void) | ((v: number) => void);
|
||||||
|
type?: "text" | "password" | "number";
|
||||||
|
value?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Input(
|
||||||
|
props: CommonInputProps & NumberInputProps,
|
||||||
|
): JSX.Element;
|
||||||
|
export default function Input(
|
||||||
|
props: CommonInputProps & TextInputProps,
|
||||||
|
): JSX.Element;
|
||||||
|
export default function Input(props: CommonInputProps & InputProps) {
|
||||||
|
const { value, type = "text", onChange, className, ...rest } = props;
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
const { inputClassName } = useContext(ListContext);
|
||||||
|
|
||||||
|
const internalType = (show && "text") || type;
|
||||||
|
|
||||||
|
const { update, handleValidate } = useContext(List.ListContext);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
update?.({ type: "input" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
handleValidate?.(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={` group/input w-[100%] rounded-chat-input bg-input transition-colors duration-300 ease-in-out flex gap-3 items-center px-3 py-2 ${className} hover:bg-select-hover ${inputClassName}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
className=" overflow-hidden text-text-input text-sm-title leading-input outline-none flex-1 group-hover/input:bg-input-input-ele-hover"
|
||||||
|
type={internalType}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (type === "number") {
|
||||||
|
const v = e.currentTarget.valueAsNumber;
|
||||||
|
(onChange as NumberInputProps["onChange"])?.(v);
|
||||||
|
} else {
|
||||||
|
const v = e.currentTarget.value;
|
||||||
|
(onChange as TextInputProps["onChange"])?.(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{type == "password" && (
|
||||||
|
<div className=" cursor-pointer" onClick={() => setShow((pre) => !pre)}>
|
||||||
|
{show ? <PasswordVisible /> : <PasswordInvisible />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
157
app/components/List/index.tsx
Normal file
157
app/components/List/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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;
|
35
app/components/Loading/index.tsx
Normal file
35
app/components/Loading/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import BotIcon from "@/app/icons/bot.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
|
||||||
|
import { getCSSVar } from "@/app/utils";
|
||||||
|
|
||||||
|
export default function Loading({
|
||||||
|
noLogo,
|
||||||
|
useSkeleton = true,
|
||||||
|
}: {
|
||||||
|
noLogo?: boolean;
|
||||||
|
useSkeleton?: boolean;
|
||||||
|
}) {
|
||||||
|
let theme;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
theme = getCSSVar("--default-container-bg");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col justify-center items-center w-[100%]
|
||||||
|
h-[100%]
|
||||||
|
md:my-2.5
|
||||||
|
md:ml-1
|
||||||
|
md:mr-2.5
|
||||||
|
md:rounded-md
|
||||||
|
md:h-[calc(100%-1.25rem)]
|
||||||
|
`}
|
||||||
|
style={{ background: useSkeleton ? theme : "" }}
|
||||||
|
>
|
||||||
|
{!noLogo && <BotIcon />}
|
||||||
|
<LoadingIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
115
app/components/MenuLayout/index.tsx
Normal file
115
app/components/MenuLayout/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Path,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import useDrag from "@/app/hooks/useDrag";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||||
|
import { ComponentType, useRef, useState } from "react";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
|
||||||
|
export interface MenuWrapperInspectProps {
|
||||||
|
setExternalProps?: (v: Record<string, any>) => void;
|
||||||
|
setShowPanel?: (v: boolean) => void;
|
||||||
|
showPanel?: boolean;
|
||||||
|
[k: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MenuLayout<
|
||||||
|
ListComponentProps extends MenuWrapperInspectProps,
|
||||||
|
PanelComponentProps extends MenuWrapperInspectProps,
|
||||||
|
>(
|
||||||
|
ListComponent: ComponentType<ListComponentProps>,
|
||||||
|
PanelComponent: ComponentType<PanelComponentProps>,
|
||||||
|
) {
|
||||||
|
return function MenuHood(props: ListComponentProps & PanelComponentProps) {
|
||||||
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
|
const [externalProps, setExternalProps] = useState({});
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
|
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
|
||||||
|
// drag side bar
|
||||||
|
const { onDragStart } = useDrag({
|
||||||
|
customToggle: () => {
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
customDragMove: (nextWidth: number) => {
|
||||||
|
const { menuWidth } = updateGlobalCSSVars(nextWidth);
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--menu-width",
|
||||||
|
`${menuWidth}px`,
|
||||||
|
);
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = nextWidth;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
customLimit: (x: number) =>
|
||||||
|
Math.max(
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Math.min(MAX_SIDEBAR_WIDTH, startDragWidth.current + x),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-[100%] relative bg-center
|
||||||
|
max-md:h-[100%]
|
||||||
|
md:flex md:my-2.5
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col px-6
|
||||||
|
h-[100%]
|
||||||
|
max-md:w-[100%] max-md:px-4 max-md:pb-4 max-md:flex-1
|
||||||
|
md:relative md:basis-sidebar md:pb-6 md:rounded-md md:bg-menu
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ListComponent
|
||||||
|
{...props}
|
||||||
|
setShowPanel={setShowPanel}
|
||||||
|
setExternalProps={setExternalProps}
|
||||||
|
showPanel={showPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isMobileScreen && (
|
||||||
|
<div
|
||||||
|
className={`group/menu-dragger cursor-col-resize w-[0.25rem] flex items-center justify-center`}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
startDragWidth.current = config.sidebarWidth;
|
||||||
|
onDragStart(e as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-[2px] opacity-0 group-hover/menu-dragger:opacity-100 bg-menu-dragger h-[100%] rounded-[2px]">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
md:flex-1 md:h-[100%] md:w-page
|
||||||
|
max-md:transition-all max-md:duration-300 max-md:absolute max-md:top-0 max-md:max-h-[100vh] max-md:w-[100%] ${
|
||||||
|
showPanel ? "max-md:left-0" : "max-md:left-[101%]"
|
||||||
|
} max-md:z-10
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PanelComponent
|
||||||
|
{...props}
|
||||||
|
{...externalProps}
|
||||||
|
setShowPanel={setShowPanel}
|
||||||
|
setExternalProps={setExternalProps}
|
||||||
|
showPanel={showPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
359
app/components/Modal/index.tsx
Normal file
359
app/components/Modal/index.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
let div: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const div: HTMLDivElement = document.createElement("div");
|
||||||
|
div.id = "confirm-root";
|
||||||
|
div.style.height = "0px";
|
||||||
|
document.body.appendChild(div);
|
||||||
|
}, []);
|
||||||
|
const root = createRoot(div);
|
||||||
|
const closeModal = () => {
|
||||||
|
root.unmount();
|
||||||
|
};
|
||||||
|
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="fixed inset-0 bg-modal-mask 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 items-center justify-start flex-1 gap-3 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`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
366
app/components/Popover/index.tsx
Normal file
366
app/components/Popover/index.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let popoverRoot: HTMLDivElement;
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (popoverRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mergedShow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${className}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!mergedShow) {
|
||||||
|
handleOpen(e);
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{mergedShow && (
|
||||||
|
<>
|
||||||
|
{!noArrow && (
|
||||||
|
<div className={`${arrowClassName}`}>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className={`${popoverCommonClass} ${popoverClassName} cursor-pointer overflow-auto`}
|
||||||
|
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||||
|
ref={popoverRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className=" fixed w-[100vw] h-[100vh] right-0 bottom-0"
|
||||||
|
style={{ zIndex: baseZIndex }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useGlobalRoot) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative ${className}`}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(closeTimer.current);
|
||||||
|
onShow?.(true);
|
||||||
|
setShow(true);
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
window.document.documentElement.style.overflow = "hidden";
|
||||||
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (delayClose) {
|
||||||
|
closeTimer.current = window.setTimeout(() => {
|
||||||
|
onShow?.(false);
|
||||||
|
setShow(false);
|
||||||
|
}, delayClose);
|
||||||
|
} else {
|
||||||
|
onShow?.(false);
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
window.document.documentElement.style.overflow = "auto";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{mergedShow && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
noArrow ? "opacity-0" : ""
|
||||||
|
} bg-inherit ${arrowClassName}`}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
{createPortal(
|
||||||
|
<div
|
||||||
|
className={` whitespace-nowrap ${popoverCommonClass} ${popoverClassName} cursor-pointer`}
|
||||||
|
style={{ zIndex: baseZIndex + 1, ...placementStyle }}
|
||||||
|
ref={popoverRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>,
|
||||||
|
popoverRoot,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group/popover relative ${className}`}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
getRelativePosition(e.currentTarget, "");
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
hidden group-hover/popover:block
|
||||||
|
${noArrow ? "opacity-0" : ""}
|
||||||
|
bg-inherit
|
||||||
|
${arrowClassName}
|
||||||
|
`}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
<ArrowIcon sibling={popoverRef} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
hidden group-hover/popover:block whitespace-nowrap
|
||||||
|
${popoverCommonClass}
|
||||||
|
${placementClassName}
|
||||||
|
${popoverClassName}
|
||||||
|
`}
|
||||||
|
ref={popoverRef}
|
||||||
|
style={{ zIndex: baseZIndex + 1 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
71
app/components/Screen/index.tsx
Normal file
71
app/components/Screen/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useMemo, ReactNode } from "react";
|
||||||
|
import { Path, SIDEBAR_ID, SlotID } from "@/app/constant";
|
||||||
|
import { getLang } from "@/app/locales";
|
||||||
|
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
|
||||||
|
import useListenWinResize from "@/app/hooks/useListenWinResize";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useDeviceInfo } from "@/app/hooks/useDeviceInfo";
|
||||||
|
|
||||||
|
interface ScreenProps {
|
||||||
|
children: ReactNode;
|
||||||
|
noAuth: ReactNode;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Screen(props: ScreenProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAuth = pathname === Path.Auth;
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const { deviceType, systemInfo } = useDeviceInfo();
|
||||||
|
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:
|
||||||
|
systemInfo === "iOS" && deviceType === "mobile"
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
24
app/components/Search/index.module.scss
Normal file
24
app/components/Search/index.module.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
max-width: 460px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 16px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--Light-Text-Black, #18182A);
|
||||||
|
background: var(--light-opacity-white-70, rgba(255, 255, 255, 0.70));
|
||||||
|
box-shadow: 0px 8px 40px 0px rgba(60, 68, 255, 0.12);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
flex: 0 0;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
height: 18px;
|
||||||
|
flex: 1 1;
|
||||||
|
}
|
||||||
|
}
|
30
app/components/Search/index.tsx
Normal file
30
app/components/Search/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import styles from "./index.module.scss";
|
||||||
|
import SearchIcon from "@/app/icons/search.svg";
|
||||||
|
|
||||||
|
export interface SearchProps {
|
||||||
|
value?: string;
|
||||||
|
onSearch?: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Search = (props: SearchProps) => {
|
||||||
|
const { placeholder = "", value, onSearch } = props;
|
||||||
|
return (
|
||||||
|
<div className={styles["search"]}>
|
||||||
|
<div className={styles["icon"]}>
|
||||||
|
<SearchIcon />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={styles["input"]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch?.(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Search;
|
118
app/components/Select/index.tsx
Normal file
118
app/components/Select/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import SelectIcon from "@/app/icons/downArrowIcon.svg";
|
||||||
|
import Popover from "@/app/components/Popover";
|
||||||
|
import React, { useContext, useMemo, useRef } from "react";
|
||||||
|
import useRelativePosition, {
|
||||||
|
Orientation,
|
||||||
|
} from "@/app/hooks/useRelativePosition";
|
||||||
|
import List from "@/app/components/List";
|
||||||
|
|
||||||
|
import Selected from "@/app/icons/selectedIcon.svg";
|
||||||
|
|
||||||
|
export type Option<Value> = {
|
||||||
|
value: Value;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SearchProps<Value> {
|
||||||
|
value?: string;
|
||||||
|
onSelect?: (v: Value) => void;
|
||||||
|
options?: Option<Value>[];
|
||||||
|
inMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = <Value extends number | string>(props: SearchProps<Value>) => {
|
||||||
|
const { value, onSelect, options = [], inMobile } = props;
|
||||||
|
|
||||||
|
const { isMobileScreen, selectClassName } = useContext(List.ListContext);
|
||||||
|
|
||||||
|
const optionsRef = useRef<Option<Value>[]>([]);
|
||||||
|
optionsRef.current = options;
|
||||||
|
const selectedOption = useMemo(
|
||||||
|
() => optionsRef.current.find((o) => o.value === value),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { position, getRelativePosition } = useRelativePosition({
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let headerH = 100;
|
||||||
|
let baseH = position?.poi.distanceToBottomBoundary || 0;
|
||||||
|
if (isMobileScreen) {
|
||||||
|
headerH = 60;
|
||||||
|
}
|
||||||
|
if (position?.poi.relativePosition[1] === Orientation.bottom) {
|
||||||
|
baseH = position?.poi.distanceToTopBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxHeight = `${baseH - headerH}px`;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={` flex flex-col gap-1 overflow-y-auto overflow-x-hidden`}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
{options?.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.value}
|
||||||
|
className={`
|
||||||
|
flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect?.(o.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 flex-1 follow-parent-svg text-text-select-option">
|
||||||
|
{!!o.icon && <div className="flex items-center">{o.icon}</div>}
|
||||||
|
<div className={`flex-1 text-text-select-option`}>{o.label}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
selectedOption?.value === o.value ? "opacity-100" : "opacity-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Selected />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
trigger="click"
|
||||||
|
noArrow
|
||||||
|
placement={
|
||||||
|
position?.poi.relativePosition[1] !== Orientation.bottom ? "rb" : "rt"
|
||||||
|
}
|
||||||
|
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-select-popover-panel"
|
||||||
|
onShow={(e) => {
|
||||||
|
getRelativePosition(contentRef.current!, "");
|
||||||
|
}}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 py-2 px-3 bg-select rounded-action-btn font-common text-sm-title cursor-pointer hover:bg-select-hover transition duration-300 ease-in-out`}
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 flex-1 follow-parent-svg text-text-select`}
|
||||||
|
>
|
||||||
|
{!!selectedOption?.icon && (
|
||||||
|
<div className={``}>{selectedOption?.icon}</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex-1`}>{selectedOption?.label}</div>
|
||||||
|
</div>
|
||||||
|
<div className={``}>
|
||||||
|
<SelectIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Select;
|
99
app/components/SlideRange/index.tsx
Normal file
99
app/components/SlideRange/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useContext, useEffect, useRef } from "react";
|
||||||
|
import { ListContext } from "@/app/components/List";
|
||||||
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
|
|
||||||
|
interface SlideRangeProps {
|
||||||
|
className?: string;
|
||||||
|
description?: string;
|
||||||
|
range?: {
|
||||||
|
start?: number;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
onSlide?: (v: number) => void;
|
||||||
|
value?: number;
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = 15;
|
||||||
|
|
||||||
|
export default function SlideRange(props: SlideRangeProps) {
|
||||||
|
const {
|
||||||
|
className = "",
|
||||||
|
description = "",
|
||||||
|
range = {},
|
||||||
|
value,
|
||||||
|
onSlide,
|
||||||
|
step,
|
||||||
|
} = props;
|
||||||
|
const { start = 0, stroke = 1 } = range;
|
||||||
|
|
||||||
|
const { rangeClassName, update } = useContext(ListContext);
|
||||||
|
|
||||||
|
const slideRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useResizeObserver({
|
||||||
|
ref: slideRef,
|
||||||
|
onResize: () => {
|
||||||
|
setProperty(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformToWidth = (x: number = start) => {
|
||||||
|
const abs = x - start;
|
||||||
|
const maxWidth = (slideRef.current?.clientWidth || 1) - margin * 2;
|
||||||
|
const result = (abs / stroke) * maxWidth;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProperty = (value?: number) => {
|
||||||
|
const initWidth = transformToWidth(value);
|
||||||
|
slideRef.current?.style.setProperty(
|
||||||
|
"--slide-value-size",
|
||||||
|
`${initWidth + margin}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
update?.({ type: "range" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center items-end gap-1 w-[100%] ${className} ${rangeClassName}`}
|
||||||
|
>
|
||||||
|
{!!description && (
|
||||||
|
<div className=" text-common text-sm ">{description}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex my-1.5 relative w-[100%] h-1.5 bg-slider rounded-slide cursor-pointer"
|
||||||
|
ref={slideRef}
|
||||||
|
>
|
||||||
|
<div className="cursor-pointer absolute marker:top-0 h-[100%] w-[var(--slide-value-size)] bg-slider-slided-travel rounded-slide">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer absolute z-1 w-[30px] top-[50%] translate-y-[-50%] left-[var(--slide-value-size)] translate-x-[-50%] h-slide-btn leading-slide-btn text-sm-mobile text-center rounded-slide border border-slider-block bg-slider-block hover:bg-slider-block-hover text-text-slider-block"
|
||||||
|
// onPointerDown={onPointerDown}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="w-[100%] h-[100%] opacity-0 cursor-pointer"
|
||||||
|
value={value}
|
||||||
|
min={start}
|
||||||
|
max={start + stroke}
|
||||||
|
step={step}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProperty(e.target.valueAsNumber);
|
||||||
|
onSlide?.(e.target.valueAsNumber);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginLeft: margin,
|
||||||
|
marginRight: margin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
app/components/Switch/index.tsx
Normal file
33
app/components/Switch/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as RadixSwitch from "@radix-ui/react-switch";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import List from "../List";
|
||||||
|
|
||||||
|
interface SwitchProps {
|
||||||
|
value: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Switch(props: SwitchProps) {
|
||||||
|
const { value, onChange } = props;
|
||||||
|
|
||||||
|
const { switchClassName = "" } = useContext(List.ListContext);
|
||||||
|
return (
|
||||||
|
<RadixSwitch.Root
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
className={`
|
||||||
|
cursor-pointer flex w-switch h-switch p-0.5 box-content rounded-md transition-colors duration-300 ease-in-out
|
||||||
|
${switchClassName}
|
||||||
|
${
|
||||||
|
value
|
||||||
|
? "bg-switch-checked justify-end"
|
||||||
|
: "bg-switch-unchecked justify-start"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<RadixSwitch.Thumb
|
||||||
|
className={` bg-switch-btn block w-4 h-4 drop-shadow-sm rounded-md`}
|
||||||
|
/>
|
||||||
|
</RadixSwitch.Root>
|
||||||
|
);
|
||||||
|
}
|
27
app/components/ThumbnailImg/index.tsx
Normal file
27
app/components/ThumbnailImg/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
|
||||||
|
|
||||||
|
export interface ThumbnailProps {
|
||||||
|
image: string;
|
||||||
|
deleteImage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Thumbnail(props: ThumbnailProps) {
|
||||||
|
const { image, deleteImage } = props;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={` h-thumbnail w-thumbnail cursor-default border border-thumbnail rounded-action-btn flex-0 bg-cover bg-center`}
|
||||||
|
style={{ backgroundImage: `url("${image}")` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer flex items-center justify-center float-right`}
|
||||||
|
onClick={deleteImage}
|
||||||
|
>
|
||||||
|
<ImgDeleteIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -6,6 +6,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
}
|
}
|
||||||
@@ -33,4 +35,18 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
input[type="number"],
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: var(--border-in-light);
|
||||||
|
min-height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
padding: 0 10px;
|
||||||
|
max-width: 50%;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
|
"use client";
|
||||||
import styles from "./auth.module.scss";
|
import styles from "./auth.module.scss";
|
||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useRouter } from "next/navigation";
|
||||||
import { Path } from "../constant";
|
import { Path } from "../constant";
|
||||||
import { useAccessStore } from "../store";
|
import { useAccessStore } from "../store";
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
@@ -11,11 +12,11 @@ import { useEffect } from "react";
|
|||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const navigate = useNavigate();
|
const router = useRouter();
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
const goHome = () => navigate(Path.Home);
|
const goHome = () => router.push(Path.Home);
|
||||||
const goChat = () => navigate(Path.Chat);
|
const goChat = () => router.push(Path.Chat);
|
||||||
const resetAccessCode = () => {
|
const resetAccessCode = () => {
|
||||||
accessStore.update((access) => {
|
accessStore.update((access) => {
|
||||||
access.openaiApiKey = "";
|
access.openaiApiKey = "";
|
||||||
|
@@ -12,13 +12,14 @@ import {
|
|||||||
import { useChatStore } from "../store";
|
import { useChatStore } from "../store";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
// import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Path } from "../constant";
|
import { Path } from "../constant";
|
||||||
import { MaskAvatar } from "./mask";
|
import { MaskAvatar } from "./mask";
|
||||||
import { Mask } from "../store/mask";
|
import { Mask } from "../store/mask";
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { useMobileScreen } from "../utils";
|
import { useMobileScreen } from "../utils";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
export function ChatItem(props: {
|
export function ChatItem(props: {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -41,14 +42,14 @@ export function ChatItem(props: {
|
|||||||
}
|
}
|
||||||
}, [props.selected]);
|
}, [props.selected]);
|
||||||
|
|
||||||
const { pathname: currentPath } = useLocation();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<Draggable draggableId={`${props.id}`} index={props.index}>
|
<Draggable draggableId={`${props.id}`} index={props.index}>
|
||||||
{(provided) => (
|
{(provided) => (
|
||||||
<div
|
<div
|
||||||
className={`${styles["chat-item"]} ${
|
className={`${styles["chat-item"]} ${
|
||||||
props.selected &&
|
props.selected &&
|
||||||
(currentPath === Path.Chat || currentPath === Path.Home) &&
|
(pathname === Path.Chat || pathname === Path.Home) &&
|
||||||
styles["chat-item-selected"]
|
styles["chat-item-selected"]
|
||||||
}`}
|
}`}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
@@ -112,8 +113,8 @@ export function ChatList(props: { narrow?: boolean }) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const navigate = useNavigate();
|
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const onDragEnd: OnDragEndResponder = (result) => {
|
const onDragEnd: OnDragEndResponder = (result) => {
|
||||||
const { destination, source } = result;
|
const { destination, source } = result;
|
||||||
@@ -150,7 +151,8 @@ export function ChatList(props: { narrow?: boolean }) {
|
|||||||
index={i}
|
index={i}
|
||||||
selected={i === selectedIndex}
|
selected={i === selectedIndex}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(Path.Chat);
|
// navigate(Path.Chat);
|
||||||
|
router.push(Path.Chat);
|
||||||
selectSession(i);
|
selectSession(i);
|
||||||
}}
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
|
@@ -97,6 +97,7 @@ import { ExportMessageModal } from "./exporter";
|
|||||||
import { getClientConfig } from "../config/client";
|
import { getClientConfig } from "../config/client";
|
||||||
import { useAllModels } from "../utils/hooks";
|
import { useAllModels } from "../utils/hooks";
|
||||||
import { MultimodalContent } from "../client/api";
|
import { MultimodalContent } from "../client/api";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||||
loading: () => <LoadingIcon />,
|
loading: () => <LoadingIcon />,
|
||||||
@@ -428,7 +429,7 @@ export function ChatActions(props: {
|
|||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const navigate = useNavigate();
|
const router = useRouter();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
// switch themes
|
// switch themes
|
||||||
@@ -543,7 +544,8 @@ export function ChatActions(props: {
|
|||||||
|
|
||||||
<ChatAction
|
<ChatAction
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(Path.Masks);
|
// navigate(Path.Masks);
|
||||||
|
router.push(Path.Masks);
|
||||||
}}
|
}}
|
||||||
text={Locale.Chat.InputActions.Masks}
|
text={Locale.Chat.InputActions.Masks}
|
||||||
icon={<MaskIcon />}
|
icon={<MaskIcon />}
|
||||||
@@ -1088,7 +1090,6 @@ function _Chat() {
|
|||||||
if (payload.url) {
|
if (payload.url) {
|
||||||
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
accessStore.update((access) => (access.openaiUrl = payload.url!));
|
||||||
}
|
}
|
||||||
accessStore.update((access) => (access.useCustomConfig = true));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@@ -2,6 +2,9 @@
|
|||||||
&-body {
|
&-body {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-content {
|
.export-content {
|
||||||
|
@@ -177,13 +177,14 @@ export function Markdown(
|
|||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
parentRef?: RefObject<HTMLDivElement>;
|
parentRef?: RefObject<HTMLDivElement>;
|
||||||
defaultShow?: boolean;
|
defaultShow?: boolean;
|
||||||
|
className?: string;
|
||||||
} & React.DOMAttributes<HTMLDivElement>,
|
} & React.DOMAttributes<HTMLDivElement>,
|
||||||
) {
|
) {
|
||||||
const mdRef = useRef<HTMLDivElement>(null);
|
const mdRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="markdown-body"
|
className={`markdown-body ${props.className}`}
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
}}
|
}}
|
||||||
|
@@ -4,6 +4,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.mask-page-body {
|
.mask-page-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import { IconButton } from "./button";
|
import { IconButton } from "./button";
|
||||||
import { ErrorBoundary } from "./error";
|
|
||||||
|
|
||||||
import styles from "./mask.module.scss";
|
import styles from "./mask.module.scss";
|
||||||
|
|
||||||
@@ -56,6 +55,7 @@ import {
|
|||||||
OnDragEndResponder,
|
OnDragEndResponder,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
import { getMessageTextContent } from "../utils";
|
import { getMessageTextContent } from "../utils";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
|
||||||
// drag and drop helper function
|
// drag and drop helper function
|
||||||
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
function reorder<T>(list: T[], startIndex: number, endIndex: number): T[] {
|
||||||
@@ -398,7 +398,7 @@ export function ContextPrompts(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaskPage() {
|
export function MaskPage(props: { className?: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
@@ -466,8 +466,13 @@ export function MaskPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<>
|
||||||
<div className={styles["mask-page"]}>
|
<div
|
||||||
|
className={`
|
||||||
|
${styles["mask-page"]}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className="window-header">
|
<div className="window-header">
|
||||||
<div className="window-header-title">
|
<div className="window-header-title">
|
||||||
<div className="window-header-main-title">
|
<div className="window-header-main-title">
|
||||||
@@ -645,6 +650,6 @@ export function MaskPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ErrorBoundary>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
div:not(.no-dark) > svg {
|
||||||
|
filter: invert(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.mask-header {
|
.mask-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@@ -16,6 +16,7 @@ import { MaskAvatar } from "./mask";
|
|||||||
import { useCommand } from "../command";
|
import { useCommand } from "../command";
|
||||||
import { showConfirm } from "./ui-lib";
|
import { showConfirm } from "./ui-lib";
|
||||||
import { BUILTIN_MASK_STORE } from "../masks";
|
import { BUILTIN_MASK_STORE } from "../masks";
|
||||||
|
import useMobileScreen from "@/app/hooks/useMobileScreen";
|
||||||
|
|
||||||
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
function MaskItem(props: { mask: Mask; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -71,7 +72,7 @@ function useMaskGroup(masks: Mask[]) {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewChat() {
|
export function NewChat(props: { className?: string }) {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const maskStore = useMaskStore();
|
const maskStore = useMaskStore();
|
||||||
|
|
||||||
@@ -110,8 +111,15 @@ export function NewChat() {
|
|||||||
}
|
}
|
||||||
}, [groups]);
|
}, [groups]);
|
||||||
|
|
||||||
|
const isMobileScreen = useMobileScreen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["new-chat"]}>
|
<div
|
||||||
|
className={`
|
||||||
|
${styles["new-chat"]}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div className={styles["mask-header"]}>
|
<div className={styles["mask-header"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<LeftIcon />}
|
icon={<LeftIcon />}
|
||||||
|
@@ -27,9 +27,9 @@ import {
|
|||||||
} from "../constant";
|
} from "../constant";
|
||||||
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { isIOS, useMobileScreen } from "../utils";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { showConfirm, showToast } from "./ui-lib";
|
import { showConfirm, showToast } from "./ui-lib";
|
||||||
|
import { useDeviceInfo } from "../hooks/useDeviceInfo";
|
||||||
|
|
||||||
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
|
||||||
loading: () => null,
|
loading: () => null,
|
||||||
@@ -130,16 +130,11 @@ function useDragSideBar() {
|
|||||||
|
|
||||||
export function SideBar(props: { className?: string }) {
|
export function SideBar(props: { className?: string }) {
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
const { deviceType, systemInfo } = useDeviceInfo();
|
||||||
// drag side bar
|
// drag side bar
|
||||||
const { onDragStart, shouldNarrow } = useDragSideBar();
|
const { onDragStart, shouldNarrow } = useDragSideBar();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const isMobileScreen = useMobileScreen();
|
|
||||||
const isIOSMobile = useMemo(
|
|
||||||
() => isIOS() && isMobileScreen,
|
|
||||||
[isMobileScreen],
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey();
|
useHotKey();
|
||||||
|
|
||||||
@@ -150,7 +145,8 @@ export function SideBar(props: { className?: string }) {
|
|||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
// #3016 disable transition on ios mobile screen
|
// #3016 disable transition on ios mobile screen
|
||||||
transition: isMobileScreen && isIOSMobile ? "none" : undefined,
|
transition:
|
||||||
|
deviceType === "mobile" && systemInfo === "iOS" ? "none" : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
<div className={styles["sidebar-header"]} data-tauri-drag-region>
|
||||||
|
@@ -101,6 +101,7 @@ interface ModalProps {
|
|||||||
defaultMax?: boolean;
|
defaultMax?: boolean;
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
export function Modal(props: ModalProps) {
|
export function Modal(props: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,14 +123,14 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={`${styles["modal-container"]} ${
|
||||||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
isMax && styles["modal-container-max"]
|
||||||
}
|
} ${props.className ?? ""}`}
|
||||||
>
|
>
|
||||||
<div className={styles["modal-header"]}>
|
<div className={`${styles["modal-header"]} new-header follow-parent-svg`}>
|
||||||
<div className={styles["modal-title"]}>{props.title}</div>
|
<div className={`${styles["modal-title"]}`}>{props.title}</div>
|
||||||
|
|
||||||
<div className={styles["modal-header-actions"]}>
|
<div className={`${styles["modal-header-actions"]}`}>
|
||||||
<div
|
<div
|
||||||
className={styles["modal-header-action"]}
|
className={styles["modal-header-action"]}
|
||||||
onClick={() => setMax(!isMax)}
|
onClick={() => setMax(!isMax)}
|
||||||
@@ -147,11 +148,11 @@ export function Modal(props: ModalProps) {
|
|||||||
|
|
||||||
<div className={styles["modal-content"]}>{props.children}</div>
|
<div className={styles["modal-content"]}>{props.children}</div>
|
||||||
|
|
||||||
<div className={styles["modal-footer"]}>
|
<div className={`${styles["modal-footer"]} new-footer`}>
|
||||||
{props.footer}
|
{props.footer}
|
||||||
<div className={styles["modal-actions"]}>
|
<div className={styles["modal-actions"]}>
|
||||||
{props.actions?.map((action, i) => (
|
{props.actions?.map((action, i) => (
|
||||||
<div key={i} className={styles["modal-action"]}>
|
<div key={i} className={`${styles["modal-action"]} new-btn`}>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@@ -3,7 +3,12 @@ import { BuildConfig, getBuildConfig } from "./build";
|
|||||||
export function getClientConfig() {
|
export function getClientConfig() {
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
// client side
|
// client side
|
||||||
return JSON.parse(queryMeta("config")) as BuildConfig;
|
try {
|
||||||
|
const config = JSON.parse(queryMeta("config")) as BuildConfig;
|
||||||
|
return config;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof process !== "undefined") {
|
if (typeof process !== "undefined") {
|
||||||
|
@@ -51,22 +51,6 @@ const ACCESS_CODES = (function getAccessCodes(): Set<string> {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function getApiKey(keys?: string) {
|
|
||||||
const apiKeyEnvVar = keys ?? "";
|
|
||||||
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
|
||||||
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
|
||||||
const apiKey = apiKeys[randomIndex];
|
|
||||||
if (apiKey) {
|
|
||||||
console.log(
|
|
||||||
`[Server Config] using ${randomIndex + 1} of ${
|
|
||||||
apiKeys.length
|
|
||||||
} api key - ${apiKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideConfig = () => {
|
export const getServerSideConfig = () => {
|
||||||
if (typeof process === "undefined") {
|
if (typeof process === "undefined") {
|
||||||
throw Error(
|
throw Error(
|
||||||
@@ -90,34 +74,34 @@ export const getServerSideConfig = () => {
|
|||||||
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
const isGoogle = !!process.env.GOOGLE_API_KEY;
|
||||||
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
// const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
const apiKeyEnvVar = process.env.OPENAI_API_KEY ?? "";
|
||||||
// const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
const apiKeys = apiKeyEnvVar.split(",").map((v) => v.trim());
|
||||||
// const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
const randomIndex = Math.floor(Math.random() * apiKeys.length);
|
||||||
// const apiKey = apiKeys[randomIndex];
|
const apiKey = apiKeys[randomIndex];
|
||||||
// console.log(
|
console.log(
|
||||||
// `[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
`[Server Config] using ${randomIndex + 1} of ${apiKeys.length} api key`,
|
||||||
// );
|
);
|
||||||
|
|
||||||
const allowedWebDevEndpoints = (
|
const whiteWebDevEndpoints = (process.env.WHITE_WEBDEV_ENDPOINTS ?? "").split(
|
||||||
process.env.WHITE_WEBDEV_ENDPOINTS ?? ""
|
",",
|
||||||
).split(",");
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: process.env.BASE_URL,
|
baseUrl: process.env.BASE_URL,
|
||||||
apiKey: getApiKey(process.env.OPENAI_API_KEY),
|
apiKey,
|
||||||
openaiOrgId: process.env.OPENAI_ORG_ID,
|
openaiOrgId: process.env.OPENAI_ORG_ID,
|
||||||
|
|
||||||
isAzure,
|
isAzure,
|
||||||
azureUrl: process.env.AZURE_URL,
|
azureUrl: process.env.AZURE_URL,
|
||||||
azureApiKey: getApiKey(process.env.AZURE_API_KEY),
|
azureApiKey: process.env.AZURE_API_KEY,
|
||||||
azureApiVersion: process.env.AZURE_API_VERSION,
|
azureApiVersion: process.env.AZURE_API_VERSION,
|
||||||
|
|
||||||
isGoogle,
|
isGoogle,
|
||||||
googleApiKey: getApiKey(process.env.GOOGLE_API_KEY),
|
googleApiKey: process.env.GOOGLE_API_KEY,
|
||||||
googleUrl: process.env.GOOGLE_URL,
|
googleUrl: process.env.GOOGLE_URL,
|
||||||
|
|
||||||
isAnthropic,
|
isAnthropic,
|
||||||
anthropicApiKey: getApiKey(process.env.ANTHROPIC_API_KEY),
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
anthropicApiVersion: process.env.ANTHROPIC_API_VERSION,
|
||||||
anthropicUrl: process.env.ANTHROPIC_URL,
|
anthropicUrl: process.env.ANTHROPIC_URL,
|
||||||
|
|
||||||
@@ -136,6 +120,6 @@ export const getServerSideConfig = () => {
|
|||||||
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
disableFastLink: !!process.env.DISABLE_FAST_LINK,
|
||||||
customModels,
|
customModels,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
allowedWebDevEndpoints,
|
whiteWebDevEndpoints,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -49,11 +49,18 @@ export enum StoreKey {
|
|||||||
Sync = "sync",
|
Sync = "sync",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SIDEBAR_WIDTH = 300;
|
|
||||||
export const MAX_SIDEBAR_WIDTH = 500;
|
|
||||||
export const MIN_SIDEBAR_WIDTH = 230;
|
|
||||||
export const NARROW_SIDEBAR_WIDTH = 100;
|
export const NARROW_SIDEBAR_WIDTH = 100;
|
||||||
|
|
||||||
|
export const DEFAULT_SIDEBAR_WIDTH = 340;
|
||||||
|
export const MAX_SIDEBAR_WIDTH = 440;
|
||||||
|
export const MIN_SIDEBAR_WIDTH = 230;
|
||||||
|
|
||||||
|
export const WINDOW_WIDTH_SM = 480;
|
||||||
|
export const WINDOW_WIDTH_MD = 768;
|
||||||
|
export const WINDOW_WIDTH_LG = 1120;
|
||||||
|
export const WINDOW_WIDTH_XL = 1440;
|
||||||
|
export const WINDOW_WIDTH_2XL = 1980;
|
||||||
|
|
||||||
export const ACCESS_CODE_PREFIX = "nk-";
|
export const ACCESS_CODE_PREFIX = "nk-";
|
||||||
|
|
||||||
export const LAST_INPUT_KEY = "last-input";
|
export const LAST_INPUT_KEY = "last-input";
|
||||||
@@ -99,6 +106,7 @@ export const Azure = {
|
|||||||
export const Google = {
|
export const Google = {
|
||||||
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
ExampleEndpoint: "https://generativelanguage.googleapis.com/",
|
||||||
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
ChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||||
|
VisionChatPath: (modelName: string) => `v1beta/models/${modelName}:generateContent`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
export const DEFAULT_INPUT_TEMPLATE = `{{input}}`; // input / time / model / lang
|
||||||
@@ -127,8 +135,8 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
"gpt-4-turbo": "2023-12",
|
"gpt-4-turbo": "2023-12",
|
||||||
"gpt-4-turbo-2024-04-09": "2023-12",
|
"gpt-4-turbo-2024-04-09": "2023-12",
|
||||||
"gpt-4-turbo-preview": "2023-12",
|
"gpt-4-turbo-preview": "2023-12",
|
||||||
"gpt-4o": "2023-10",
|
"gpt-4-1106-preview": "2023-04",
|
||||||
"gpt-4o-2024-05-13": "2023-10",
|
"gpt-4-0125-preview": "2023-12",
|
||||||
"gpt-4-vision-preview": "2023-04",
|
"gpt-4-vision-preview": "2023-04",
|
||||||
// After improvements,
|
// After improvements,
|
||||||
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
// it's now easier to add "KnowledgeCutOffDate" instead of stupid hardcoding it, as was done previously.
|
||||||
@@ -138,18 +146,24 @@ export const KnowledgeCutOffDate: Record<string, string> = {
|
|||||||
|
|
||||||
const openaiModels = [
|
const openaiModels = [
|
||||||
"gpt-3.5-turbo",
|
"gpt-3.5-turbo",
|
||||||
|
"gpt-3.5-turbo-0301",
|
||||||
|
"gpt-3.5-turbo-0613",
|
||||||
"gpt-3.5-turbo-1106",
|
"gpt-3.5-turbo-1106",
|
||||||
"gpt-3.5-turbo-0125",
|
"gpt-3.5-turbo-0125",
|
||||||
|
"gpt-3.5-turbo-16k",
|
||||||
|
"gpt-3.5-turbo-16k-0613",
|
||||||
"gpt-4",
|
"gpt-4",
|
||||||
|
"gpt-4-0314",
|
||||||
"gpt-4-0613",
|
"gpt-4-0613",
|
||||||
|
"gpt-4-1106-preview",
|
||||||
|
"gpt-4-0125-preview",
|
||||||
"gpt-4-32k",
|
"gpt-4-32k",
|
||||||
|
"gpt-4-32k-0314",
|
||||||
"gpt-4-32k-0613",
|
"gpt-4-32k-0613",
|
||||||
"gpt-4-turbo",
|
"gpt-4-turbo",
|
||||||
"gpt-4-turbo-preview",
|
"gpt-4-turbo-preview",
|
||||||
"gpt-4o",
|
|
||||||
"gpt-4o-2024-05-13",
|
|
||||||
"gpt-4-vision-preview",
|
"gpt-4-vision-preview",
|
||||||
"gpt-4-turbo-2024-04-09"
|
"gpt-4-turbo-2024-04-09",
|
||||||
];
|
];
|
||||||
|
|
||||||
const googleModels = [
|
const googleModels = [
|
||||||
@@ -201,7 +215,7 @@ export const CHAT_PAGE_SIZE = 15;
|
|||||||
export const MAX_RENDER_MSG_COUNT = 45;
|
export const MAX_RENDER_MSG_COUNT = 45;
|
||||||
|
|
||||||
// some famous webdav endpoints
|
// some famous webdav endpoints
|
||||||
export const internalAllowedWebDavEndpoints = [
|
export const internalWhiteWebDavEndpoints = [
|
||||||
"https://dav.jianguoyun.com/dav/",
|
"https://dav.jianguoyun.com/dav/",
|
||||||
"https://dav.dropdav.com/",
|
"https://dav.dropdav.com/",
|
||||||
"https://dav.box.com/dav",
|
"https://dav.box.com/dav",
|
||||||
@@ -211,3 +225,5 @@ export const internalAllowedWebDavEndpoints = [
|
|||||||
"https://webdav.yandex.com",
|
"https://webdav.yandex.com",
|
||||||
"https://app.koofr.net/dav/Koofr",
|
"https://app.koofr.net/dav/Koofr",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const SIDEBAR_ID = "sidebar";
|
||||||
|
301
app/containers/Chat/ChatPanel.tsx
Normal file
301
app/containers/Chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
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>;
|
||||||
|
}
|
276
app/containers/Chat/components/ChatActions.tsx
Normal file
276
app/containers/Chat/components/ChatActions.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
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";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
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 router = useRouter();
|
||||||
|
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: () => {
|
||||||
|
router.push(Path.Masks);
|
||||||
|
},
|
||||||
|
text: Locale.Chat.InputActions.Masks,
|
||||||
|
isShow: true,
|
||||||
|
icon: <MaskIcon />,
|
||||||
|
placement: "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: () => {
|
||||||
|
chatStore.updateCurrentSession((session) => {
|
||||||
|
if (session.clearContextIndex === session.messages.length) {
|
||||||
|
session.clearContextIndex = undefined;
|
||||||
|
} else {
|
||||||
|
session.clearContextIndex = session.messages.length;
|
||||||
|
session.memoryPrompt = ""; // will clear memory
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
text: Locale.Chat.InputActions.Clear,
|
||||||
|
isShow: true,
|
||||||
|
icon: <BreakIcon />,
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onClick: props.showChatSetting,
|
||||||
|
text: Locale.Chat.InputActions.Settings,
|
||||||
|
isShow: true,
|
||||||
|
icon: <SettingsIcon />,
|
||||||
|
placement: "right",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (props.isMobileScreen) {
|
||||||
|
const content = (
|
||||||
|
<div className="w-[100%]">
|
||||||
|
{actions
|
||||||
|
.filter((v) => v.isShow && v.icon)
|
||||||
|
.map((act) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={act.text}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer follow-parent-svg default-icon-color`}
|
||||||
|
onClick={act.onClick}
|
||||||
|
>
|
||||||
|
{act.icon}
|
||||||
|
<div className="flex-1 font-common text-actions-popover-menu-item">
|
||||||
|
{act.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={content}
|
||||||
|
trigger="click"
|
||||||
|
placement="rt"
|
||||||
|
noArrow
|
||||||
|
popoverClassName="border border-chat-actions-popover-mobile rounded-md shadow-chat-actions-popover-mobile w-actions-popover bg-chat-actions-popover-panel-mobile "
|
||||||
|
className="cursor-pointer follow-parent-svg default-icon-color"
|
||||||
|
>
|
||||||
|
<AddCircleIcon />
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const popoverClassName = `bg-chat-actions-btn-popover px-3 py-2.5 text-text-chat-actions-btn-popover text-sm-title rounded-md`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-2 item-center ${props.className}`}>
|
||||||
|
{actions
|
||||||
|
.filter((v) => v.placement === "left" && v.isShow)
|
||||||
|
.map((act, ind) => {
|
||||||
|
if (act.render) {
|
||||||
|
return (
|
||||||
|
<div className={`${act.className ?? ""}`} key={act.text}>
|
||||||
|
{act.render(act.text)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
key={act.text}
|
||||||
|
content={act.text}
|
||||||
|
popoverClassName={`${popoverClassName}`}
|
||||||
|
placement={ind ? "t" : "lt"}
|
||||||
|
className={`${act.className ?? ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
cursor-pointer h-[32px] w-[32px] flex items-center justify-center transition duration-300 ease-in-out
|
||||||
|
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
|
||||||
|
follow-parent-svg default-icon-color
|
||||||
|
`}
|
||||||
|
onClick={act.onClick}
|
||||||
|
>
|
||||||
|
{act.icon}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
{actions
|
||||||
|
.filter((v) => v.placement === "right" && v.isShow)
|
||||||
|
.map((act, ind, arr) => {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
key={act.text}
|
||||||
|
content={act.text}
|
||||||
|
popoverClassName={`${popoverClassName}`}
|
||||||
|
placement={ind === arr.length - 1 ? "rt" : "t"}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
cursor-pointer h-[32px] w-[32px] flex items-center transition duration-300 ease-in-out justify-center
|
||||||
|
hover:bg-chat-actions-btn-hovered hover:rounded-action-btn
|
||||||
|
follow-parent-svg default-icon-color
|
||||||
|
`}
|
||||||
|
onClick={act.onClick}
|
||||||
|
>
|
||||||
|
{act.icon}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
91
app/containers/Chat/components/ChatHeader.tsx
Normal file
91
app/containers/Chat/components/ChatHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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 router = useRouter();
|
||||||
|
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={() => router.push(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>
|
||||||
|
);
|
||||||
|
}
|
323
app/containers/Chat/components/ChatInputPanel.tsx
Normal file
323
app/containers/Chat/components/ChatInputPanel.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
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 { useRouter } from "next/navigation";
|
||||||
|
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 router = useRouter();
|
||||||
|
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: () => router.push(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 gap-3 text-sm">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
248
app/containers/Chat/components/ChatMessagePanel.tsx
Normal file
248
app/containers/Chat/components/ChatMessagePanel.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { Fragment, useEffect, 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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!MarkdownLoadedCallback) {
|
||||||
|
MarkdownLoadedCallback = () => {
|
||||||
|
window.setTimeout(scrollDomToBottom, 100);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [scrollDomToBottom]);
|
||||||
|
|
||||||
|
const messages = useMemo(() => {
|
||||||
|
const endRenderIndex = Math.min(
|
||||||
|
msgRenderIndex + 3 * CHAT_PAGE_SIZE,
|
||||||
|
renderMessages.length,
|
||||||
|
);
|
||||||
|
return renderMessages.slice(msgRenderIndex, endRenderIndex);
|
||||||
|
}, [msgRenderIndex, renderMessages]);
|
||||||
|
|
||||||
|
const onChatBodyScroll = (e: HTMLElement) => {
|
||||||
|
const bottomHeight = e.scrollTop + e.clientHeight;
|
||||||
|
const edgeThreshold = e.clientHeight;
|
||||||
|
|
||||||
|
const isTouchTopEdge = e.scrollTop <= edgeThreshold;
|
||||||
|
const isTouchBottomEdge = bottomHeight >= e.scrollHeight - edgeThreshold;
|
||||||
|
const isHitBottom =
|
||||||
|
bottomHeight >= e.scrollHeight - (isMobileScreen ? 4 : 10);
|
||||||
|
|
||||||
|
const prevPageMsgIndex = msgRenderIndex - CHAT_PAGE_SIZE;
|
||||||
|
const nextPageMsgIndex = msgRenderIndex + CHAT_PAGE_SIZE;
|
||||||
|
|
||||||
|
if (isTouchTopEdge && !isTouchBottomEdge) {
|
||||||
|
setMsgRenderIndex?.(prevPageMsgIndex);
|
||||||
|
} else if (isTouchBottomEdge) {
|
||||||
|
setMsgRenderIndex?.(nextPageMsgIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHitBottom?.(isHitBottom);
|
||||||
|
setAutoScroll?.(isHitBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRightClick = (e: any, message: ChatMessage) => {
|
||||||
|
// copy to clipboard
|
||||||
|
if (selectOrCopy(e.currentTarget, getMessageTextContent(message))) {
|
||||||
|
if (userInput.length === 0) {
|
||||||
|
setUserInput?.(getMessageTextContent(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pt-[80px] relative flex-1 overscroll-y-none overflow-y-auto overflow-x-hidden px-3 pb-6 md:bg-chat-panel-message bg-chat-panel-message-mobile`}
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||||
|
onMouseDown={() => inputRef.current?.blur()}
|
||||||
|
onTouchStart={() => {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
setAutoScroll?.(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.map((message, i) => {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isContext = i < context.length;
|
||||||
|
|
||||||
|
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
||||||
|
|
||||||
|
const actionsBarPosition =
|
||||||
|
position?.id === message.id &&
|
||||||
|
position?.poi.overlapPositions[Orientation.bottom]
|
||||||
|
? "bottom-[calc(100%-0.25rem)]"
|
||||||
|
: "top-[calc(100%-0.25rem)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={message.id}>
|
||||||
|
<div
|
||||||
|
className={`flex mt-6 gap-2 ${isUser ? "flex-row-reverse" : ""}`}
|
||||||
|
>
|
||||||
|
<div className={`relative flex-0`}>
|
||||||
|
{isUser ? (
|
||||||
|
<Avatar avatar={config.avatar} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{["system"].includes(message.role) ? (
|
||||||
|
<Avatar avatar="2699-fe0f" />
|
||||||
|
) : (
|
||||||
|
<MaskAvatar
|
||||||
|
avatar={session.mask.avatar}
|
||||||
|
model={message.model || session.mask.modelConfig.model}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`group relative flex ${
|
||||||
|
isUser ? "flex-row-reverse" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={` pointer-events-none text-text-chat-message-date text-right font-common whitespace-nowrap transition-all duration-500 text-sm absolute z-1 ${
|
||||||
|
isUser ? "right-0" : "left-0"
|
||||||
|
} bottom-[100%] hidden group-hover:block`}
|
||||||
|
>
|
||||||
|
{isContext
|
||||||
|
? Locale.Chat.IsContext
|
||||||
|
: message.date.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-300 select-text break-words font-common text-sm-title ${
|
||||||
|
isUser
|
||||||
|
? "rounded-user-message bg-chat-panel-message-user"
|
||||||
|
: "rounded-bot-message bg-chat-panel-message-bot"
|
||||||
|
} box-border peer py-2 px-3`}
|
||||||
|
onPointerMoveCapture={(e) =>
|
||||||
|
getRelativePosition(e.currentTarget, message.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Markdown
|
||||||
|
content={getMessageTextContent(message)}
|
||||||
|
loading={
|
||||||
|
(message.preview || message.streaming) &&
|
||||||
|
message.content.length === 0 &&
|
||||||
|
!isUser
|
||||||
|
}
|
||||||
|
onContextMenu={(e) => onRightClick(e, message)}
|
||||||
|
onDoubleClickCapture={() => {
|
||||||
|
if (!isMobileScreen) return;
|
||||||
|
setUserInput?.(getMessageTextContent(message));
|
||||||
|
}}
|
||||||
|
fontSize={fontSize}
|
||||||
|
parentRef={scrollRef}
|
||||||
|
defaultShow={i >= messages.length - 6}
|
||||||
|
className={`leading-6 max-w-message-width ${
|
||||||
|
isUser
|
||||||
|
? " text-text-chat-message-markdown-user"
|
||||||
|
: "text-text-chat-message-markdown-bot"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Imgs message={message} />
|
||||||
|
</div>
|
||||||
|
<MessageActions
|
||||||
|
className={actionsBarPosition}
|
||||||
|
message={message}
|
||||||
|
inputRef={inputRef}
|
||||||
|
isUser={isUser}
|
||||||
|
isContext={isContext}
|
||||||
|
setIsLoading={setIsLoading}
|
||||||
|
setShowPromptModal={setShowPromptModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
46
app/containers/Chat/components/ClearContextDivider.tsx
Normal file
46
app/containers/Chat/components/ClearContextDivider.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useAppConfig } from "@/app/store";
|
||||||
|
|
||||||
|
export default function ClearContextDivider() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const { isMobileScreen } = useAppConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mt-6 mb-8 flex items-center justify-center gap-2.5 max-md:cursor-pointer`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isMobileScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatStore.updateCurrentSession(
|
||||||
|
(session) => (session.clearContextIndex = undefined),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
|
||||||
|
<div className="flex items-center justify-between gap-1 text-sm">
|
||||||
|
<div className={`text-text-chat-panel-message-clear`}>
|
||||||
|
{Locale.Context.Clear}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
text-text-chat-panel-message-clear-revert underline font-common
|
||||||
|
md:cursor-pointer
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMobileScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatStore.updateCurrentSession(
|
||||||
|
(session) => (session.clearContextIndex = undefined),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Locale.Context.Revert}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-chat-panel-message-clear-divider h-[1px] w-10"> </div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
75
app/containers/Chat/components/EditMessageModal.tsx
Normal file
75
app/containers/Chat/components/EditMessageModal.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import { List, ListItem, Modal } from "@/app/components/ui-lib";
|
||||||
|
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import { ContextPrompts } from "@/app/components/mask";
|
||||||
|
|
||||||
|
import CancelIcon from "@/app/icons/cancel.svg";
|
||||||
|
import ConfirmIcon from "@/app/icons/confirm.svg";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
|
||||||
|
export function EditMessageModal(props: { onClose: () => void }) {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const [messages, setMessages] = useState(session.messages.slice());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Chat.EditMessage.Title}
|
||||||
|
onClose={props.onClose}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
text={Locale.UI.Cancel}
|
||||||
|
icon={<CancelIcon />}
|
||||||
|
key="cancel"
|
||||||
|
onClick={() => {
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<IconButton
|
||||||
|
type="primary"
|
||||||
|
text={Locale.UI.Confirm}
|
||||||
|
icon={<ConfirmIcon />}
|
||||||
|
key="ok"
|
||||||
|
onClick={() => {
|
||||||
|
chatStore.updateCurrentSession(
|
||||||
|
(session) => (session.messages = messages),
|
||||||
|
);
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
// className="!bg-modal-mask"
|
||||||
|
>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Chat.EditMessage.Topic.Title}
|
||||||
|
subTitle={Locale.Chat.EditMessage.Topic.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={session.topic}
|
||||||
|
onChange={(e) =>
|
||||||
|
chatStore.updateCurrentSession(
|
||||||
|
(session) => (session.topic = e || ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className=" text-center"
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
<ContextPrompts
|
||||||
|
context={messages}
|
||||||
|
updateContext={(updater) => {
|
||||||
|
const newMessages = messages.slice();
|
||||||
|
updater(newMessages);
|
||||||
|
setMessages(newMessages);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
295
app/containers/Chat/components/MessageActions.tsx
Normal file
295
app/containers/Chat/components/MessageActions.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import Locale from "@/app/locales";
|
||||||
|
|
||||||
|
import StopIcon from "@/app/icons/pause.svg";
|
||||||
|
import DeleteRequestIcon from "@/app/icons/deleteRequestIcon.svg";
|
||||||
|
import RetryRequestIcon from "@/app/icons/retryRequestIcon.svg";
|
||||||
|
import CopyRequestIcon from "@/app/icons/copyRequestIcon.svg";
|
||||||
|
import EditRequestIcon from "@/app/icons/editRequestIcon.svg";
|
||||||
|
import PinRequestIcon from "@/app/icons/pinRequestIcon.svg";
|
||||||
|
import { showPrompt, showToast } from "@/app/components/ui-lib";
|
||||||
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
getMessageImages,
|
||||||
|
getMessageTextContent,
|
||||||
|
} from "@/app/utils";
|
||||||
|
import { MultimodalContent } from "@/app/client/api";
|
||||||
|
import { ChatMessage, useChatStore } from "@/app/store/chat";
|
||||||
|
import ActionsBar from "@/app/components/ActionsBar";
|
||||||
|
import { ChatControllerPool } from "@/app/client/controller";
|
||||||
|
import { RefObject } from "react";
|
||||||
|
|
||||||
|
export type RenderMessage = ChatMessage & { preview?: boolean };
|
||||||
|
|
||||||
|
export interface MessageActionsProps {
|
||||||
|
message: RenderMessage;
|
||||||
|
isUser: boolean;
|
||||||
|
isContext: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
inputRef: RefObject<HTMLTextAreaElement>;
|
||||||
|
className?: string;
|
||||||
|
setIsLoading?: (value: boolean) => void;
|
||||||
|
setShowPromptModal?: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
159
app/containers/Chat/components/ModelSelect.tsx
Normal file
159
app/containers/Chat/components/ModelSelect.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Popover from "@/app/components/Popover";
|
||||||
|
import React, { useMemo, useRef } from "react";
|
||||||
|
import useRelativePosition, {
|
||||||
|
Orientation,
|
||||||
|
} from "@/app/hooks/useRelativePosition";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import { useAllModels } from "@/app/utils/hooks";
|
||||||
|
import { ModelType, useAppConfig } from "@/app/store/config";
|
||||||
|
import { showToast } from "@/app/components/ui-lib";
|
||||||
|
import BottomArrow from "@/app/icons/downArrowLgIcon.svg";
|
||||||
|
import BottomArrowMobile from "@/app/icons/bottomArrow.svg";
|
||||||
|
import Modal, { TriggerProps } from "@/app/components/Modal";
|
||||||
|
|
||||||
|
import Selected from "@/app/icons/selectedIcon.svg";
|
||||||
|
|
||||||
|
const ModelSelect = () => {
|
||||||
|
const config = useAppConfig();
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const currentModel = chatStore.currentSession().mask.modelConfig.model;
|
||||||
|
const allModels = useAllModels();
|
||||||
|
const models = useMemo(() => {
|
||||||
|
const filteredModels = allModels.filter((m) => m.available);
|
||||||
|
const defaultModel = filteredModels.find((m) => m.isDefault);
|
||||||
|
|
||||||
|
if (defaultModel) {
|
||||||
|
const arr = [
|
||||||
|
defaultModel,
|
||||||
|
...filteredModels.filter((m) => m !== defaultModel),
|
||||||
|
];
|
||||||
|
return arr;
|
||||||
|
} else {
|
||||||
|
return filteredModels;
|
||||||
|
}
|
||||||
|
}, [allModels]);
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { position, getRelativePosition } = useRelativePosition({
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentRef = useMemo<{ current: HTMLDivElement | null }>(() => {
|
||||||
|
return {
|
||||||
|
current: null,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const autoScrollToSelectedModal = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const distanceToParent = selectedItemRef.current?.offsetTop || 0;
|
||||||
|
const childHeight = selectedItemRef.current?.offsetHeight || 0;
|
||||||
|
const parentHeight = contentRef.current?.offsetHeight || 0;
|
||||||
|
const distanceToParentCenter =
|
||||||
|
distanceToParent + childHeight / 2 - parentHeight / 2;
|
||||||
|
|
||||||
|
if (distanceToParentCenter > 0 && contentRef.current) {
|
||||||
|
contentRef.current.scrollTop = distanceToParentCenter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const content: TriggerProps["content"] = ({ close }) => (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-1 overflow-x-hidden relative text-sm-title`}
|
||||||
|
>
|
||||||
|
{models?.map((o) => (
|
||||||
|
<div
|
||||||
|
key={o.displayName}
|
||||||
|
className={`flex items-center px-3 py-2 gap-3 rounded-action-btn hover:bg-select-option-hovered cursor-pointer`}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
chatStore.updateCurrentSession((session) => {
|
||||||
|
session.mask.modelConfig.model = o.name as ModelType;
|
||||||
|
session.mask.syncGlobalConfig = false;
|
||||||
|
});
|
||||||
|
showToast(o.name);
|
||||||
|
}}
|
||||||
|
ref={currentModel === o.name ? selectedItemRef : undefined}
|
||||||
|
>
|
||||||
|
<div className={`flex-1 text-text-select`}>{o.name}</div>
|
||||||
|
<div
|
||||||
|
className={currentModel === o.name ? "opacity-100" : "opacity-0"}
|
||||||
|
>
|
||||||
|
<Selected />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobileScreen) {
|
||||||
|
return (
|
||||||
|
<Modal.Trigger
|
||||||
|
content={(e) => (
|
||||||
|
<div className="h-[100%] overflow-y-auto" ref={contentRef}>
|
||||||
|
{content(e)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
type="bottom-drawer"
|
||||||
|
onOpen={(e) => {
|
||||||
|
if (e) {
|
||||||
|
autoScrollToSelectedModal();
|
||||||
|
getRelativePosition(rootRef.current!, "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={Locale.Chat.SelectModel}
|
||||||
|
headerBordered
|
||||||
|
noFooter
|
||||||
|
modelClassName="h-model-bottom-drawer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-pointer text-text-modal-select"
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
{currentModel}
|
||||||
|
<BottomArrowMobile />
|
||||||
|
</div>
|
||||||
|
</Modal.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div className="max-h-chat-actions-select-model-popover overflow-y-auto">
|
||||||
|
{content({ close: () => {} })}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
trigger="click"
|
||||||
|
noArrow
|
||||||
|
placement={
|
||||||
|
position?.poi.relativePosition[1] !== Orientation.bottom ? "lb" : "lt"
|
||||||
|
}
|
||||||
|
popoverClassName="border border-select-popover rounded-lg shadow-select-popover-shadow w-actions-popover bg-model-select-popover-panel w-[280px]"
|
||||||
|
onShow={(e) => {
|
||||||
|
if (e) {
|
||||||
|
autoScrollToSelectedModal();
|
||||||
|
getRelativePosition(rootRef.current!, "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
getPopoverPanelRef={(ref) => (contentRef.current = ref.current)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-1 cursor-pointer rounded-chat-model-select pl-3 pr-2.5 py-2 font-common leading-4 bg-chat-actions-select-model hover:bg-chat-actions-select-model-hover"
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<div className="line-clamp-1 max-w-chat-actions-select-model text-sm-title text-text-modal-select">
|
||||||
|
{currentModel}
|
||||||
|
</div>
|
||||||
|
<BottomArrow />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelSelect;
|
96
app/containers/Chat/components/PromptHint.tsx
Normal file
96
app/containers/Chat/components/PromptHint.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Prompt } from "@/app/store/prompt";
|
||||||
|
|
||||||
|
import styles from "../index.module.scss";
|
||||||
|
import useShowPromptHint from "@/app/hooks/useShowPromptHint";
|
||||||
|
|
||||||
|
export type RenderPompt = Pick<Prompt, "title" | "content">;
|
||||||
|
|
||||||
|
export default function PromptHints(props: {
|
||||||
|
prompts: RenderPompt[];
|
||||||
|
onPromptSelect: (prompt: RenderPompt) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const noPrompts = props.prompts.length === 0;
|
||||||
|
|
||||||
|
const [selectIndex, setSelectIndex] = useState(0);
|
||||||
|
|
||||||
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { internalPrompts, notShowPrompt } = useShowPromptHint({ ...props });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectIndex(0);
|
||||||
|
}, [props.prompts.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (noPrompts || e.metaKey || e.altKey || e.ctrlKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// arrow up / down to select prompt
|
||||||
|
const changeIndex = (delta: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(props.prompts.length - 1, selectIndex + delta),
|
||||||
|
);
|
||||||
|
setSelectIndex(nextIndex);
|
||||||
|
selectedRef.current?.scrollIntoView({
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
changeIndex(1);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
changeIndex(-1);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
const selectedPrompt = props.prompts.at(selectIndex);
|
||||||
|
if (selectedPrompt) {
|
||||||
|
props.onPromptSelect(selectedPrompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [props.prompts.length, selectIndex]);
|
||||||
|
|
||||||
|
if (!internalPrompts.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
transition-all duration-300 shadow-prompt-hint-container rounded-none flex flex-col-reverse overflow-x-hidden
|
||||||
|
${
|
||||||
|
notShowPrompt
|
||||||
|
? "max-h-[0vh] border-none"
|
||||||
|
: "border-b pt-2.5 max-h-[50vh]"
|
||||||
|
}
|
||||||
|
${props.className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{internalPrompts.map((prompt, i) => (
|
||||||
|
<div
|
||||||
|
ref={i === selectIndex ? selectedRef : null}
|
||||||
|
className={
|
||||||
|
styles["prompt-hint"] +
|
||||||
|
` ${i === selectIndex ? styles["prompt-hint-selected"] : ""}`
|
||||||
|
}
|
||||||
|
key={prompt.title + i.toString()}
|
||||||
|
onClick={() => props.onPromptSelect(prompt)}
|
||||||
|
onMouseEnter={() => setSelectIndex(i)}
|
||||||
|
>
|
||||||
|
<div className={styles["hint-title"]}>{prompt.title}</div>
|
||||||
|
<div className={styles["hint-content"]}>{prompt.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
32
app/containers/Chat/components/PromptToast.tsx
Normal file
32
app/containers/Chat/components/PromptToast.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
|
||||||
|
import BrainIcon from "@/app/icons/brain.svg";
|
||||||
|
|
||||||
|
import styles from "../index.module.scss";
|
||||||
|
|
||||||
|
export default function PromptToast(props: {
|
||||||
|
showToast?: boolean;
|
||||||
|
setShowModal: (_: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const context = session.mask.context;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles["prompt-toast"]} key="prompt-toast">
|
||||||
|
{props.showToast && (
|
||||||
|
<div
|
||||||
|
className={styles["prompt-toast-inner"] + " clickable"}
|
||||||
|
role="button"
|
||||||
|
onClick={() => props.setShowModal(true)}
|
||||||
|
>
|
||||||
|
<BrainIcon />
|
||||||
|
<span className={styles["prompt-toast-content"]}>
|
||||||
|
{Locale.Context.Toast(context.length)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
77
app/containers/Chat/components/SessionConfigModal.tsx
Normal file
77
app/containers/Chat/components/SessionConfigModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Modal, showConfirm } from "@/app/components/ui-lib";
|
||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import { useMaskStore } from "@/app/store/mask";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import { Path } from "@/app/constant";
|
||||||
|
|
||||||
|
import ResetIcon from "@/app/icons/reload.svg";
|
||||||
|
import CopyIcon from "@/app/icons/copy.svg";
|
||||||
|
import MaskConfig from "@/app/containers/Settings/components/MaskConfig";
|
||||||
|
import { ListItem } from "@/app/components/List";
|
||||||
|
|
||||||
|
export default function SessionConfigModel(props: { onClose: () => void }) {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const session = chatStore.currentSession();
|
||||||
|
const maskStore = useMaskStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Context.Edit}
|
||||||
|
onClose={() => props.onClose()}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="reset"
|
||||||
|
icon={<ResetIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Chat.Config.Reset}
|
||||||
|
onClick={async () => {
|
||||||
|
if (await showConfirm(Locale.Memory.ResetConfirm)) {
|
||||||
|
chatStore.updateCurrentSession(
|
||||||
|
(session) => (session.memoryPrompt = ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<IconButton
|
||||||
|
key="copy"
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Chat.Config.SaveAs}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(Path.Masks);
|
||||||
|
setTimeout(() => {
|
||||||
|
maskStore.create(session.mask);
|
||||||
|
}, 500);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
// className="!bg-modal-mask"
|
||||||
|
>
|
||||||
|
<MaskConfig
|
||||||
|
mask={session.mask}
|
||||||
|
updateMask={(updater) => {
|
||||||
|
const mask = { ...session.mask };
|
||||||
|
updater(mask);
|
||||||
|
chatStore.updateCurrentSession((session) => (session.mask = mask));
|
||||||
|
}}
|
||||||
|
shouldSyncFromGlobal
|
||||||
|
extraListItems={
|
||||||
|
session.mask.modelConfig.sendMemory ? (
|
||||||
|
<ListItem
|
||||||
|
className="copyable"
|
||||||
|
title={`${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`}
|
||||||
|
subTitle={session.memoryPrompt || Locale.Memory.EmptyContent}
|
||||||
|
></ListItem>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></MaskConfig>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
182
app/containers/Chat/components/SessionItem.tsx
Normal file
182
app/containers/Chat/components/SessionItem.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Draggable } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { Path } from "@/app/constant";
|
||||||
|
import { Mask } from "@/app/store/mask";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
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 = usePathname();
|
||||||
|
|
||||||
|
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 &&
|
||||||
|
(pathname === Path.Chat || pathname === Path.Home)
|
||||||
|
? `
|
||||||
|
md:!bg-chat-menu-session-selected md:!border-chat-menu-session-selected
|
||||||
|
!bg-chat-menu-session-selected-mobile !border-chat-menu-session-selected-mobile
|
||||||
|
`
|
||||||
|
: `md:hover:bg-chat-menu-session-hovered md:hover:chat-menu-session-hovered`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={props.onClick}
|
||||||
|
ref={(ele) => {
|
||||||
|
draggableRef.current = ele;
|
||||||
|
provided.innerRef(ele);
|
||||||
|
}}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
title={`${props.title}\n${Locale.ChatItem.ChatItemCount(
|
||||||
|
props.count,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 ">
|
||||||
|
<LogIcon />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<div className={`flex justify-between items-center`}>
|
||||||
|
<div
|
||||||
|
className={` text-text-chat-menu-item-title text-sm-title line-clamp-1 flex-1`}
|
||||||
|
>
|
||||||
|
{props.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-text-chat-menu-item-time text-sm group-hover/chat-menu-list:opacity-0 pl-3 hidden md:block`}
|
||||||
|
>
|
||||||
|
{getTime(props.time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-text-chat-menu-item-description text-sm`}>
|
||||||
|
{Locale.ChatItem.ChatItemCount(props.count)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-text-chat-menu-item-time text-sm pl-3 block md:hidden`}
|
||||||
|
>
|
||||||
|
{getTime(props.time)}
|
||||||
|
</div>
|
||||||
|
{props.isMobileScreen ? (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
|
||||||
|
follow-parent-svg
|
||||||
|
fill-none
|
||||||
|
text-text-chat-menu-item-delete
|
||||||
|
`}
|
||||||
|
onClickCapture={(e) => {
|
||||||
|
props.onDelete?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteChatIcon />
|
||||||
|
<div className="flex-1 font-common text-actions-popover-menu-item ">
|
||||||
|
{Locale.Chat.Actions.Delete}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
popoverClassName={`
|
||||||
|
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
|
||||||
|
`}
|
||||||
|
noArrow
|
||||||
|
placement="r"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
cursor-pointer rounded-chat-img
|
||||||
|
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
|
||||||
|
md:group-hover/chat-menu-list:pointer-events-auto
|
||||||
|
md:group-hover/chat-menu-list:opacity-100
|
||||||
|
md:hover:bg-select-hover
|
||||||
|
follow-parent-svg
|
||||||
|
fill-none
|
||||||
|
text-text-chat-menu-item-time
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<HoverPopover
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 p-3 rounded-action-btn leading-6 cursor-pointer
|
||||||
|
follow-parent-svg
|
||||||
|
fill-none
|
||||||
|
text-text-chat-menu-item-delete
|
||||||
|
`}
|
||||||
|
onClickCapture={(e) => {
|
||||||
|
props.onDelete?.();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteChatIcon />
|
||||||
|
<div className="flex-1 font-common text-actions-popover-menu-item text-text-chat-menu-item-delete">
|
||||||
|
{Locale.Chat.Actions.Delete}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
popoverClassName={`
|
||||||
|
px-2 py-1 border-delete-chat-popover bg-delete-chat-popover-panel rounded-md shadow-delete-chat-popover-shadow
|
||||||
|
`}
|
||||||
|
noArrow
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
cursor-pointer rounded-chat-img
|
||||||
|
md:!absolute md:top-[50%] md:translate-y-[-50%] md:right-3 md:pointer-events-none md:opacity-0
|
||||||
|
md:group-hover/chat-menu-list:pointer-events-auto
|
||||||
|
md:group-hover/chat-menu-list:opacity-100
|
||||||
|
md:hover:bg-select-hover
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</div>
|
||||||
|
</HoverPopover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
}
|
609
app/containers/Chat/index.module.scss
Normal file
609
app/containers/Chat/index.module.scss
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
@import "~@/app/styles/animation.scss";
|
||||||
|
|
||||||
|
.attach-images {
|
||||||
|
position: absolute;
|
||||||
|
left: 30px;
|
||||||
|
bottom: 32px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-image {
|
||||||
|
cursor: default;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: var(--white);
|
||||||
|
|
||||||
|
.attach-image-mask {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all ease 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-image-mask:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-image {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
float: right;
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.chat-input-action {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
border: var(--border-in-light);
|
||||||
|
padding: 4px 10px;
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
transition: width ease 0.3s;
|
||||||
|
align-items: center;
|
||||||
|
height: 16px;
|
||||||
|
width: var(--icon-width);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: 5px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-5px);
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--delay: 0.5s;
|
||||||
|
width: var(--full-width);
|
||||||
|
transition-delay: var(--delay);
|
||||||
|
|
||||||
|
.text {
|
||||||
|
transition-delay: var(--delay);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text,
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-toast {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -50px;
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
|
||||||
|
.prompt-toast-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
|
||||||
|
border: var(--border-in-light);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
|
||||||
|
animation: slide-in-from-top ease 0.3s;
|
||||||
|
|
||||||
|
.prompt-toast-content {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.section-title-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-prompt {
|
||||||
|
.context-prompt-insert {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0.2;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-prompt-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.context-drag {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-drag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-role {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-delete-button {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-prompt-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-prompt {
|
||||||
|
margin: 20px 0;
|
||||||
|
|
||||||
|
.memory-prompt-content {
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-context {
|
||||||
|
margin: 20px 0 0 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
border-top: var(--border-in-light);
|
||||||
|
border-bottom: var(--border-in-light);
|
||||||
|
box-shadow: var(--card-shadow) inset;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: var(--black);
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
|
$linear: linear-gradient(to right,
|
||||||
|
rgba(0, 0, 0, 0),
|
||||||
|
rgba(0, 0, 0, 1),
|
||||||
|
rgba(0, 0, 0, 0));
|
||||||
|
mask-image: $linear;
|
||||||
|
|
||||||
|
@mixin show {
|
||||||
|
transform: translateY(0);
|
||||||
|
position: relative;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin hide {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
position: absolute;
|
||||||
|
transition: all ease 0.1s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-tips {
|
||||||
|
@include show;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-revert-btn {
|
||||||
|
color: var(--primary);
|
||||||
|
@include hide;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--primary);
|
||||||
|
|
||||||
|
.clear-context-tips {
|
||||||
|
@include hide;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-context-revert-btn {
|
||||||
|
@include show;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
// height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
position: relative;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-body-main-title {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.chat-body-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
animation: slide-in ease 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.chat-message-header {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-header {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.chat-message-actions {
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
transform: scale(0.9) translateY(5px);
|
||||||
|
margin: 0 10px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.chat-input-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-container {
|
||||||
|
max-width: var(--message-max-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.chat-message-edit {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user>.chat-message-container {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-avatar {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.chat-message-edit {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific styles for iOS devices */
|
||||||
|
@media screen and (max-device-width: 812px) and (-webkit-min-device-pixel-ratio: 2) {
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
.chat-message-edit {
|
||||||
|
top: -8%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item {
|
||||||
|
// box-sizing: border-box;
|
||||||
|
// max-width: 100%;
|
||||||
|
// margin-top: 10px;
|
||||||
|
// border-radius: 10px;
|
||||||
|
// background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
// padding: 10px;
|
||||||
|
// font-size: 14px;
|
||||||
|
// user-select: text;
|
||||||
|
// word-break: break-word;
|
||||||
|
// border: var(--border-in-light);
|
||||||
|
// position: relative;
|
||||||
|
transition: all ease 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-images {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
justify-content: left;
|
||||||
|
grid-gap: 10px;
|
||||||
|
grid-template-columns: repeat(var(--image-count), auto);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
object-fit: cover;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image,
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
||||||
|
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
width: $calc-image-width;
|
||||||
|
height: $calc-image-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image {
|
||||||
|
max-width: calc(100vw/3*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
||||||
|
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
||||||
|
|
||||||
|
.chat-message-item-image-multi {
|
||||||
|
width: $image-width;
|
||||||
|
height: $image-width;
|
||||||
|
max-width: $max-image-width;
|
||||||
|
max-height: $max-image-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-item-image {
|
||||||
|
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .chat-message-action-date {
|
||||||
|
// // font-size: 12px;
|
||||||
|
// // opacity: 0.2;
|
||||||
|
// // white-space: nowrap;
|
||||||
|
// // transition: all ease 0.6s;
|
||||||
|
// // color: var(--black);
|
||||||
|
// // text-align: right;
|
||||||
|
// // width: 100%;
|
||||||
|
// // box-sizing: border-box;
|
||||||
|
// // padding-right: 10px;
|
||||||
|
// // pointer-events: none;
|
||||||
|
// // z-index: 1;
|
||||||
|
// }
|
||||||
|
|
||||||
|
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||||
|
background-color: var(--second);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-panel {
|
||||||
|
// position: relative;
|
||||||
|
// width: 100%;
|
||||||
|
// padding: 20px;
|
||||||
|
// padding-top: 10px;
|
||||||
|
// box-sizing: border-box;
|
||||||
|
// flex-direction: column;
|
||||||
|
// border-top: var(--border-in-light);
|
||||||
|
// box-shadow: var(--card-shadow);
|
||||||
|
|
||||||
|
.chat-input-actions {
|
||||||
|
.chat-input-action {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin single-line {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-hint {
|
||||||
|
color:var(--btn-default-text);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: transparent 1px solid;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bolder;
|
||||||
|
|
||||||
|
@include single-line();
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-content {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
@include single-line();
|
||||||
|
}
|
||||||
|
|
||||||
|
&-selected,
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .chat-input-panel-inner {
|
||||||
|
// cursor: text;
|
||||||
|
// display: flex;
|
||||||
|
// flex: 1;
|
||||||
|
// border-radius: 10px;
|
||||||
|
// border: var(--border-in-light);
|
||||||
|
// }
|
||||||
|
|
||||||
|
.chat-input-panel-inner-attach {
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-panel-inner:has(.chat-input:focus) {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.03);
|
||||||
|
background-color: var(--white);
|
||||||
|
color: var(--black);
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 10px 90px 10px 14px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {}
|
||||||
|
|
||||||
|
.chat-input-send {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.chat-input {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-send {
|
||||||
|
bottom: 30px;
|
||||||
|
}
|
||||||
|
}
|
148
app/containers/Chat/index.tsx
Normal file
148
app/containers/Chat/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Droppable,
|
||||||
|
OnDragEndResponder,
|
||||||
|
} from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
import { useAppConfig, useChatStore } from "@/app/store";
|
||||||
|
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
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";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
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 config = useAppConfig();
|
||||||
|
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
useEffect(() => {
|
||||||
|
setShowPanel?.(pathname === Path.Chat);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
router.push(Path.Chat);
|
||||||
|
} else {
|
||||||
|
// navigate(Path.NewChat);
|
||||||
|
router.push(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);
|
||||||
|
router.push(Path.Chat);
|
||||||
|
}}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (
|
||||||
|
await Modal.warn({
|
||||||
|
okText: Locale.ChatItem.DeleteOkBtn,
|
||||||
|
cancelText: Locale.ChatItem.DeleteCancelBtn,
|
||||||
|
title: Locale.ChatItem.DeleteTitle,
|
||||||
|
content: Locale.ChatItem.DeleteContent,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
chatStore.deleteSession(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
mask={item.mask}
|
||||||
|
isMobileScreen={isMobileScreen}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, Panel);
|
137
app/containers/Settings/SettingPanel.tsx
Normal file
137
app/containers/Settings/SettingPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useAccessStore, useAppConfig } from "@/app/store";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
|
||||||
|
import { Path } from "@/app/constant";
|
||||||
|
import List from "@/app/components/List";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import Card from "@/app/components/Card";
|
||||||
|
import SettingHeader from "./components/SettingHeader";
|
||||||
|
import { MenuWrapperInspectProps } from "@/app/components/MenuLayout";
|
||||||
|
import SyncItems from "./components/SyncItems";
|
||||||
|
import DangerItems from "./components/DangerItems";
|
||||||
|
import AppSetting from "./components/AppSetting";
|
||||||
|
import MaskSetting from "./components/MaskSetting";
|
||||||
|
import PromptSetting from "./components/PromptSetting";
|
||||||
|
import ProviderSetting from "./components/ProviderSetting";
|
||||||
|
import ModelConfigList from "./components/ModelSetting";
|
||||||
|
|
||||||
|
export default function Settings(props: MenuWrapperInspectProps) {
|
||||||
|
const { setShowPanel, id } = props;
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keydownEvent = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
navigate(Path.Home);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (clientConfig?.isApp) {
|
||||||
|
// Force to set custom endpoint to true if it's app
|
||||||
|
accessStore.update((state) => {
|
||||||
|
state.useCustomConfig = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", keydownEvent);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", keydownEvent);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||||
|
|
||||||
|
const cardClassName = "mb-6 md:mb-8 last:mb-0";
|
||||||
|
|
||||||
|
const itemMap = {
|
||||||
|
[Locale.Settings.GeneralSettings]: (
|
||||||
|
<>
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Basic.Title}>
|
||||||
|
<AppSetting />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Mask.Title}>
|
||||||
|
<MaskSetting />
|
||||||
|
</Card>
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Prompt.Title}>
|
||||||
|
<PromptSetting />
|
||||||
|
</Card>
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Provider.Title}>
|
||||||
|
<ProviderSetting />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Danger.Title}>
|
||||||
|
<DangerItems />
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[Locale.Settings.ModelSettings]: (
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Models.Title}>
|
||||||
|
<List
|
||||||
|
widgetStyle={{
|
||||||
|
// selectClassName: "min-w-select-mobile-lg",
|
||||||
|
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||||
|
inputClassName: "md:min-w-select",
|
||||||
|
rangeClassName: "md:min-w-select",
|
||||||
|
rangeNextLine: isMobileScreen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModelConfigList
|
||||||
|
modelConfig={config.modelConfig}
|
||||||
|
updateConfig={(updater) => {
|
||||||
|
const modelConfig = { ...config.modelConfig };
|
||||||
|
updater(modelConfig);
|
||||||
|
config.update((config) => (config.modelConfig = modelConfig));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
[Locale.Settings.DataSettings]: (
|
||||||
|
<Card className={cardClassName} title={Locale.Settings.Sync.Title}>
|
||||||
|
<SyncItems />
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex flex-col overflow-hidden bg-settings-panel
|
||||||
|
h-setting-panel-mobile
|
||||||
|
md:h-[100%] md:mr-2.5 md:rounded-md
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<SettingHeader
|
||||||
|
isMobileScreen={isMobileScreen}
|
||||||
|
goback={() => setShowPanel?.(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-md:w-[100%]
|
||||||
|
px-4 py-5
|
||||||
|
md:px-6 md:py-8
|
||||||
|
flex items-start justify-center
|
||||||
|
overflow-y-auto
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-full
|
||||||
|
max-w-screen-md
|
||||||
|
!overflow-x-hidden
|
||||||
|
overflow-y-auto
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{itemMap[id] || null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
200
app/containers/Settings/components/AppSetting.tsx
Normal file
200
app/containers/Settings/components/AppSetting.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
import ResetIcon from "@/app/icons/reload.svg";
|
||||||
|
|
||||||
|
import styles from "../index.module.scss";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Avatar, AvatarPicker } from "@/app/components/emoji";
|
||||||
|
import { Popover } from "@/app/components/ui-lib";
|
||||||
|
import Locale, {
|
||||||
|
ALL_LANG_OPTIONS,
|
||||||
|
AllLangs,
|
||||||
|
changeLang,
|
||||||
|
getLang,
|
||||||
|
} from "@/app/locales";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import { useUpdateStore } from "@/app/store/update";
|
||||||
|
import {
|
||||||
|
SubmitKey,
|
||||||
|
Theme,
|
||||||
|
ThemeConfig,
|
||||||
|
useAppConfig,
|
||||||
|
} from "@/app/store/config";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { RELEASE_URL, UPDATE_URL } from "@/app/constant";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Select from "@/app/components/Select";
|
||||||
|
import SlideRange from "@/app/components/SlideRange";
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
|
||||||
|
export interface AppSettingProps {}
|
||||||
|
|
||||||
|
export default function AppSetting(props: AppSettingProps) {
|
||||||
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
|
||||||
|
const updateStore = useUpdateStore();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const { update: updateConfig, isMobileScreen } = config;
|
||||||
|
|
||||||
|
const currentVersion = updateStore.formatVersion(updateStore.version);
|
||||||
|
const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
|
||||||
|
const hasNewVersion = currentVersion !== remoteId;
|
||||||
|
const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
|
||||||
|
|
||||||
|
function checkUpdate(force = false) {
|
||||||
|
setCheckingUpdate(true);
|
||||||
|
updateStore.getLatestVersion(force).then(() => {
|
||||||
|
setCheckingUpdate(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Update] local version ", updateStore.version);
|
||||||
|
console.log("[Update] remote version ", updateStore.remoteVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// checks per minutes
|
||||||
|
checkUpdate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
widgetStyle={{
|
||||||
|
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||||
|
inputClassName: "md:min-w-select",
|
||||||
|
rangeClassName: "md:min-w-select",
|
||||||
|
rangeNextLine: isMobileScreen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem title={Locale.Settings.Avatar}>
|
||||||
|
<Popover
|
||||||
|
onClose={() => setShowEmojiPicker(false)}
|
||||||
|
content={
|
||||||
|
<AvatarPicker
|
||||||
|
onEmojiClick={(avatar: string) => {
|
||||||
|
updateConfig((config) => (config.avatar = avatar));
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
open={showEmojiPicker}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.avatar}
|
||||||
|
onClick={() => {
|
||||||
|
setShowEmojiPicker(!showEmojiPicker);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar avatar={config.avatar} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
|
||||||
|
subTitle={
|
||||||
|
checkingUpdate
|
||||||
|
? Locale.Settings.Update.IsChecking
|
||||||
|
: hasNewVersion
|
||||||
|
? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
|
||||||
|
: Locale.Settings.Update.IsLatest
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{checkingUpdate ? (
|
||||||
|
<LoadingIcon />
|
||||||
|
) : hasNewVersion ? (
|
||||||
|
<Link href={updateUrl} target="_blank" className="link">
|
||||||
|
{Locale.Settings.Update.GoToUpdate}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={<ResetIcon />}
|
||||||
|
text={Locale.Settings.Update.CheckUpdate}
|
||||||
|
onClick={() => checkUpdate(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title={Locale.Settings.SendKey}>
|
||||||
|
<Select
|
||||||
|
value={config.submitKey}
|
||||||
|
options={Object.values(SubmitKey).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: v,
|
||||||
|
}))}
|
||||||
|
onSelect={(v) => {
|
||||||
|
updateConfig((config) => (config.submitKey = v));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title={Locale.Settings.Theme}>
|
||||||
|
<Select
|
||||||
|
value={config.theme}
|
||||||
|
options={Object.entries(ThemeConfig).map(([k, t]) => ({
|
||||||
|
value: k as Theme,
|
||||||
|
label: t.title,
|
||||||
|
icon: <t.icon />,
|
||||||
|
}))}
|
||||||
|
onSelect={(e) => {
|
||||||
|
updateConfig((config) => (config.theme = e));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title={Locale.Settings.Lang.Name}>
|
||||||
|
<Select
|
||||||
|
value={getLang()}
|
||||||
|
options={AllLangs.map((lang) => ({
|
||||||
|
value: lang,
|
||||||
|
label: ALL_LANG_OPTIONS[lang],
|
||||||
|
}))}
|
||||||
|
onSelect={(e) => {
|
||||||
|
changeLang(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.FontSize.Title}
|
||||||
|
subTitle={Locale.Settings.FontSize.SubTitle}
|
||||||
|
>
|
||||||
|
<SlideRange
|
||||||
|
value={config.fontSize}
|
||||||
|
range={{
|
||||||
|
start: 12,
|
||||||
|
stroke: 28,
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
onSlide={(e) => updateConfig((config) => (config.fontSize = e))}
|
||||||
|
></SlideRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.AutoGenerateTitle.Title}
|
||||||
|
subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={config.enableAutoGenerateTitle}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig((config) => (config.enableAutoGenerateTitle = e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.SendPreviewBubble.Title}
|
||||||
|
subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={config.sendPreviewBubble}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig((config) => (config.sendPreviewBubble = e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
153
app/containers/Settings/components/DangerItems.tsx
Normal file
153
app/containers/Settings/components/DangerItems.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import { showConfirm } from "@/app/components/ui-lib";
|
||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useAccessStore } from "@/app/store/access";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { OPENAI_BASE_URL, ServiceProvider } from "@/app/constant";
|
||||||
|
import { useUpdateStore } from "@/app/store/update";
|
||||||
|
|
||||||
|
import ResetIcon from "@/app/icons/reload.svg";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
import Btn from "@/app/components/Btn";
|
||||||
|
|
||||||
|
export default function DangerItems() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const appConfig = useAppConfig();
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const updateStore = useUpdateStore();
|
||||||
|
const { isMobileScreen } = appConfig;
|
||||||
|
|
||||||
|
const enabledAccessControl = useMemo(
|
||||||
|
() => accessStore.enabledAccessControl(),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||||
|
|
||||||
|
const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
|
||||||
|
|
||||||
|
const shouldHideBalanceQuery = useMemo(() => {
|
||||||
|
const isOpenAiUrl = accessStore.openaiUrl.includes(OPENAI_BASE_URL);
|
||||||
|
return (
|
||||||
|
accessStore.hideBalanceQuery ||
|
||||||
|
isOpenAiUrl ||
|
||||||
|
accessStore.provider === ServiceProvider.Azure
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
accessStore.hideBalanceQuery,
|
||||||
|
accessStore.openaiUrl,
|
||||||
|
accessStore.provider,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [loadingUsage, setLoadingUsage] = useState(false);
|
||||||
|
const usage = {
|
||||||
|
used: updateStore.used,
|
||||||
|
subscription: updateStore.subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkUsage(force = false) {
|
||||||
|
if (shouldHideBalanceQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingUsage(true);
|
||||||
|
updateStore.updateUsage(force).finally(() => {
|
||||||
|
setLoadingUsage(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const showUsage = accessStore.isAuthorized();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showUsage && checkUsage();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
widgetStyle={{
|
||||||
|
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||||
|
inputClassName: "md:min-w-select",
|
||||||
|
rangeClassName: "md:min-w-select",
|
||||||
|
rangeNextLine: isMobileScreen,
|
||||||
|
inputNextLine: isMobileScreen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAccessCode && (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.AccessCode.Title}
|
||||||
|
subTitle={Locale.Settings.Access.AccessCode.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={accessStore.accessCode}
|
||||||
|
type="password"
|
||||||
|
placeholder={Locale.Settings.Access.AccessCode.Placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update((access) => (access.accessCode = e));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!shouldHideBalanceQuery && !clientConfig?.isApp ? (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Usage.Title}
|
||||||
|
subTitle={
|
||||||
|
showUsage
|
||||||
|
? loadingUsage
|
||||||
|
? Locale.Settings.Usage.IsChecking
|
||||||
|
: Locale.Settings.Usage.SubTitle(
|
||||||
|
usage?.used ?? "[?]",
|
||||||
|
usage?.subscription ?? "[?]",
|
||||||
|
)
|
||||||
|
: Locale.Settings.Usage.NoAccess
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!showUsage || loadingUsage ? (
|
||||||
|
<div />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={<ResetIcon />}
|
||||||
|
text={Locale.Settings.Usage.Check}
|
||||||
|
onClick={() => checkUsage(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Danger.Reset.Title}
|
||||||
|
subTitle={Locale.Settings.Danger.Reset.SubTitle}
|
||||||
|
>
|
||||||
|
<Btn
|
||||||
|
text={Locale.Settings.Danger.Reset.Action}
|
||||||
|
onClick={async () => {
|
||||||
|
if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
|
||||||
|
appConfig.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="danger"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Danger.Clear.Title}
|
||||||
|
subTitle={Locale.Settings.Danger.Clear.SubTitle}
|
||||||
|
>
|
||||||
|
<Btn
|
||||||
|
text={Locale.Settings.Danger.Clear.Action}
|
||||||
|
onClick={async () => {
|
||||||
|
if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
|
||||||
|
chatStore.clearAllData();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="danger"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
162
app/containers/Settings/components/MaskConfig.tsx
Normal file
162
app/containers/Settings/components/MaskConfig.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import { ContextPrompts, MaskAvatar } from "@/app/components/mask";
|
||||||
|
import { Path } from "@/app/constant";
|
||||||
|
import { ModelConfig, useAppConfig } from "@/app/store/config";
|
||||||
|
import { Mask } from "@/app/store/mask";
|
||||||
|
import { Updater } from "@/app/typing";
|
||||||
|
import { copyToClipboard } from "@/app/utils";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { Popover, showConfirm } from "@/app/components/ui-lib";
|
||||||
|
import { AvatarPicker } from "@/app/components/emoji";
|
||||||
|
import ModelSetting from "@/app/containers/Settings/components/ModelSetting";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
|
||||||
|
import CopyIcon from "@/app/icons/copy.svg";
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
|
||||||
|
export default function MaskConfig(props: {
|
||||||
|
mask: Mask;
|
||||||
|
updateMask: Updater<Mask>;
|
||||||
|
extraListItems?: JSX.Element;
|
||||||
|
readonly?: boolean;
|
||||||
|
shouldSyncFromGlobal?: boolean;
|
||||||
|
}) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
|
||||||
|
const updateConfig = (updater: (config: ModelConfig) => void) => {
|
||||||
|
if (props.readonly) return;
|
||||||
|
|
||||||
|
const config = { ...props.mask.modelConfig };
|
||||||
|
updater(config);
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.modelConfig = config;
|
||||||
|
// if user changed current session mask, it will disable auto sync
|
||||||
|
mask.syncGlobalConfig = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyMaskLink = () => {
|
||||||
|
const maskLink = `${location.protocol}//${location.host}/#${Path.NewChat}?mask=${props.mask.id}`;
|
||||||
|
copyToClipboard(maskLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalConfig = useAppConfig();
|
||||||
|
|
||||||
|
const { isMobileScreen } = globalConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContextPrompts
|
||||||
|
context={props.mask.context}
|
||||||
|
updateContext={(updater) => {
|
||||||
|
const context = props.mask.context.slice();
|
||||||
|
updater(context);
|
||||||
|
props.updateMask((mask) => (mask.context = context));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<List
|
||||||
|
widgetStyle={{
|
||||||
|
rangeNextLine: isMobileScreen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem title={Locale.Mask.Config.Avatar}>
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<AvatarPicker
|
||||||
|
onEmojiClick={(emoji) => {
|
||||||
|
props.updateMask((mask) => (mask.avatar = emoji));
|
||||||
|
setShowPicker(false);
|
||||||
|
}}
|
||||||
|
></AvatarPicker>
|
||||||
|
}
|
||||||
|
open={showPicker}
|
||||||
|
onClose={() => setShowPicker(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => setShowPicker(true)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<MaskAvatar
|
||||||
|
avatar={props.mask.avatar}
|
||||||
|
model={props.mask.modelConfig.model}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={Locale.Mask.Config.Name}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={props.mask.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.name = e;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Mask.Config.HideContext.Title}
|
||||||
|
subTitle={Locale.Mask.Config.HideContext.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={!!props.mask.hideContext}
|
||||||
|
onChange={(e) => {
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.hideContext = e;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Switch>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{!props.shouldSyncFromGlobal ? (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Mask.Config.Share.Title}
|
||||||
|
subTitle={Locale.Mask.Config.Share.SubTitle}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
text={Locale.Mask.Config.Share.Action}
|
||||||
|
onClick={copyMaskLink}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{props.shouldSyncFromGlobal ? (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Mask.Config.Sync.Title}
|
||||||
|
subTitle={Locale.Mask.Config.Sync.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={!!props.mask.syncGlobalConfig}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const checked = e;
|
||||||
|
if (
|
||||||
|
checked &&
|
||||||
|
(await showConfirm(Locale.Mask.Config.Sync.Confirm))
|
||||||
|
) {
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.syncGlobalConfig = checked;
|
||||||
|
mask.modelConfig = { ...globalConfig.modelConfig };
|
||||||
|
});
|
||||||
|
} else if (!checked) {
|
||||||
|
props.updateMask((mask) => {
|
||||||
|
mask.syncGlobalConfig = checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ModelSetting
|
||||||
|
modelConfig={{ ...props.mask.modelConfig }}
|
||||||
|
updateConfig={updateConfig}
|
||||||
|
/>
|
||||||
|
{props.extraListItems}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
39
app/containers/Settings/components/MaskSetting.tsx
Normal file
39
app/containers/Settings/components/MaskSetting.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
|
||||||
|
export interface MaskSettingProps {}
|
||||||
|
|
||||||
|
export default function MaskSetting(props: MaskSettingProps) {
|
||||||
|
const config = useAppConfig();
|
||||||
|
const updateConfig = config.update;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Mask.Splash.Title}
|
||||||
|
subTitle={Locale.Settings.Mask.Splash.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={!config.dontShowMaskSplashScreen}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig((config) => (config.dontShowMaskSplashScreen = !e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Mask.Builtin.Title}
|
||||||
|
subTitle={Locale.Settings.Mask.Builtin.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={config.hideBuiltinMasks}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig((config) => (config.hideBuiltinMasks = e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
220
app/containers/Settings/components/ModelSetting.tsx
Normal file
220
app/containers/Settings/components/ModelSetting.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { ListItem } from "@/app/components/List";
|
||||||
|
import {
|
||||||
|
ModalConfigValidator,
|
||||||
|
ModelConfig,
|
||||||
|
useAppConfig,
|
||||||
|
} from "@/app/store/config";
|
||||||
|
import { useAllModels } from "@/app/utils/hooks";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import Select from "@/app/components/Select";
|
||||||
|
import SlideRange from "@/app/components/SlideRange";
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
|
||||||
|
export default function ModelSetting(props: {
|
||||||
|
modelConfig: ModelConfig;
|
||||||
|
updateConfig: (updater: (config: ModelConfig) => void) => void;
|
||||||
|
}) {
|
||||||
|
const allModels = useAllModels();
|
||||||
|
const { isMobileScreen } = useAppConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItem title={Locale.Settings.Model}>
|
||||||
|
<Select
|
||||||
|
value={props.modelConfig.model}
|
||||||
|
options={allModels
|
||||||
|
.filter((v) => v.available)
|
||||||
|
.map((v) => ({
|
||||||
|
value: v.name,
|
||||||
|
label: `${v.displayName}(${v.provider?.providerName})`,
|
||||||
|
}))}
|
||||||
|
onSelect={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.model = ModalConfigValidator.model(e)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Temperature.Title}
|
||||||
|
subTitle={Locale.Settings.Temperature.SubTitle}
|
||||||
|
>
|
||||||
|
<SlideRange
|
||||||
|
value={props.modelConfig.temperature}
|
||||||
|
range={{
|
||||||
|
start: 0,
|
||||||
|
stroke: 1,
|
||||||
|
}}
|
||||||
|
step={0.1}
|
||||||
|
onSlide={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.temperature = ModalConfigValidator.temperature(e)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></SlideRange>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.TopP.Title}
|
||||||
|
subTitle={Locale.Settings.TopP.SubTitle}
|
||||||
|
>
|
||||||
|
<SlideRange
|
||||||
|
value={props.modelConfig.top_p ?? 1}
|
||||||
|
range={{
|
||||||
|
start: 0,
|
||||||
|
stroke: 1,
|
||||||
|
}}
|
||||||
|
step={0.1}
|
||||||
|
onSlide={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.top_p = ModalConfigValidator.top_p(e)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></SlideRange>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.MaxTokens.Title}
|
||||||
|
subTitle={Locale.Settings.MaxTokens.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1024}
|
||||||
|
max={512000}
|
||||||
|
value={props.modelConfig.max_tokens}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.max_tokens = ModalConfigValidator.max_tokens(e)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{props.modelConfig.model.startsWith("gemini") ? null : (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.PresencePenalty.Title}
|
||||||
|
subTitle={Locale.Settings.PresencePenalty.SubTitle}
|
||||||
|
>
|
||||||
|
<SlideRange
|
||||||
|
value={props.modelConfig.presence_penalty}
|
||||||
|
range={{
|
||||||
|
start: -2,
|
||||||
|
stroke: 4,
|
||||||
|
}}
|
||||||
|
step={0.1}
|
||||||
|
onSlide={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.presence_penalty =
|
||||||
|
ModalConfigValidator.presence_penalty(e)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></SlideRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.FrequencyPenalty.Title}
|
||||||
|
subTitle={Locale.Settings.FrequencyPenalty.SubTitle}
|
||||||
|
>
|
||||||
|
<SlideRange
|
||||||
|
value={props.modelConfig.frequency_penalty}
|
||||||
|
range={{
|
||||||
|
start: -2,
|
||||||
|
stroke: 4,
|
||||||
|
}}
|
||||||
|
step={0.1}
|
||||||
|
onSlide={(e) => {
|
||||||
|
props.updateConfig(
|
||||||
|
(config) =>
|
||||||
|
(config.frequency_penalty =
|
||||||
|
ModalConfigValidator.frequency_penalty(e)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></SlideRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.InjectSystemPrompts.Title}
|
||||||
|
subTitle={Locale.Settings.InjectSystemPrompts.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={props.modelConfig.enableInjectSystemPrompts}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.enableInjectSystemPrompts = e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.InputTemplate.Title}
|
||||||
|
subTitle={Locale.Settings.InputTemplate.SubTitle}
|
||||||
|
nextline={isMobileScreen}
|
||||||
|
validator={(v: string) => {
|
||||||
|
if (!v.includes("{{input}}")) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
message: Locale.Settings.InputTemplate.Error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: false };
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={props.modelConfig.template}
|
||||||
|
onChange={(e = "") =>
|
||||||
|
props.updateConfig((config) => (config.template = e))
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.HistoryCount.Title}
|
||||||
|
subTitle={Locale.Settings.HistoryCount.SubTitle}
|
||||||
|
>
|
||||||
|
<SlideRange
|
||||||
|
value={props.modelConfig.historyMessageCount}
|
||||||
|
range={{
|
||||||
|
start: 0,
|
||||||
|
stroke: 64,
|
||||||
|
}}
|
||||||
|
step={1}
|
||||||
|
onSlide={(e) => {
|
||||||
|
props.updateConfig((config) => (config.historyMessageCount = e));
|
||||||
|
}}
|
||||||
|
></SlideRange>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.CompressThreshold.Title}
|
||||||
|
subTitle={Locale.Settings.CompressThreshold.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={500}
|
||||||
|
max={4000}
|
||||||
|
value={props.modelConfig.compressMessageLengthThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig(
|
||||||
|
(config) => (config.compressMessageLengthThreshold = e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={Locale.Memory.Title} subTitle={Locale.Memory.Send}>
|
||||||
|
<Switch
|
||||||
|
value={props.modelConfig.sendMemory}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.updateConfig((config) => (config.sendMemory = e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
63
app/containers/Settings/components/PromptSetting.tsx
Normal file
63
app/containers/Settings/components/PromptSetting.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import UserPromptModal from "./UserPromptModal";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
import { SearchService, usePromptStore } from "@/app/store/prompt";
|
||||||
|
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
import Btn from "@/app/components/Btn";
|
||||||
|
|
||||||
|
import EditIcon from "@/app/icons/editIcon.svg";
|
||||||
|
|
||||||
|
export interface PromptSettingProps {}
|
||||||
|
|
||||||
|
export default function PromptSetting(props: PromptSettingProps) {
|
||||||
|
const [shouldShowPromptModal, setShowPromptModal] = useState(false);
|
||||||
|
|
||||||
|
const config = useAppConfig();
|
||||||
|
const updateConfig = config.update;
|
||||||
|
|
||||||
|
const builtinCount = SearchService.count.builtin;
|
||||||
|
|
||||||
|
const promptStore = usePromptStore();
|
||||||
|
const customCount = promptStore.getUserPrompts().length ?? 0;
|
||||||
|
|
||||||
|
const textStyle = " !text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Prompt.Disable.Title}
|
||||||
|
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={config.disablePromptHint}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig((config) => (config.disablePromptHint = e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Prompt.List}
|
||||||
|
subTitle={Locale.Settings.Prompt.ListCount(builtinCount, customCount)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Btn
|
||||||
|
onClick={() => setShowPromptModal(true)}
|
||||||
|
text={
|
||||||
|
<span className={textStyle}>{Locale.Settings.Prompt.Edit}</span>
|
||||||
|
}
|
||||||
|
prefixIcon={config.isMobileScreen ? undefined : <EditIcon />}
|
||||||
|
></Btn>
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
{shouldShowPromptModal && (
|
||||||
|
<UserPromptModal onClose={() => setShowPromptModal(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
283
app/containers/Settings/components/ProviderSetting.tsx
Normal file
283
app/containers/Settings/components/ProviderSetting.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Anthropic,
|
||||||
|
Azure,
|
||||||
|
Google,
|
||||||
|
OPENAI_BASE_URL,
|
||||||
|
ServiceProvider,
|
||||||
|
SlotID,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { useAccessStore } from "@/app/store/access";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Select from "@/app/components/Select";
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
|
||||||
|
export default function ProviderSetting() {
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
const clientConfig = useMemo(() => getClientConfig(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
id={SlotID.CustomModel}
|
||||||
|
widgetStyle={{
|
||||||
|
selectClassName: "min-w-select-mobile md:min-w-select",
|
||||||
|
inputClassName: "md:min-w-select",
|
||||||
|
rangeClassName: "md:min-w-select",
|
||||||
|
inputNextLine: isMobileScreen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!accessStore.hideUserApiKey && (
|
||||||
|
<>
|
||||||
|
{
|
||||||
|
// Conditionally render the following ListItem based on clientConfig.isApp
|
||||||
|
!clientConfig?.isApp && ( // only show if isApp is false
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.CustomEndpoint.Title}
|
||||||
|
subTitle={Locale.Settings.Access.CustomEndpoint.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={accessStore.useCustomConfig}
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update((access) => (access.useCustomConfig = e))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{accessStore.useCustomConfig && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Provider.Title}
|
||||||
|
subTitle={Locale.Settings.Access.Provider.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={accessStore.provider}
|
||||||
|
onSelect={(e) => {
|
||||||
|
accessStore.update((access) => (access.provider = e));
|
||||||
|
}}
|
||||||
|
options={Object.entries(ServiceProvider).map(([k, v]) => ({
|
||||||
|
value: v,
|
||||||
|
label: k,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{accessStore.provider === ServiceProvider.OpenAI && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.OpenAI.Endpoint.Title}
|
||||||
|
subTitle={Locale.Settings.Access.OpenAI.Endpoint.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.openaiUrl}
|
||||||
|
placeholder={OPENAI_BASE_URL}
|
||||||
|
onChange={(e = "") =>
|
||||||
|
accessStore.update((access) => (access.openaiUrl = e))
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.OpenAI.ApiKey.Title}
|
||||||
|
subTitle={Locale.Settings.Access.OpenAI.ApiKey.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={accessStore.openaiApiKey}
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
Locale.Settings.Access.OpenAI.ApiKey.Placeholder
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.openaiApiKey = e),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{accessStore.provider === ServiceProvider.Azure && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Azure.Endpoint.Title}
|
||||||
|
subTitle={
|
||||||
|
Locale.Settings.Access.Azure.Endpoint.SubTitle +
|
||||||
|
Azure.ExampleEndpoint
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.azureUrl}
|
||||||
|
placeholder={Azure.ExampleEndpoint}
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update((access) => (access.azureUrl = e))
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Azure.ApiKey.Title}
|
||||||
|
subTitle={Locale.Settings.Access.Azure.ApiKey.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={accessStore.azureApiKey}
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
Locale.Settings.Access.Azure.ApiKey.Placeholder
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.azureApiKey = e),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Azure.ApiVerion.Title}
|
||||||
|
subTitle={Locale.Settings.Access.Azure.ApiVerion.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.azureApiVersion}
|
||||||
|
placeholder="2023-08-01-preview"
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.azureApiVersion = e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{accessStore.provider === ServiceProvider.Google && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Google.Endpoint.Title}
|
||||||
|
subTitle={
|
||||||
|
Locale.Settings.Access.Google.Endpoint.SubTitle +
|
||||||
|
Google.ExampleEndpoint
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.googleUrl}
|
||||||
|
placeholder={Google.ExampleEndpoint}
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update((access) => (access.googleUrl = e))
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Google.ApiKey.Title}
|
||||||
|
subTitle={Locale.Settings.Access.Google.ApiKey.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={accessStore.googleApiKey}
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
Locale.Settings.Access.Google.ApiKey.Placeholder
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.googleApiKey = e),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Google.ApiVersion.Title}
|
||||||
|
subTitle={Locale.Settings.Access.Google.ApiVersion.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.googleApiVersion}
|
||||||
|
placeholder="2023-08-01-preview"
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.googleApiVersion = e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{accessStore.provider === ServiceProvider.Anthropic && (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Anthropic.Endpoint.Title}
|
||||||
|
subTitle={
|
||||||
|
Locale.Settings.Access.Anthropic.Endpoint.SubTitle +
|
||||||
|
Anthropic.ExampleEndpoint
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.anthropicUrl}
|
||||||
|
placeholder={Anthropic.ExampleEndpoint}
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.anthropicUrl = e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Anthropic.ApiKey.Title}
|
||||||
|
subTitle={Locale.Settings.Access.Anthropic.ApiKey.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={accessStore.anthropicApiKey}
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
Locale.Settings.Access.Anthropic.ApiKey.Placeholder
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.anthropicApiKey = e),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.Anthropic.ApiVerion.Title}
|
||||||
|
subTitle={
|
||||||
|
Locale.Settings.Access.Anthropic.ApiVerion.SubTitle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={accessStore.anthropicApiVersion}
|
||||||
|
placeholder={Anthropic.Vision}
|
||||||
|
onChange={(e) =>
|
||||||
|
accessStore.update(
|
||||||
|
(access) => (access.anthropicApiVersion = e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Access.CustomModel.Title}
|
||||||
|
subTitle={Locale.Settings.Access.CustomModel.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={config.customModels}
|
||||||
|
placeholder="model1,model2,model3"
|
||||||
|
onChange={(e) => config.update((config) => (config.customModels = e))}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
47
app/containers/Settings/components/SettingHeader.tsx
Normal file
47
app/containers/Settings/components/SettingHeader.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import Locale from "@/app/locales";
|
||||||
|
import GobackIcon from "@/app/icons/goback.svg";
|
||||||
|
|
||||||
|
export interface ChatHeaderProps {
|
||||||
|
isMobileScreen: boolean;
|
||||||
|
goback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingHeader(props: ChatHeaderProps) {
|
||||||
|
const { isMobileScreen, goback } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative flex flex-0 justify-between items-center px-6 py-4 gap-chat-header-gap border-b border-settings-header
|
||||||
|
max-md:h-menu-title-mobile max-md:bg-settings-header-mobile
|
||||||
|
`}
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
{isMobileScreen ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-4 top-[50%] translate-y-[-50%] cursor-pointer"
|
||||||
|
onClick={() => goback()}
|
||||||
|
>
|
||||||
|
<GobackIcon />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex-1
|
||||||
|
max-md:flex max-md:flex-col max-md:items-center max-md:justify-center max-md:gap-0.5 max-md:text
|
||||||
|
md:mr-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
line-clamp-1 cursor-pointer text-text-settings-panel-header-title text-chat-header-title font-common
|
||||||
|
max-md:text-sm-title max-md:h-chat-header-title-mobile max-md:leading-5 !font-medium
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{Locale.Settings.Title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
199
app/containers/Settings/components/SyncConfigModal.tsx
Normal file
199
app/containers/Settings/components/SyncConfigModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { Modal } from "@/app/components/ui-lib";
|
||||||
|
import { useSyncStore } from "@/app/store/sync";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
import { ProviderType } from "@/app/utils/cloud";
|
||||||
|
import { STORAGE_KEY } from "@/app/constant";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import ConnectionIcon from "@/app/icons/connection.svg";
|
||||||
|
import CloudSuccessIcon from "@/app/icons/cloud-success.svg";
|
||||||
|
import CloudFailIcon from "@/app/icons/cloud-fail.svg";
|
||||||
|
import ConfirmIcon from "@/app/icons/confirm.svg";
|
||||||
|
import LoadingIcon from "@/app/icons/three-dots.svg";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Switch from "@/app/components/Switch";
|
||||||
|
import Select from "@/app/components/Select";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
import { useAppConfig } from "@/app/store";
|
||||||
|
|
||||||
|
function CheckButton() {
|
||||||
|
const syncStore = useSyncStore();
|
||||||
|
|
||||||
|
const couldCheck = useMemo(() => {
|
||||||
|
return syncStore.cloudSync();
|
||||||
|
}, [syncStore]);
|
||||||
|
|
||||||
|
const [checkState, setCheckState] = useState<
|
||||||
|
"none" | "checking" | "success" | "failed"
|
||||||
|
>("none");
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
setCheckState("checking");
|
||||||
|
const valid = await syncStore.check();
|
||||||
|
setCheckState(valid ? "success" : "failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!couldCheck) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
text={Locale.Settings.Sync.Config.Modal.Check}
|
||||||
|
bordered
|
||||||
|
onClick={check}
|
||||||
|
icon={
|
||||||
|
checkState === "none" ? (
|
||||||
|
<ConnectionIcon />
|
||||||
|
) : checkState === "checking" ? (
|
||||||
|
<LoadingIcon />
|
||||||
|
) : checkState === "success" ? (
|
||||||
|
<CloudSuccessIcon />
|
||||||
|
) : checkState === "failed" ? (
|
||||||
|
<CloudFailIcon />
|
||||||
|
) : (
|
||||||
|
<ConnectionIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyncConfigModal(props: { onClose?: () => void }) {
|
||||||
|
const syncStore = useSyncStore();
|
||||||
|
const config = useAppConfig();
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
return (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Settings.Sync.Config.Modal.Title}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
actions={[
|
||||||
|
<CheckButton key="check" />,
|
||||||
|
<IconButton
|
||||||
|
key="confirm"
|
||||||
|
onClick={props.onClose}
|
||||||
|
icon={<ConfirmIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.UI.Confirm}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
className="!bg-modal-mask active-new"
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
widgetStyle={{
|
||||||
|
rangeNextLine: isMobileScreen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Sync.Config.SyncType.Title}
|
||||||
|
subTitle={Locale.Settings.Sync.Config.SyncType.SubTitle}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={syncStore.provider}
|
||||||
|
options={Object.entries(ProviderType).map(([k, v]) => ({
|
||||||
|
value: v,
|
||||||
|
label: k,
|
||||||
|
}))}
|
||||||
|
onSelect={(v) => {
|
||||||
|
syncStore.update((config) => (config.provider = v));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Sync.Config.Proxy.Title}
|
||||||
|
subTitle={Locale.Settings.Sync.Config.Proxy.SubTitle}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={syncStore.useProxy}
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.useProxy = e));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
{syncStore.useProxy ? (
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Sync.Config.ProxyUrl.Title}
|
||||||
|
subTitle={Locale.Settings.Sync.Config.ProxyUrl.SubTitle}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={syncStore.proxyUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.proxyUrl = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{syncStore.provider === ProviderType.WebDAV && (
|
||||||
|
<>
|
||||||
|
<ListItem title={Locale.Settings.Sync.Config.WebDav.Endpoint}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={syncStore.webdav.endpoint}
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.webdav.endpoint = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title={Locale.Settings.Sync.Config.WebDav.UserName}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={syncStore.webdav.username}
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.webdav.username = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={Locale.Settings.Sync.Config.WebDav.Password}>
|
||||||
|
<Input
|
||||||
|
value={syncStore.webdav.password}
|
||||||
|
type="password"
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.webdav.password = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncStore.provider === ProviderType.UpStash && (
|
||||||
|
<>
|
||||||
|
<ListItem title={Locale.Settings.Sync.Config.UpStash.Endpoint}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={syncStore.upstash.endpoint}
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.upstash.endpoint = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title={Locale.Settings.Sync.Config.UpStash.UserName}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={syncStore.upstash.username}
|
||||||
|
placeholder={STORAGE_KEY}
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.upstash.username = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={Locale.Settings.Sync.Config.UpStash.Password}>
|
||||||
|
<Input
|
||||||
|
value={syncStore.upstash.apiKey}
|
||||||
|
type="password"
|
||||||
|
onChange={(e) => {
|
||||||
|
syncStore.update((config) => (config.upstash.apiKey = e));
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</ListItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
112
app/containers/Settings/components/SyncItems.tsx
Normal file
112
app/containers/Settings/components/SyncItems.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import ConfigIcon from "@/app/icons/configIcon2.svg";
|
||||||
|
import ExportIcon from "@/app/icons/exportIcon.svg";
|
||||||
|
import ImportIcon from "@/app/icons/importIcon.svg";
|
||||||
|
import SyncIcon from "@/app/icons/syncIcon.svg";
|
||||||
|
|
||||||
|
import { showToast } from "@/app/components/ui-lib";
|
||||||
|
import { useChatStore } from "@/app/store/chat";
|
||||||
|
import { useMaskStore } from "@/app/store/mask";
|
||||||
|
import { usePromptStore } from "@/app/store/prompt";
|
||||||
|
import { useSyncStore } from "@/app/store/sync";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
|
||||||
|
import SyncConfigModal from "./SyncConfigModal";
|
||||||
|
import List, { ListItem } from "@/app/components/List";
|
||||||
|
import Btn from "@/app/components/Btn";
|
||||||
|
import { useAppConfig } from "@/app/store";
|
||||||
|
|
||||||
|
export default function SyncItems() {
|
||||||
|
const syncStore = useSyncStore();
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const promptStore = usePromptStore();
|
||||||
|
const maskStore = useMaskStore();
|
||||||
|
const couldSync = useMemo(() => {
|
||||||
|
return syncStore.cloudSync();
|
||||||
|
}, [syncStore]);
|
||||||
|
|
||||||
|
const { isMobileScreen } = useAppConfig();
|
||||||
|
|
||||||
|
const [showSyncConfigModal, setShowSyncConfigModal] = useState(false);
|
||||||
|
|
||||||
|
const stateOverview = useMemo(() => {
|
||||||
|
const sessions = chatStore.sessions;
|
||||||
|
const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat: sessions.length,
|
||||||
|
message: messageCount,
|
||||||
|
prompt: Object.keys(promptStore.prompts).length,
|
||||||
|
mask: Object.keys(maskStore.masks).length,
|
||||||
|
};
|
||||||
|
}, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
|
||||||
|
|
||||||
|
const textStyle = "!text-sm";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Sync.CloudState}
|
||||||
|
subTitle={
|
||||||
|
syncStore.lastProvider
|
||||||
|
? `${new Date(syncStore.lastSyncTime).toLocaleString()} [${
|
||||||
|
syncStore.lastProvider
|
||||||
|
}]`
|
||||||
|
: Locale.Settings.Sync.NotSyncYet
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Btn
|
||||||
|
onClick={() => {
|
||||||
|
setShowSyncConfigModal(true);
|
||||||
|
}}
|
||||||
|
text={<span className={textStyle}>{Locale.UI.Config}</span>}
|
||||||
|
prefixIcon={isMobileScreen ? undefined : <ConfigIcon />}
|
||||||
|
></Btn>
|
||||||
|
{couldSync && (
|
||||||
|
<Btn
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await syncStore.sync();
|
||||||
|
showToast(Locale.Settings.Sync.Success);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(Locale.Settings.Sync.Fail);
|
||||||
|
console.error("[Sync]", e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
text={<span className={textStyle}>{Locale.UI.Sync}</span>}
|
||||||
|
prefixIcon={<SyncIcon />}
|
||||||
|
></Btn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={Locale.Settings.Sync.LocalState}
|
||||||
|
subTitle={Locale.Settings.Sync.Overview(stateOverview)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Btn
|
||||||
|
onClick={() => {
|
||||||
|
syncStore.export();
|
||||||
|
}}
|
||||||
|
text={<span className={textStyle}>{Locale.UI.Export}</span>}
|
||||||
|
prefixIcon={<ExportIcon />}
|
||||||
|
></Btn>
|
||||||
|
<Btn
|
||||||
|
onClick={async () => {
|
||||||
|
syncStore.import();
|
||||||
|
}}
|
||||||
|
text={<span className={textStyle}>{Locale.UI.Import}</span>}
|
||||||
|
prefixIcon={<ImportIcon />}
|
||||||
|
></Btn>
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{showSyncConfigModal && (
|
||||||
|
<SyncConfigModal onClose={() => setShowSyncConfigModal(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
169
app/containers/Settings/components/UserPromptModal.tsx
Normal file
169
app/containers/Settings/components/UserPromptModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { Prompt, SearchService, usePromptStore } from "@/app/store/prompt";
|
||||||
|
import { Input as Textarea, Modal } from "@/app/components/ui-lib";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import { IconButton } from "@/app/components/button";
|
||||||
|
|
||||||
|
import AddIcon from "@/app/icons/add.svg";
|
||||||
|
import CopyIcon from "@/app/icons/copy.svg";
|
||||||
|
import ClearIcon from "@/app/icons/clear.svg";
|
||||||
|
import EditIcon from "@/app/icons/edit.svg";
|
||||||
|
import EyeIcon from "@/app/icons/eye.svg";
|
||||||
|
|
||||||
|
import styles from "../index.module.scss";
|
||||||
|
import { copyToClipboard } from "@/app/utils";
|
||||||
|
import Input from "@/app/components/Input";
|
||||||
|
|
||||||
|
function EditPromptModal(props: { id: string; onClose: () => void }) {
|
||||||
|
const promptStore = usePromptStore();
|
||||||
|
const prompt = promptStore.get(props.id);
|
||||||
|
|
||||||
|
return prompt ? (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Settings.Prompt.EditModal.Title}
|
||||||
|
onClose={props.onClose}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key=""
|
||||||
|
onClick={props.onClose}
|
||||||
|
text={Locale.UI.Confirm}
|
||||||
|
bordered
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
// className="!bg-modal-mask"
|
||||||
|
>
|
||||||
|
<div className={styles["edit-prompt-modal"]}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={prompt.title}
|
||||||
|
readOnly={!prompt.isUser}
|
||||||
|
className={styles["edit-prompt-title"]}
|
||||||
|
onChange={(e) =>
|
||||||
|
promptStore.updatePrompt(props.id, (prompt) => (prompt.title = e))
|
||||||
|
}
|
||||||
|
></Input>
|
||||||
|
<Textarea
|
||||||
|
value={prompt.content}
|
||||||
|
readOnly={!prompt.isUser}
|
||||||
|
className={styles["edit-prompt-content"]}
|
||||||
|
rows={10}
|
||||||
|
onInput={(e) =>
|
||||||
|
promptStore.updatePrompt(
|
||||||
|
props.id,
|
||||||
|
(prompt) => (prompt.content = e.currentTarget.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
></Textarea>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserPromptModal(props: { onClose?: () => void }) {
|
||||||
|
const promptStore = usePromptStore();
|
||||||
|
const userPrompts = promptStore.getUserPrompts();
|
||||||
|
const builtinPrompts = SearchService.builtinPrompts;
|
||||||
|
const allPrompts = userPrompts.concat(builtinPrompts);
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
|
||||||
|
const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
|
||||||
|
|
||||||
|
const [editingPromptId, setEditingPromptId] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchInput.length > 0) {
|
||||||
|
const searchResult = SearchService.search(searchInput);
|
||||||
|
setSearchPrompts(searchResult);
|
||||||
|
} else {
|
||||||
|
setSearchPrompts([]);
|
||||||
|
}
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-mask">
|
||||||
|
<Modal
|
||||||
|
title={Locale.Settings.Prompt.Modal.Title}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
actions={[
|
||||||
|
<IconButton
|
||||||
|
key="add"
|
||||||
|
onClick={() => {
|
||||||
|
const promptId = promptStore.add({
|
||||||
|
id: nanoid(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
title: "Empty Prompt",
|
||||||
|
content: "Empty Prompt Content",
|
||||||
|
});
|
||||||
|
setEditingPromptId(promptId);
|
||||||
|
}}
|
||||||
|
icon={<AddIcon />}
|
||||||
|
bordered
|
||||||
|
text={Locale.Settings.Prompt.Modal.Add}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
// className="!bg-modal-mask"
|
||||||
|
>
|
||||||
|
<div className={styles["user-prompt-modal"]}>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className={styles["user-prompt-search"]}
|
||||||
|
placeholder={Locale.Settings.Prompt.Modal.Search}
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e)}
|
||||||
|
></Input>
|
||||||
|
|
||||||
|
<div className={styles["user-prompt-list"]}>
|
||||||
|
{prompts.map((v, _) => (
|
||||||
|
<div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
|
||||||
|
<div className={styles["user-prompt-header"]}>
|
||||||
|
<div className={styles["user-prompt-title"]}>{v.title}</div>
|
||||||
|
<div className={styles["user-prompt-content"] + " one-line"}>
|
||||||
|
{v.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles["user-prompt-buttons"]}>
|
||||||
|
{v.isUser && (
|
||||||
|
<IconButton
|
||||||
|
icon={<ClearIcon />}
|
||||||
|
className={styles["user-prompt-button"]}
|
||||||
|
onClick={() => promptStore.remove(v.id!)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{v.isUser ? (
|
||||||
|
<IconButton
|
||||||
|
icon={<EditIcon />}
|
||||||
|
className={styles["user-prompt-button"]}
|
||||||
|
onClick={() => setEditingPromptId(v.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon={<EyeIcon />}
|
||||||
|
className={styles["user-prompt-button"]}
|
||||||
|
onClick={() => setEditingPromptId(v.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={<CopyIcon />}
|
||||||
|
className={styles["user-prompt-button"]}
|
||||||
|
onClick={() => copyToClipboard(v.content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{editingPromptId !== undefined && (
|
||||||
|
<EditPromptModal
|
||||||
|
id={editingPromptId!}
|
||||||
|
onClose={() => setEditingPromptId(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
69
app/containers/Settings/index.module.scss
Normal file
69
app/containers/Settings/index.module.scss
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
.avatar {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-prompt-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.edit-prompt-title {
|
||||||
|
max-width: unset;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.edit-prompt-content {
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-prompt-modal {
|
||||||
|
min-height: 40vh;
|
||||||
|
|
||||||
|
.user-prompt-search {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: var(--gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-prompt-list {
|
||||||
|
border: var(--border-in-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.user-prompt-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: var(--border-in-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-prompt-header {
|
||||||
|
max-width: calc(100% - 100px);
|
||||||
|
|
||||||
|
.user-prompt-title {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 2;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.user-prompt-content {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-prompt-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 2px;
|
||||||
|
|
||||||
|
.user-prompt-button {
|
||||||
|
//height: 100%;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
98
app/containers/Settings/index.tsx
Normal file
98
app/containers/Settings/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
import Locale from "@/app/locales";
|
||||||
|
import MenuLayout from "@/app/components/MenuLayout";
|
||||||
|
|
||||||
|
import Panel from "./SettingPanel";
|
||||||
|
|
||||||
|
import GotoIcon from "@/app/icons/goto.svg";
|
||||||
|
import { useAppConfig } from "@/app/store";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const list = [
|
||||||
|
{
|
||||||
|
id: Locale.Settings.GeneralSettings,
|
||||||
|
title: Locale.Settings.GeneralSettings,
|
||||||
|
icon: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Locale.Settings.ModelSettings,
|
||||||
|
title: Locale.Settings.ModelSettings,
|
||||||
|
icon: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Locale.Settings.DataSettings,
|
||||||
|
title: Locale.Settings.DataSettings,
|
||||||
|
icon: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default MenuLayout(function SettingList(props) {
|
||||||
|
const { setShowPanel, setExternalProps } = props;
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState(list[0].id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExternalProps?.(list[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-md:h-[100%] max-md:mx-[-1rem] max-md:py-6 max-md:px-4 max-md:bg-settings-menu-mobile
|
||||||
|
md:pt-7
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div data-tauri-drag-region>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center justify-between
|
||||||
|
max-md:h-menu-title-mobile
|
||||||
|
md:pb-5 md:px-4
|
||||||
|
`}
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<div className="text-setting-title text-text-settings-menu-title font-common !font-bold">
|
||||||
|
{Locale.Settings.Title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-2 overflow-y-auto overflow-x-hidden w-[100%]`}
|
||||||
|
>
|
||||||
|
{list.map((i) => (
|
||||||
|
<div
|
||||||
|
key={i.id}
|
||||||
|
className={`
|
||||||
|
p-4 font-common text-setting-items font-normal text-text-settings-menu-item-title
|
||||||
|
cursor-pointer
|
||||||
|
border
|
||||||
|
rounded-md
|
||||||
|
border-transparent
|
||||||
|
${
|
||||||
|
selected === i.id && !isMobileScreen
|
||||||
|
? `!bg-chat-menu-session-selected !border-chat-menu-session-selected !font-medium`
|
||||||
|
: `hover:bg-chat-menu-session-unselected hover:border-chat-menu-session-unselected`
|
||||||
|
}
|
||||||
|
|
||||||
|
flex justify-between items-center
|
||||||
|
max-md:bg-settings-menu-item-mobile
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPanel?.(true);
|
||||||
|
setExternalProps?.(i);
|
||||||
|
setSelected(i.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i.title}
|
||||||
|
{i.icon}
|
||||||
|
{isMobileScreen && <GotoIcon />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, Panel);
|
130
app/containers/Sidebar/index.tsx
Normal file
130
app/containers/Sidebar/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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 useHotKey from "@/app/hooks/useHotKey";
|
||||||
|
import ActionsBar from "@/app/components/ActionsBar";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function SideBar(props: { className?: string }) {
|
||||||
|
// const navigate = useNavigate();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const config = useAppConfig();
|
||||||
|
const { isMobileScreen } = config;
|
||||||
|
|
||||||
|
useHotKey();
|
||||||
|
|
||||||
|
let selectedTab: string;
|
||||||
|
switch (pathname) {
|
||||||
|
case Path.Masks:
|
||||||
|
case Path.NewChat:
|
||||||
|
selectedTab = Path.Masks;
|
||||||
|
break;
|
||||||
|
case Path.Settings:
|
||||||
|
selectedTab = Path.Settings;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
selectedTab = Path.Home;
|
||||||
|
}
|
||||||
|
console.log("======", selectedTab);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
router.push(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.dontShowMaskSplashScreen !== true) {
|
||||||
|
// navigate(Path.NewChat, { state: { fromHome: true } });
|
||||||
|
router.push(Path.NewChat);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// navigate(Path.Masks, { state: { fromHome: true } });
|
||||||
|
router.push(Path.Masks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
groups={{
|
||||||
|
normal: [
|
||||||
|
[Path.Home, Path.Masks],
|
||||||
|
["github", Path.Settings],
|
||||||
|
],
|
||||||
|
mobile: [[Path.Home, Path.Masks, Path.Settings]],
|
||||||
|
}}
|
||||||
|
selected={selectedTab}
|
||||||
|
className={`
|
||||||
|
max-md:bg-sidebar-mobile max-md:h-mobile max-md:justify-around
|
||||||
|
2xl:px-5 xl:px-4 md:px-2 md:py-6 md:flex-col
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
146
app/containers/index.tsx
Normal file
146
app/containers/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
require("../polyfill");
|
||||||
|
|
||||||
|
import { HashRouter as Router, Routes, Route } from "react-router-dom";
|
||||||
|
import { useState, useEffect, useLayoutEffect } from "react";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Path } from "@/app/constant";
|
||||||
|
import { ErrorBoundary } from "@/app/components/error";
|
||||||
|
import { getISOLang } from "@/app/locales";
|
||||||
|
import { useSwitchTheme } from "@/app/hooks/useSwitchTheme";
|
||||||
|
import { AuthPage } from "@/app/components/auth";
|
||||||
|
import { getClientConfig } from "@/app/config/client";
|
||||||
|
import { useAccessStore, useAppConfig } from "@/app/store";
|
||||||
|
import { useLoadData } from "@/app/hooks/useLoadData";
|
||||||
|
import Loading from "@/app/components/Loading";
|
||||||
|
import Screen from "@/app/components/Screen";
|
||||||
|
import { SideBar } from "./Sidebar";
|
||||||
|
import GlobalLoading from "@/app/components/GlobalLoading";
|
||||||
|
import { MOBILE_MAX_WIDTH } from "../hooks/useListenWinResize";
|
||||||
|
|
||||||
|
const Settings = dynamic(
|
||||||
|
async () => await import("@/app/containers/Settings"),
|
||||||
|
{
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Chat = dynamic(async () => await import("@/app/containers/Chat"), {
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const NewChat = dynamic(
|
||||||
|
async () => (await import("@/app/components/new-chat")).NewChat,
|
||||||
|
{
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const MaskPage = dynamic(
|
||||||
|
async () => (await import("@/app/components/mask")).MaskPage,
|
||||||
|
{
|
||||||
|
loading: () => <Loading noLogo />,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function useHtmlLang() {
|
||||||
|
useEffect(() => {
|
||||||
|
const lang = getISOLang();
|
||||||
|
const htmlLang = document.documentElement.lang;
|
||||||
|
|
||||||
|
if (lang !== htmlLang) {
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useHasHydrated = () => {
|
||||||
|
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return hasHydrated;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAsyncGoogleFont = () => {
|
||||||
|
const linkEl = document.createElement("link");
|
||||||
|
const proxyFontUrl = "/google-fonts";
|
||||||
|
const remoteFontUrl = "https://fonts.googleapis.com";
|
||||||
|
const googleFontUrl =
|
||||||
|
getClientConfig()?.buildMode === "export" ? remoteFontUrl : proxyFontUrl;
|
||||||
|
linkEl.rel = "stylesheet";
|
||||||
|
linkEl.href =
|
||||||
|
googleFontUrl +
|
||||||
|
"/css2?family=" +
|
||||||
|
encodeURIComponent("Noto Sans:wght@300;400;700;900") +
|
||||||
|
"&display=swap";
|
||||||
|
document.head.appendChild(linkEl);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
useSwitchTheme();
|
||||||
|
useLoadData();
|
||||||
|
useHtmlLang();
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[Config] got config from build time", getClientConfig());
|
||||||
|
useAccessStore.getState().fetch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
loadAsyncGoogleFont();
|
||||||
|
config.update(
|
||||||
|
(config) =>
|
||||||
|
(config.isMobileScreen = window.innerWidth <= MOBILE_MAX_WIDTH),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!useHasHydrated()) {
|
||||||
|
return <GlobalLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Router>
|
||||||
|
<Screen noAuth={<AuthPage />} sidebar={<SideBar />}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Routes>
|
||||||
|
<Route path={Path.Home} element={<Chat />} />
|
||||||
|
<Route
|
||||||
|
path={Path.NewChat}
|
||||||
|
element={
|
||||||
|
<NewChat
|
||||||
|
className={`
|
||||||
|
md:w-[100%] px-1
|
||||||
|
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
|
||||||
|
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Path.Masks}
|
||||||
|
element={
|
||||||
|
<MaskPage
|
||||||
|
className={`
|
||||||
|
md:w-[100%]
|
||||||
|
${config.theme === "dark" ? "bg-[var(--white)]" : "bg-gray-50"}
|
||||||
|
${config.isMobileScreen ? "pb-chat-panel-mobile" : ""}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path={Path.Chat} element={<Chat />} />
|
||||||
|
<Route path={Path.Settings} element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Screen>
|
||||||
|
</Router>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
BIN
app/fonts/Satoshi-Variable.ttf
Normal file
BIN
app/fonts/Satoshi-Variable.ttf
Normal file
Binary file not shown.
BIN
app/fonts/Satoshi-Variable.woff
Normal file
BIN
app/fonts/Satoshi-Variable.woff
Normal file
Binary file not shown.
BIN
app/fonts/Satoshi-Variable.woff2
Normal file
BIN
app/fonts/Satoshi-Variable.woff2
Normal file
Binary file not shown.
44
app/hooks/useDeviceInfo.ts
Normal file
44
app/hooks/useDeviceInfo.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// retur user device info
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useDeviceInfo() {
|
||||||
|
const [deviceInfo, setDeviceInfo] = useState({});
|
||||||
|
|
||||||
|
const [systemInfo, setSystemInfo] = useState<string | null>(null);
|
||||||
|
const [deviceType, setDeviceType] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
|
||||||
|
if (/iphone|ipad|ipod/.test(userAgent)) {
|
||||||
|
setSystemInfo("iOS");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => {
|
||||||
|
setDeviceInfo({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.innerWidth < 600) {
|
||||||
|
setDeviceType("mobile");
|
||||||
|
} else {
|
||||||
|
setDeviceType("desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
windowSize: deviceInfo,
|
||||||
|
systemInfo,
|
||||||
|
deviceType,
|
||||||
|
};
|
||||||
|
}
|
59
app/hooks/useDrag.ts
Normal file
59
app/hooks/useDrag.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { RefObject, useRef } from "react";
|
||||||
|
|
||||||
|
export default function useDrag(options: {
|
||||||
|
customDragMove: (nextWidth: number, start?: number) => void;
|
||||||
|
customToggle: () => void;
|
||||||
|
customLimit?: (x: number, start?: number) => number;
|
||||||
|
customDragEnd?: (nextWidth: number, start?: number) => void;
|
||||||
|
}) {
|
||||||
|
const { customDragMove, customToggle, customLimit, customDragEnd } =
|
||||||
|
options || {};
|
||||||
|
const limit = customLimit;
|
||||||
|
|
||||||
|
const startX = useRef(0);
|
||||||
|
const lastUpdateTime = useRef(Date.now());
|
||||||
|
|
||||||
|
const toggleSideBar = customToggle;
|
||||||
|
|
||||||
|
const onDragMove = customDragMove;
|
||||||
|
|
||||||
|
const onDragStart = (e: MouseEvent) => {
|
||||||
|
// Remembers the initial width each time the mouse is pressed
|
||||||
|
startX.current = e.clientX;
|
||||||
|
const dragStartTime = Date.now();
|
||||||
|
|
||||||
|
const handleDragMove = (e: MouseEvent) => {
|
||||||
|
if (Date.now() < lastUpdateTime.current + 20) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUpdateTime.current = Date.now();
|
||||||
|
const d = e.clientX - startX.current;
|
||||||
|
const nextWidth = limit?.(d, startX.current) ?? d;
|
||||||
|
|
||||||
|
onDragMove(nextWidth, startX.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (e: MouseEvent) => {
|
||||||
|
// In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
|
||||||
|
window.removeEventListener("pointermove", handleDragMove);
|
||||||
|
window.removeEventListener("pointerup", handleDragEnd);
|
||||||
|
|
||||||
|
// if user click the drag icon, should toggle the sidebar
|
||||||
|
const shouldFireClick = Date.now() - dragStartTime < 300;
|
||||||
|
if (shouldFireClick) {
|
||||||
|
toggleSideBar();
|
||||||
|
} else {
|
||||||
|
const d = e.clientX - startX.current;
|
||||||
|
const nextWidth = limit?.(d, startX.current) ?? d;
|
||||||
|
customDragEnd?.(nextWidth, startX.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handleDragMove);
|
||||||
|
window.addEventListener("pointerup", handleDragEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onDragStart,
|
||||||
|
};
|
||||||
|
}
|
21
app/hooks/useHotKey.ts
Normal file
21
app/hooks/useHotKey.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useChatStore } from "../store/chat";
|
||||||
|
|
||||||
|
export default function useHotKey() {
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.altKey || e.ctrlKey) {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
chatStore.nextSession(-1);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
chatStore.nextSession(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
});
|
||||||
|
}
|
55
app/hooks/useListenWinResize.ts
Normal file
55
app/hooks/useListenWinResize.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useWindowSize } from "@/app/hooks/useWindowSize";
|
||||||
|
import {
|
||||||
|
WINDOW_WIDTH_2XL,
|
||||||
|
WINDOW_WIDTH_LG,
|
||||||
|
WINDOW_WIDTH_MD,
|
||||||
|
WINDOW_WIDTH_SM,
|
||||||
|
WINDOW_WIDTH_XL,
|
||||||
|
DEFAULT_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
} from "@/app/constant";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
import { updateGlobalCSSVars } from "@/app/utils/client";
|
||||||
|
|
||||||
|
export const MOBILE_MAX_WIDTH = 768;
|
||||||
|
|
||||||
|
const widths = [
|
||||||
|
WINDOW_WIDTH_2XL,
|
||||||
|
WINDOW_WIDTH_XL,
|
||||||
|
WINDOW_WIDTH_LG,
|
||||||
|
WINDOW_WIDTH_MD,
|
||||||
|
WINDOW_WIDTH_SM,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function useListenWinResize() {
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
useWindowSize((size) => {
|
||||||
|
let nextSidebar = config.sidebarWidth;
|
||||||
|
if (!nextSidebar) {
|
||||||
|
switch (widths.find((w) => w < size.width)) {
|
||||||
|
case WINDOW_WIDTH_2XL:
|
||||||
|
nextSidebar = MAX_SIDEBAR_WIDTH;
|
||||||
|
break;
|
||||||
|
case WINDOW_WIDTH_XL:
|
||||||
|
case WINDOW_WIDTH_LG:
|
||||||
|
nextSidebar = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
break;
|
||||||
|
case WINDOW_WIDTH_MD:
|
||||||
|
case WINDOW_WIDTH_SM:
|
||||||
|
default:
|
||||||
|
nextSidebar = MIN_SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { menuWidth } = updateGlobalCSSVars(nextSidebar);
|
||||||
|
|
||||||
|
config.update((config) => {
|
||||||
|
config.sidebarWidth = menuWidth;
|
||||||
|
});
|
||||||
|
config.update((config) => {
|
||||||
|
config.isMobileScreen = size.width <= MOBILE_MAX_WIDTH;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
25
app/hooks/useLoadData.ts
Normal file
25
app/hooks/useLoadData.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppConfig } from "@/app/store/config";
|
||||||
|
import { ClientApi } from "@/app/client/api";
|
||||||
|
import { ModelProvider } from "@/app/constant";
|
||||||
|
import { identifyDefaultClaudeModel } from "@/app/utils/checkers";
|
||||||
|
|
||||||
|
export function useLoadData() {
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
var api: ClientApi;
|
||||||
|
if (config.modelConfig.model.startsWith("gemini")) {
|
||||||
|
api = new ClientApi(ModelProvider.GeminiPro);
|
||||||
|
} else if (identifyDefaultClaudeModel(config.modelConfig.model)) {
|
||||||
|
api = new ClientApi(ModelProvider.Claude);
|
||||||
|
} else {
|
||||||
|
api = new ClientApi(ModelProvider.GPT);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const models = await api.llm.models();
|
||||||
|
config.mergeModels(models);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
8
app/hooks/useMobileScreen.ts
Normal file
8
app/hooks/useMobileScreen.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useWindowSize } from "@/app/hooks/useWindowSize";
|
||||||
|
import { MOBILE_MAX_WIDTH } from "@/app/hooks/useListenWinResize";
|
||||||
|
|
||||||
|
export default function useMobileScreen() {
|
||||||
|
const { width } = useWindowSize();
|
||||||
|
|
||||||
|
return width <= MOBILE_MAX_WIDTH;
|
||||||
|
}
|
72
app/hooks/usePaste.ts
Normal file
72
app/hooks/usePaste.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
104
app/hooks/useRelativePosition.ts
Normal file
104
app/hooks/useRelativePosition.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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: null },
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
39
app/hooks/useRows.ts
Normal file
39
app/hooks/useRows.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { autoGrowTextArea } from "../utils";
|
||||||
|
import { useAppConfig } from "../store";
|
||||||
|
|
||||||
|
export default function useRows({
|
||||||
|
inputRef,
|
||||||
|
}: {
|
||||||
|
inputRef: React.RefObject<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,
|
||||||
|
};
|
||||||
|
}
|
61
app/hooks/useScrollToBottom.ts
Normal file
61
app/hooks/useScrollToBottom.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
29
app/hooks/useShowPromptHint.ts
Normal file
29
app/hooks/useShowPromptHint.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
49
app/hooks/useSubmitHandler.ts
Normal file
49
app/hooks/useSubmitHandler.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { SubmitKey, useAppConfig } from "../store/config";
|
||||||
|
|
||||||
|
export default function useSubmitHandler() {
|
||||||
|
const config = useAppConfig();
|
||||||
|
const submitKey = config.submitKey;
|
||||||
|
const isComposing = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCompositionStart = () => {
|
||||||
|
isComposing.current = true;
|
||||||
|
};
|
||||||
|
const onCompositionEnd = () => {
|
||||||
|
isComposing.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("compositionstart", onCompositionStart);
|
||||||
|
window.addEventListener("compositionend", onCompositionEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("compositionstart", onCompositionStart);
|
||||||
|
window.removeEventListener("compositionend", onCompositionEnd);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldSubmit = (e: React.KeyboardEvent<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,
|
||||||
|
};
|
||||||
|
}
|
48
app/hooks/useSwitchTheme.ts
Normal file
48
app/hooks/useSwitchTheme.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { Theme, useAppConfig } from "@/app/store/config";
|
||||||
|
import { getCSSVar } from "../utils";
|
||||||
|
|
||||||
|
const DARK_CLASS = "dark-new";
|
||||||
|
const LIGHT_CLASS = "light-new";
|
||||||
|
|
||||||
|
export function useSwitchTheme() {
|
||||||
|
const config = useAppConfig();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
document.body.classList.remove(DARK_CLASS);
|
||||||
|
document.body.classList.remove(LIGHT_CLASS);
|
||||||
|
|
||||||
|
if (config.theme === Theme.Dark) {
|
||||||
|
document.body.classList.add(DARK_CLASS);
|
||||||
|
} else {
|
||||||
|
document.body.classList.add(LIGHT_CLASS);
|
||||||
|
}
|
||||||
|
}, [config.theme]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
document.body.classList.remove("light");
|
||||||
|
document.body.classList.remove("dark");
|
||||||
|
|
||||||
|
if (config.theme === "dark") {
|
||||||
|
document.body.classList.add("dark");
|
||||||
|
} else if (config.theme === "light") {
|
||||||
|
document.body.classList.add("light");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaDescriptionDark = document.querySelector(
|
||||||
|
'meta[name="theme-color"][media*="dark"]',
|
||||||
|
);
|
||||||
|
const metaDescriptionLight = document.querySelector(
|
||||||
|
'meta[name="theme-color"][media*="light"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.theme === "auto") {
|
||||||
|
metaDescriptionDark?.setAttribute("content", "#151515");
|
||||||
|
metaDescriptionLight?.setAttribute("content", "#fafafa");
|
||||||
|
} else {
|
||||||
|
const themeColor = getCSSVar("--theme-color");
|
||||||
|
metaDescriptionDark?.setAttribute("content", themeColor);
|
||||||
|
metaDescriptionLight?.setAttribute("content", themeColor);
|
||||||
|
}
|
||||||
|
}, [config.theme]);
|
||||||
|
}
|
69
app/hooks/useUploadImage.ts
Normal file
69
app/hooks/useUploadImage.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
47
app/hooks/useWindowSize.ts
Normal file
47
app/hooks/useWindowSize.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, 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({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSize({
|
||||||
|
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;
|
||||||
|
}
|
5
app/icons/addCircle.svg
Normal file
5
app/icons/addCircle.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 1.1 KiB |
3
app/icons/addIcon.svg
Normal file
3
app/icons/addIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 839 B |
9
app/icons/assistantActive.svg
Normal file
9
app/icons/assistantActive.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 1021 B |
3
app/icons/assistantInactive.svg
Normal file
3
app/icons/assistantInactive.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 792 B |
9
app/icons/assistantMobileActive.svg
Normal file
9
app/icons/assistantMobileActive.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 1.0 KiB |
7
app/icons/assistantMobileInactive.svg
Normal file
7
app/icons/assistantMobileInactive.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 1.8 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user