Merge pull request #2 from Yidadaa/main

pull request new
This commit is contained in:
JackHans
2023-04-14 12:47:46 +08:00
committed by GitHub
61 changed files with 1344 additions and 292 deletions

View File

@@ -49,4 +49,7 @@
.icon-button-text {
margin-left: 5px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -96,7 +96,7 @@ export function ChatList() {
index={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={chatStore.deleteSession}
onDelete={() => chatStore.deleteSession(i)}
/>
))}
{provided.placeholder}

View File

@@ -1,5 +1,29 @@
@import "../styles/animation.scss";
.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: all ease 0.3s;
margin-bottom: 10px;
align-items: center;
&:not(:last-child) {
margin-right: 5px;
}
}
}
.prompt-toast {
position: absolute;
bottom: -50px;

View File

@@ -3,7 +3,7 @@ import { memo, useState, useRef, useEffect, useLayoutEffect } from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
import ExportIcon from "../icons/export.svg";
import ExportIcon from "../icons/share.svg";
import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg";
@@ -11,6 +11,13 @@ import LoadingIcon from "../icons/three-dots.svg";
import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import MaxIcon from "../icons/max.svg";
import MinIcon from "../icons/min.svg";
import LightIcon from "../icons/light.svg";
import DarkIcon from "../icons/dark.svg";
import AutoIcon from "../icons/auto.svg";
import BottomIcon from "../icons/bottom.svg";
import {
Message,
@@ -19,6 +26,8 @@ import {
BOT_HELLO,
ROLES,
createMessage,
useAccessStore,
Theme,
} from "../store";
import {
@@ -28,6 +37,7 @@ import {
isMobileScreen,
selectOrCopy,
autoGrowTextArea,
getCSSVar,
} from "../utils";
import dynamic from "next/dynamic";
@@ -57,7 +67,11 @@ export function Avatar(props: { role: Message["role"] }) {
const config = useChatStore((state) => state.config);
if (props.role !== "user") {
return <BotIcon className={styles["user-avtar"]} />;
return (
<div className="no-dark">
<BotIcon className={styles["user-avtar"]} />
</div>
);
}
return (
@@ -313,22 +327,78 @@ function useScrollToBottom() {
// for auto-scroll
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const scrollToBottom = () => {
const dom = scrollRef.current;
if (dom) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
};
// auto scroll
useLayoutEffect(() => {
const dom = scrollRef.current;
if (dom && autoScroll) {
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 1);
}
autoScroll && scrollToBottom();
});
return {
scrollRef,
autoScroll,
setAutoScroll,
scrollToBottom,
};
}
export function ChatActions(props: {
showPromptModal: () => void;
scrollToBottom: () => void;
hitBottom: boolean;
}) {
const chatStore = useChatStore();
const theme = chatStore.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];
chatStore.updateConfig((config) => (config.theme = nextTheme));
}
return (
<div className={chatStyle["chat-input-actions"]}>
{!props.hitBottom && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.scrollToBottom}
>
<BottomIcon />
</div>
)}
{props.hitBottom && (
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={props.showPromptModal}
>
<BrainIcon />
</div>
)}
<div
className={`${chatStyle["chat-input-action"]} clickable`}
onClick={nextTheme}
>
{theme === Theme.Auto ? (
<AutoIcon />
) : theme === Theme.Light ? (
<LightIcon />
) : theme === Theme.Dark ? (
<DarkIcon />
) : null}
</div>
</div>
);
}
export function Chat(props: {
showSideBar?: () => void;
sideBarShowing?: boolean;
@@ -347,7 +417,7 @@ export function Chat(props: {
const [beforeInput, setBeforeInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { submitKey, shouldSubmit } = useSubmitHandler();
const { scrollRef, setAutoScroll } = useScrollToBottom();
const { scrollRef, setAutoScroll, scrollToBottom } = useScrollToBottom();
const [hitBottom, setHitBottom] = useState(false);
const onChatBodyScroll = (e: HTMLElement) => {
@@ -485,11 +555,17 @@ export function Chat(props: {
const context: RenderMessage[] = session.context.slice();
const accessStore = useAccessStore();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
context.push(BOT_HELLO);
const copiedHello = Object.assign({}, BOT_HELLO);
if (!accessStore.isAuthorized()) {
copiedHello.content = Locale.Error.Unauthorized;
}
context.push(copiedHello);
}
// preview messages
@@ -584,6 +660,19 @@ export function Chat(props: {
}}
/>
</div>
{!isMobileScreen() && (
<div className={styles["window-action-button"]}>
<IconButton
icon={chatStore.config.tightBorder ? <MinIcon /> : <MaxIcon />}
bordered
onClick={() => {
chatStore.updateConfig(
(config) => (config.tightBorder = !config.tightBorder),
);
}}
/>
</div>
)}
</div>
<PromptToast
@@ -650,22 +739,20 @@ export function Chat(props: {
</div>
</div>
)}
{(message.preview || message.content.length === 0) &&
!isUser ? (
<LoadingIcon />
) : (
<div
className="markdown-body"
style={{ fontSize: `${fontSize}px` }}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen()) return;
setUserInput(message.content);
}}
>
<Markdown content={message.content} />
</div>
)}
<Markdown
content={message.content}
loading={
(message.preview || message.content.length === 0) &&
!isUser
}
onContextMenu={(e) => onRightClick(e, message)}
onDoubleClickCapture={() => {
if (!isMobileScreen()) return;
setUserInput(message.content);
}}
fontSize={fontSize}
parentRef={scrollRef}
/>
</div>
{!isUser && !message.preview && (
<div className={styles["chat-message-actions"]}>
@@ -682,6 +769,12 @@ export function Chat(props: {
<div className={styles["chat-input-panel"]}>
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
<ChatActions
showPromptModal={() => setShowPromptModal(true)}
scrollToBottom={scrollToBottom}
hitBottom={hitBottom}
/>
<div className={styles["chat-input-panel-inner"]}>
<textarea
ref={inputRef}

View File

@@ -10,7 +10,7 @@
background-color: var(--white);
min-width: 600px;
min-height: 480px;
max-width: 900px;
max-width: 1200px;
display: flex;
overflow: hidden;
@@ -36,6 +36,7 @@
max-height: var(--full-height);
border-radius: 0;
border: 0;
}
}
@@ -48,6 +49,27 @@
display: flex;
flex-direction: column;
box-shadow: inset -2px 0px 2px 0px rgb(0, 0, 0, 0.05);
position: relative;
transition: width ease 0.1s;
}
.sidebar-drag {
$width: 10px;
position: absolute;
top: 0;
right: 0;
height: 100%;
width: $width;
background-color: var(--black);
cursor: ew-resize;
opacity: 0;
transition: all ease 0.3s;
&:hover,
&:active {
opacity: 0.2;
}
}
.window-content {
@@ -177,10 +199,11 @@
margin-top: 8px;
}
.chat-item-count {
}
.chat-item-count,
.chat-item-date {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tail {
@@ -208,6 +231,7 @@
flex: 1;
overflow: auto;
padding: 20px;
padding-bottom: 40px;
position: relative;
}
@@ -332,11 +356,16 @@
}
.chat-input-panel {
position: relative;
width: 100%;
padding: 20px;
padding-top: 5px;
padding-top: 10px;
box-sizing: border-box;
flex-direction: column;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-top: var(--border-in-light);
box-shadow: var(--card-shadow);
}
@mixin single-line {
@@ -436,6 +465,7 @@
.export-content {
white-space: break-spaces;
padding: 10px !important;
}
.loading-content {

View File

@@ -2,7 +2,7 @@
require("../polyfill");
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { IconButton } from "./button";
import styles from "./home.module.scss";
@@ -17,7 +17,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import CloseIcon from "../icons/close.svg";
import { useChatStore } from "../store";
import { isMobileScreen } from "../utils";
import { getCSSVar, isMobileScreen } from "../utils";
import Locale from "../locales";
import { Chat } from "./chat";
@@ -66,15 +66,60 @@ function useSwitchTheme() {
metaDescriptionDark?.setAttribute("content", "#151515");
metaDescriptionLight?.setAttribute("content", "#fafafa");
} else {
const themeColor = getComputedStyle(document.body)
.getPropertyValue("--theme-color")
.trim();
const themeColor = getCSSVar("--themeColor");
metaDescriptionDark?.setAttribute("content", themeColor);
metaDescriptionLight?.setAttribute("content", themeColor);
}
}, [config.theme]);
}
function useDragSideBar() {
const limit = (x: number) => Math.min(500, Math.max(220, x));
const chatStore = useChatStore();
const startX = useRef(0);
const startDragWidth = useRef(chatStore.config.sidebarWidth ?? 300);
const lastUpdateTime = useRef(Date.now());
const handleMouseMove = useRef((e: MouseEvent) => {
if (Date.now() < lastUpdateTime.current + 100) {
return;
}
lastUpdateTime.current = Date.now();
const d = e.clientX - startX.current;
const nextWidth = limit(startDragWidth.current + d);
chatStore.updateConfig((config) => (config.sidebarWidth = nextWidth));
});
const handleMouseUp = useRef(() => {
startDragWidth.current = chatStore.config.sidebarWidth ?? 300;
window.removeEventListener("mousemove", handleMouseMove.current);
window.removeEventListener("mouseup", handleMouseUp.current);
});
const onDragMouseDown = (e: MouseEvent) => {
startX.current = e.clientX;
window.addEventListener("mousemove", handleMouseMove.current);
window.addEventListener("mouseup", handleMouseUp.current);
};
useEffect(() => {
if (isMobileScreen()) {
return;
}
document.documentElement.style.setProperty(
"--sidebar-width",
`${limit(chatStore.config.sidebarWidth ?? 300)}px`,
);
}, [chatStore.config.sidebarWidth]);
return {
onDragMouseDown,
};
}
const useHasHydrated = () => {
const [hasHydrated, setHasHydrated] = useState<boolean>(false);
@@ -101,6 +146,9 @@ function _Home() {
const [openSettings, setOpenSettings] = useState(false);
const config = useChatStore((state) => state.config);
// drag side bar
const { onDragMouseDown } = useDragSideBar();
useSwitchTheme();
if (loading) {
@@ -174,6 +222,11 @@ function _Home() {
/>
</div>
</div>
<div
className={styles["sidebar-drag"]}
onMouseDown={(e) => onDragMouseDown(e as any)}
></div>
</div>
<div className={styles["window-content"]}>

View File

@@ -8,6 +8,8 @@ import RehypeHighlight from "rehype-highlight";
import { useRef, useState, RefObject, useEffect } from "react";
import { copyToClipboard } from "../utils";
import LoadingIcon from "../icons/three-dots.svg";
export function PreCode(props: { children: any }) {
const ref = useRef<HTMLPreElement>(null);
@@ -27,49 +29,78 @@ export function PreCode(props: { children: any }) {
);
}
const useLazyLoad = (ref: RefObject<Element>): boolean => {
const [isIntersecting, setIntersecting] = useState<boolean>(false);
export function Markdown(
props: {
content: string;
loading?: boolean;
fontSize?: number;
parentRef: RefObject<HTMLDivElement>;
} & React.DOMAttributes<HTMLDivElement>,
) {
const mdRef = useRef<HTMLDivElement>(null);
const parent = props.parentRef.current;
const md = mdRef.current;
const rendered = useRef(true); // disable lazy loading for bad ux
const [counter, setCounter] = useState(0);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIntersecting(true);
observer.disconnect();
// to triggr rerender
setCounter(counter + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.loading]);
const inView =
rendered.current ||
(() => {
if (parent && md) {
const parentBounds = parent.getBoundingClientRect();
const mdBounds = md.getBoundingClientRect();
const isInRange = (x: number) =>
x <= parentBounds.bottom && x >= parentBounds.top;
const inView = isInRange(mdBounds.top) || isInRange(mdBounds.bottom);
if (inView) {
rendered.current = true;
}
return inView;
}
});
})();
if (ref.current) {
observer.observe(ref.current);
}
const shouldLoading = props.loading || !inView;
return () => {
observer.disconnect();
};
}, [ref]);
return isIntersecting;
};
export function Markdown(props: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={[
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
}}
linkTarget={"_blank"}
<div
className="markdown-body"
style={{ fontSize: `${props.fontSize ?? 14}px` }}
ref={mdRef}
onContextMenu={props.onContextMenu}
onDoubleClickCapture={props.onDoubleClickCapture}
>
{props.content}
</ReactMarkdown>
{shouldLoading ? (
<LoadingIcon />
) : (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={[
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
]}
components={{
pre: PreCode,
}}
linkTarget={"_blank"}
>
{props.content}
</ReactMarkdown>
)}
</div>
);
}

View File

@@ -19,11 +19,16 @@
cursor: pointer;
}
.password-input {
.password-input-container {
max-width: 50%;
display: flex;
justify-content: flex-end;
.password-eye {
margin-right: 4px;
}
.password-input {
min-width: 80%;
}
}

View File

@@ -26,7 +26,7 @@ import {
import { Avatar } from "./chat";
import Locale, { AllLangs, changeLang, getLang } from "../locales";
import { getCurrentVersion, getEmojiUrl } from "../utils";
import { getEmojiUrl } from "../utils";
import Link from "next/link";
import { UPDATE_URL } from "../constant";
import { SearchService, usePromptStore } from "../store/prompt";
@@ -60,13 +60,17 @@ function PasswordInput(props: HTMLProps<HTMLInputElement>) {
}
return (
<div className={styles["password-input"]}>
<div className={styles["password-input-container"]}>
<IconButton
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
onClick={changeVisibility}
className={styles["password-eye"]}
/>
<input {...props} type={visible ? "text" : "password"} />
<input
{...props}
type={visible ? "text" : "password"}
className={styles["password-input"]}
/>
</div>
);
}
@@ -84,13 +88,13 @@ export function Settings(props: { closeSettings: () => void }) {
const updateStore = useUpdateStore();
const [checkingUpdate, setCheckingUpdate] = useState(false);
const currentId = getCurrentVersion();
const remoteId = updateStore.remoteId;
const hasNewVersion = currentId !== remoteId;
const currentVersion = updateStore.version;
const remoteId = updateStore.remoteVersion;
const hasNewVersion = currentVersion !== remoteId;
function checkUpdate(force = false) {
setCheckingUpdate(true);
updateStore.getLatestCommitId(force).then(() => {
updateStore.getLatestVersion(force).then(() => {
setCheckingUpdate(false);
});
}
@@ -120,8 +124,7 @@ export function Settings(props: { closeSettings: () => void }) {
const builtinCount = SearchService.count.builtin;
const customCount = promptStore.prompts.size ?? 0;
const showUsage = !!accessStore.token || !!accessStore.accessCode;
const showUsage = accessStore.isAuthorized();
useEffect(() => {
checkUpdate();
showUsage && checkUsage();
@@ -156,7 +159,14 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}>
<IconButton
icon={<ClearIcon />}
onClick={clearSessions}
onClick={() => {
const confirmed = window.confirm(
`${Locale.Settings.Actions.ConfirmClearAll.Confirm}`,
);
if (confirmed) {
clearSessions();
}
}}
bordered
title={Locale.Settings.Actions.ClearAll}
/>
@@ -164,7 +174,14 @@ export function Settings(props: { closeSettings: () => void }) {
<div className={styles["window-action-button"]}>
<IconButton
icon={<ResetIcon />}
onClick={resetConfig}
onClick={() => {
const confirmed = window.confirm(
`${Locale.Settings.Actions.ConfirmResetAll.Confirm}`,
);
if (confirmed) {
resetConfig();
}
}}
bordered
title={Locale.Settings.Actions.ResetAll}
/>
@@ -207,7 +224,7 @@ export function Settings(props: { closeSettings: () => void }) {
</SettingItem>
<SettingItem
title={Locale.Settings.Update.Version(currentId)}
title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
subTitle={
checkingUpdate
? Locale.Settings.Update.IsChecking
@@ -328,37 +345,7 @@ export function Settings(props: { closeSettings: () => void }) {
></input>
</SettingItem>
</List>
<List>
<SettingItem
title={Locale.Settings.Prompt.Disable.Title}
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
>
<input
type="checkbox"
checked={config.disablePromptHint}
onChange={(e) =>
updateConfig(
(config) =>
(config.disablePromptHint = e.currentTarget.checked),
)
}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.Prompt.List}
subTitle={Locale.Settings.Prompt.ListCount(
builtinCount,
customCount,
)}
>
<IconButton
icon={<EditIcon />}
text={Locale.Settings.Prompt.Edit}
onClick={() => showToast(Locale.WIP)}
/>
</SettingItem>
</List>
<List>
{enabledAccessControl ? (
<SettingItem
@@ -455,6 +442,38 @@ export function Settings(props: { closeSettings: () => void }) {
</SettingItem>
</List>
<List>
<SettingItem
title={Locale.Settings.Prompt.Disable.Title}
subTitle={Locale.Settings.Prompt.Disable.SubTitle}
>
<input
type="checkbox"
checked={config.disablePromptHint}
onChange={(e) =>
updateConfig(
(config) =>
(config.disablePromptHint = e.currentTarget.checked),
)
}
></input>
</SettingItem>
<SettingItem
title={Locale.Settings.Prompt.List}
subTitle={Locale.Settings.Prompt.ListCount(
builtinCount,
customCount,
)}
>
<IconButton
icon={<EditIcon />}
text={Locale.Settings.Prompt.Edit}
onClick={() => showToast(Locale.WIP)}
/>
</SettingItem>
</List>
<List>
<SettingItem title={Locale.Settings.Model}>
<select

View File

@@ -9,6 +9,7 @@
.popover {
position: relative;
z-index: 2;
}
.popover-content {
@@ -126,6 +127,7 @@
width: 100vw;
display: flex;
justify-content: center;
pointer-events: none;
.toast-content {
max-width: 80vw;
@@ -140,6 +142,7 @@
margin-bottom: 20px;
display: flex;
align-items: center;
pointer-events: all;
.toast-action {
padding-left: 20px;