feat: seperate chat page

This commit is contained in:
butterfly
2024-04-12 10:57:57 +08:00
parent 67acc38a1f
commit 0a8e5d6734
56 changed files with 3868 additions and 25 deletions

View File

@@ -0,0 +1,83 @@
import {
DEFAULT_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
NARROW_SIDEBAR_WIDTH,
} from "@/app/constant";
import { useAppConfig } from "../store/config";
import { useEffect, useRef } from "react";
import useMobileScreen from "@/app/hooks/useMobileScreen";
export default function useDragSideBar() {
const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
const config = useAppConfig();
const startX = useRef(0);
const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
const lastUpdateTime = useRef(Date.now());
const toggleSideBar = () => {
config.update((config) => {
if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
} else {
config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
}
});
};
const onDragStart = (e: MouseEvent) => {
// Remembers the initial width each time the mouse is pressed
startX.current = e.clientX;
startDragWidth.current = config.sidebarWidth;
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(startDragWidth.current + d);
config.update((config) => {
if (nextWidth < MIN_SIDEBAR_WIDTH) {
config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
} else {
config.sidebarWidth = nextWidth;
}
});
};
const handleDragEnd = () => {
// 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();
}
};
window.addEventListener("pointermove", handleDragMove);
window.addEventListener("pointerup", handleDragEnd);
};
const isMobileScreen = useMobileScreen();
const shouldNarrow =
!isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
useEffect(() => {
const barWidth = shouldNarrow
? NARROW_SIDEBAR_WIDTH
: limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
}, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
return {
onDragStart,
shouldNarrow,
};
}

21
app/hooks/useHotKey.ts Normal file
View 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);
});
}

View File

@@ -0,0 +1,12 @@
import { useLayoutEffect } from "react";
import { useWindowSize } from "../utils";
export const MOBILE_MAX_WIDTH = 600;
export default function useMobileScreen() {
const { width } = useWindowSize();
const isMobile = width <= MOBILE_MAX_WIDTH;
return isMobile;
}

72
app/hooks/usePaste.ts Normal file
View 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,
};
}

View File

@@ -0,0 +1,33 @@
import { RefObject, useEffect, useState } from "react";
export default function useScrollToBottom(
scrollRef: RefObject<HTMLDivElement>,
detach: boolean = false,
) {
// for auto-scroll
const [autoScroll, setAutoScroll] = useState(true);
function scrollDomToBottom() {
const dom = scrollRef.current;
if (dom) {
requestAnimationFrame(() => {
setAutoScroll(true);
dom.scrollTo(0, dom.scrollHeight);
});
}
}
// auto scroll
useEffect(() => {
if (autoScroll && !detach) {
scrollDomToBottom();
}
});
return {
scrollRef,
autoScroll,
setAutoScroll,
scrollDomToBottom,
};
}

View 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,
};
}

View 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,
};
}