diff --git a/app/api/openai/typing.ts b/app/api/openai/typing.ts index b936530c..2286d231 100644 --- a/app/api/openai/typing.ts +++ b/app/api/openai/typing.ts @@ -5,3 +5,5 @@ import type { export type ChatRequest = CreateChatCompletionRequest; export type ChatResponse = CreateChatCompletionResponse; + +export type Updater = (updater: (value: T) => void) => void; diff --git a/app/components/button.tsx b/app/components/button.tsx index 1675a4b7..3a2cb8ac 100644 --- a/app/components/button.tsx +++ b/app/components/button.tsx @@ -4,7 +4,7 @@ import styles from "./button.module.scss"; export function IconButton(props: { onClick?: () => void; - icon: JSX.Element; + icon?: JSX.Element; text?: string; bordered?: boolean; shadow?: boolean; @@ -26,11 +26,16 @@ export function IconButton(props: { disabled={props.disabled} role="button" > -
- {props.icon} -
+ {props.icon && ( +
+ {props.icon} +
+ )} + {props.text && (
{props.text}
)} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index b38b0835..24da3221 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -57,6 +57,8 @@ import { useNavigate } from "react-router-dom"; import { Path } from "../constant"; import { ModelConfigList } from "./model-config"; import { Avatar, AvatarPicker } from "./emoji"; +import { MaskConfig } from "./mask"; +import { DEFAULT_MASK_ID } from "../store/mask"; const Markdown = dynamic( async () => memo((await import("./markdown")).Markdown), @@ -103,103 +105,10 @@ function exportMessages(messages: Message[], topic: string) { }); } -function ContextPrompts() { - const chatStore = useChatStore(); - const session = chatStore.currentSession(); - const context = session.context; - - const addContextPrompt = (prompt: Message) => { - chatStore.updateCurrentSession((session) => { - session.context.push(prompt); - }); - }; - - const removeContextPrompt = (i: number) => { - chatStore.updateCurrentSession((session) => { - session.context.splice(i, 1); - }); - }; - - const updateContextPrompt = (i: number, prompt: Message) => { - chatStore.updateCurrentSession((session) => { - session.context[i] = prompt; - }); - }; - - return ( - <> -
- {context.map((c, i) => ( -
- - - updateContextPrompt(i, { - ...c, - content: e.currentTarget.value as any, - }) - } - /> - } - className={chatStyle["context-delete-button"]} - onClick={() => removeContextPrompt(i)} - bordered - /> -
- ))} - -
- } - text={Locale.Context.Add} - bordered - className={chatStyle["context-prompt-button"]} - onClick={() => - addContextPrompt({ - role: "system", - content: "", - date: "", - }) - } - /> -
-
- - ); -} - export function SessionConfigModel(props: { onClose: () => void }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); - const [showPicker, setShowPicker] = useState(false); - - const updateConfig = (updater: (config: ModelConfig) => void) => { - const config = { ...session.modelConfig }; - updater(config); - chatStore.updateCurrentSession((session) => (session.modelConfig = config)); - }; - return (
void }) { key="reset" icon={} bordered - text="重置预设" + text="重置" onClick={() => confirm(Locale.Memory.ResetConfirm) && chatStore.resetSession() } @@ -219,69 +128,29 @@ export function SessionConfigModel(props: { onClose: () => void }) { key="copy" icon={} bordered - text="保存预设" + text="保存为面具" onClick={() => copyToClipboard(session.memoryPrompt)} />, ]} > - - - - - - chatStore.updateCurrentSession( - (session) => (session.avatar = emoji), - ) - } - > - } - open={showPicker} - onClose={() => setShowPicker(false)} - > -
setShowPicker(true)} - style={{ cursor: "pointer" }} - > - {session.avatar ? ( - - ) : ( - - )} -
-
-
- - - chatStore.updateCurrentSession( - (session) => (session.topic = e.currentTarget.value), - ) - } - > - -
- - - - - {session.modelConfig.sendMemory ? ( - - ) : ( - <> - )} - + { + const mask = { ...session.mask }; + updater(mask); + chatStore.updateCurrentSession((session) => (session.mask = mask)); + }} + extraListItems={ + session.mask.modelConfig.sendMemory ? ( + + ) : ( + <> + ) + } + >
); @@ -294,7 +163,7 @@ function PromptToast(props: { }) { const chatStore = useChatStore(); const session = chatStore.currentSession(); - const context = session.context; + const context = session.mask.context; return (
@@ -617,7 +486,7 @@ export function Chat() { inputRef.current?.focus(); }; - const context: RenderMessage[] = session.context.slice(); + const context: RenderMessage[] = session.mask.context.slice(); const accessStore = useAccessStore(); @@ -680,20 +549,20 @@ export function Chat() { return (
-
-
+
+
{!session.topic ? DEFAULT_TOPIC : session.topic}
-
+
{Locale.Chat.SubTitle(session.messages.length)}
-
-
+
+
} bordered @@ -701,14 +570,14 @@ export function Chat() { onClick={() => navigate(Path.Home)} />
-
+
} bordered onClick={renameSession} />
-
+
} bordered @@ -722,7 +591,7 @@ export function Chat() { />
{!isMobileScreen && ( -
+
: } bordered @@ -773,10 +642,10 @@ export function Chat() {
{message.role === "user" ? ( - ) : session.avatar ? ( - - ) : ( + ) : session.mask.id === DEFAULT_MASK_ID ? ( + ) : ( + )}
{showTyping && ( diff --git a/app/components/home.module.scss b/app/components/home.module.scss index b8452534..470bc9dd 100644 --- a/app/components/home.module.scss +++ b/app/components/home.module.scss @@ -1,6 +1,3 @@ -@import "./window.scss"; -@import "../styles/animation.scss"; - @mixin container { background-color: var(--white); border: var(--border-in-light); diff --git a/app/components/home.tsx b/app/components/home.tsx index 4e334805..a83a7798 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -45,6 +45,10 @@ const NewChat = dynamic(async () => (await import("./new-chat")).NewChat, { loading: () => , }); +const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, { + loading: () => , +}); + export function useSwitchTheme() { const config = useAppConfig(); @@ -109,6 +113,7 @@ function Screen() { } /> } /> + } /> } /> } /> diff --git a/app/components/mask.module.scss b/app/components/mask.module.scss new file mode 100644 index 00000000..dc823253 --- /dev/null +++ b/app/components/mask.module.scss @@ -0,0 +1,33 @@ +.mask-page { + height: 100%; + display: flex; + flex-direction: column; + + .mask-page-body { + padding: 20px; + overflow-y: auto; + + .search-bar { + width: 100%; + max-width: 100%; + margin-bottom: 20px; + } + + .mask-item { + .mask-icon { + display: flex; + align-items: center; + justify-content: center; + border: var(--border-in-light); + border-radius: 10px; + padding: 6px; + } + + .mask-actions { + display: flex; + flex-wrap: nowrap; + transition: all ease 0.3s; + } + } + } +} diff --git a/app/components/mask.tsx b/app/components/mask.tsx new file mode 100644 index 00000000..281a3d3b --- /dev/null +++ b/app/components/mask.tsx @@ -0,0 +1,258 @@ +import { IconButton } from "./button"; +import { ErrorBoundary } from "./error"; + +import styles from "./mask.module.scss"; + +import DownloadIcon from "../icons/download.svg"; +import EditIcon from "../icons/edit.svg"; +import AddIcon from "../icons/add.svg"; +import CloseIcon from "../icons/close.svg"; +import DeleteIcon from "../icons/delete.svg"; +import CopyIcon from "../icons/copy.svg"; + +import { DEFAULT_MASK_AVATAR, DEFAULT_MASK_ID, Mask } from "../store/mask"; +import { + Message, + ModelConfig, + ROLES, + useAppConfig, + useChatStore, +} from "../store"; +import { Input, List, ListItem, Modal, Popover } from "./ui-lib"; +import { Avatar, AvatarPicker, EmojiAvatar } from "./emoji"; +import Locale from "../locales"; +import { useNavigate } from "react-router-dom"; + +import chatStyle from "./chat.module.scss"; +import { useState } from "react"; +import { copyToClipboard } from "../utils"; +import { Updater } from "../api/openai/typing"; +import { ModelConfigList } from "./model-config"; + +export function MaskConfig(props: { + mask: Mask; + updateMask: Updater; + extraListItems?: JSX.Element; +}) { + const [showPicker, setShowPicker] = useState(false); + + const updateConfig = (updater: (config: ModelConfig) => void) => { + const config = { ...props.mask.modelConfig }; + updater(config); + props.updateMask((mask) => (mask.modelConfig = config)); + }; + + return ( + <> + { + const context = props.mask.context.slice(); + updater(context); + props.updateMask((mask) => (mask.context = context)); + }} + /> + + + + { + props.updateMask((mask) => (mask.avatar = emoji)); + setShowPicker(false); + }} + > + } + open={showPicker} + onClose={() => setShowPicker(false)} + > +
setShowPicker(true)} + style={{ cursor: "pointer" }} + > + {props.mask.avatar !== DEFAULT_MASK_AVATAR ? ( + + ) : ( + + )} +
+
+
+ + + props.updateMask((mask) => (mask.name = e.currentTarget.value)) + } + > + +
+ + + + {props.extraListItems} + + + ); +} + +export function ContextPrompts(props: { + context: Message[]; + updateContext: (updater: (context: Message[]) => void) => void; +}) { + const context = props.context; + + const addContextPrompt = (prompt: Message) => { + props.updateContext((context) => context.push(prompt)); + }; + + const removeContextPrompt = (i: number) => { + props.updateContext((context) => context.splice(i, 1)); + }; + + const updateContextPrompt = (i: number, prompt: Message) => { + props.updateContext((context) => (context[i] = prompt)); + }; + + return ( + <> +
+ {context.map((c, i) => ( +
+ + + updateContextPrompt(i, { + ...c, + content: e.currentTarget.value as any, + }) + } + /> + } + className={chatStyle["context-delete-button"]} + onClick={() => removeContextPrompt(i)} + bordered + /> +
+ ))} + +
+ } + text={Locale.Context.Add} + bordered + className={chatStyle["context-prompt-button"]} + onClick={() => + addContextPrompt({ + role: "system", + content: "", + date: "", + }) + } + /> +
+
+ + ); +} + +export function MaskPage() { + const config = useAppConfig(); + const navigate = useNavigate(); + const masks: Mask[] = new Array(10).fill(0).map((m, i) => ({ + id: i, + avatar: "1f606", + name: "预设角色 " + i.toString(), + context: [ + { role: "assistant", content: "你好,有什么可以帮忙的吗", date: "" }, + ], + modelConfig: config.modelConfig, + lang: "cn", + })); + + return ( + +
+
+
+
预设角色面具
+
编辑预设角色定义
+
+ +
+
+ } bordered /> +
+
+ } bordered /> +
+
+ } + bordered + onClick={() => navigate(-1)} + /> +
+
+
+ +
+ + + + {masks.map((m) => ( + + +
+ } + className={styles["mask-item"]} + > +
+ } text="对话" /> + } text="编辑" /> + } text="删除" /> +
+ + ))} + +
+
+ + ); +} diff --git a/app/components/model-config.tsx b/app/components/model-config.tsx index 797bcb37..32c2f5c0 100644 --- a/app/components/model-config.tsx +++ b/app/components/model-config.tsx @@ -97,7 +97,7 @@ export function ModelConfigList(props: { title={props.modelConfig.historyMessageCount.toString()} value={props.modelConfig.historyMessageCount} min="0" - max="25" + max="32" step="1" onChange={(e) => props.updateConfig( diff --git a/app/components/new-chat.module.scss b/app/components/new-chat.module.scss index 9cd17960..1fdd86a6 100644 --- a/app/components/new-chat.module.scss +++ b/app/components/new-chat.module.scss @@ -1,3 +1,5 @@ +@import "../styles/animation.scss"; + .new-chat { height: 100%; width: 100%; @@ -5,11 +7,21 @@ align-items: center; justify-content: center; flex-direction: column; - padding-top: 80px; + + .mask-header { + display: flex; + justify-content: space-between; + width: 100%; + padding: 10px; + box-sizing: border-box; + animation: slide-in-from-top ease 0.3s; + } .mask-cards { display: flex; + margin-top: 5vh; margin-bottom: 20px; + animation: slide-in ease 0.3s; .mask-card { padding: 20px 10px; @@ -32,15 +44,18 @@ .title { font-size: 32px; font-weight: bolder; - animation: slide-in ease 0.3s; + margin-bottom: 1vh; + animation: slide-in ease 0.35s; } .sub-title { - animation: slide-in ease 0.3s; + animation: slide-in ease 0.4s; } .search-bar { - margin-top: 20px; + margin-top: 5vh; + margin-bottom: 5vh; + animation: slide-in ease 0.45s; } .masks { @@ -50,7 +65,7 @@ align-items: center; padding-top: 20px; - animation: slide-in ease 0.3s; + animation: slide-in ease 0.5s; .mask-row { margin-bottom: 10px; diff --git a/app/components/new-chat.tsx b/app/components/new-chat.tsx index e053a7fe..a161ee01 100644 --- a/app/components/new-chat.tsx +++ b/app/components/new-chat.tsx @@ -1,7 +1,10 @@ import { useEffect, useRef } from "react"; import { SlotID } from "../constant"; +import { IconButton } from "./button"; import { EmojiAvatar } from "./emoji"; import styles from "./new-chat.module.scss"; +import LeftIcon from "../icons/left.svg"; +import { useNavigate } from "react-router-dom"; function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) { const xmin = Math.max(aRect.x, bRect.x); @@ -59,8 +62,18 @@ export function NewChat() { })), ); + const navigate = useNavigate(); + return (
+
+ } + text="返回" + onClick={() => navigate(-1)} + > + +
@@ -74,7 +87,9 @@ export function NewChat() {
挑选一个面具
-
现在开始,与面具背后的思维碰撞
+
+ 现在开始,与面具背后的灵魂思维碰撞 +
diff --git a/app/components/settings.module.scss b/app/components/settings.module.scss index 6fb5a68b..30abc36d 100644 --- a/app/components/settings.module.scss +++ b/app/components/settings.module.scss @@ -1,5 +1,3 @@ -@import "./window.scss"; - .settings { padding: 20px; overflow: auto; diff --git a/app/components/settings.tsx b/app/components/settings.tsx index f396ed32..83aec5af 100644 --- a/app/components/settings.tsx +++ b/app/components/settings.tsx @@ -202,17 +202,17 @@ export function Settings() { return ( -
-
-
+
+
+
{Locale.Settings.Title}
-
+
{Locale.Settings.SubTitle}
-
-
+
+
} onClick={() => { @@ -227,7 +227,7 @@ export function Settings() { title={Locale.Settings.Actions.ClearAll} />
-
+
} onClick={() => { @@ -242,7 +242,7 @@ export function Settings() { title={Locale.Settings.Actions.ResetAll} />
-
+
} onClick={() => navigate(Path.Home)} diff --git a/app/components/ui-lib.module.scss b/app/components/ui-lib.module.scss index e3acd6d6..b20edc35 100644 --- a/app/components/ui-lib.module.scss +++ b/app/components/ui-lib.module.scss @@ -36,14 +36,23 @@ padding: 10px 20px; animation: slide-in ease 0.6s; - .list-item-title { - font-size: 14px; - font-weight: bolder; - } + .list-header { + display: flex; + align-items: center; - .list-item-sub-title { - font-size: 12px; - font-weight: normal; + .list-icon { + margin-right: 10px; + } + + .list-item-title { + font-size: 14px; + font-weight: bolder; + } + + .list-item-sub-title { + font-size: 12px; + font-weight: normal; + } } } diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx index 4a92461c..5b6ed959 100644 --- a/app/components/ui-lib.tsx +++ b/app/components/ui-lib.tsx @@ -37,21 +37,34 @@ export function ListItem(props: { title: string; subTitle?: string; children?: JSX.Element | JSX.Element[]; + icon?: JSX.Element; + className?: string; }) { return ( -
-
-
{props.title}
- {props.subTitle && ( -
{props.subTitle}
- )} +
+
+ {props.icon &&
{props.icon}
} +
+
{props.title}
+ {props.subTitle && ( +
+ {props.subTitle} +
+ )} +
{props.children}
); } -export function List(props: { children: JSX.Element[] | JSX.Element }) { +export function List(props: { + children: + | Array + | JSX.Element + | null + | undefined; +}) { return
{props.children}
; } diff --git a/app/config/masks.ts b/app/config/masks.ts new file mode 100644 index 00000000..fc635d9c --- /dev/null +++ b/app/config/masks.ts @@ -0,0 +1,3 @@ +import { Mask } from "../store/mask"; + +export const BUILTIN_MASKS: Mask[] = []; diff --git a/app/constant.ts b/app/constant.ts index 60bb73bd..9be260a7 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -12,6 +12,7 @@ export enum Path { Chat = "/chat", Settings = "/settings", NewChat = "/new-chat", + Masks = "/masks", } export enum SlotID { diff --git a/app/icons/left.svg b/app/icons/left.svg new file mode 100644 index 00000000..8f1cf52d --- /dev/null +++ b/app/icons/left.svg @@ -0,0 +1 @@ + diff --git a/app/requests.ts b/app/requests.ts index 6ab075a8..7e92cc45 100644 --- a/app/requests.ts +++ b/app/requests.ts @@ -30,7 +30,7 @@ const makeRequestParam = ( const modelConfig = { ...useAppConfig.getState().modelConfig, - ...useChatStore.getState().currentSession().modelConfig, + ...useChatStore.getState().currentSession().mask.modelConfig, }; // override model config diff --git a/app/store/chat.ts b/app/store/chat.ts index 4692a5a4..a95d767b 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -12,6 +12,7 @@ import { isMobileScreen, trimTopic } from "../utils"; import Locale from "../locales"; import { showToast } from "../components/ui-lib"; import { DEFAULT_CONFIG, ModelConfig, ModelType, useAppConfig } from "./config"; +import { createEmptyMask, Mask } from "./mask"; export type Message = ChatCompletionResponseMessage & { date: string; @@ -41,16 +42,16 @@ export interface ChatStat { export interface ChatSession { id: number; + topic: string; - avatar?: string; + memoryPrompt: string; - context: Message[]; messages: Message[]; stat: ChatStat; lastUpdate: string; lastSummarizeIndex: number; - modelConfig: ModelConfig; + mask: Mask; } export const DEFAULT_TOPIC = Locale.Store.DefaultTopic; @@ -66,7 +67,6 @@ function createEmptySession(): ChatSession { id: Date.now(), topic: DEFAULT_TOPIC, memoryPrompt: "", - context: [], messages: [], stat: { tokenCount: 0, @@ -75,8 +75,7 @@ function createEmptySession(): ChatSession { }, lastUpdate: createDate, lastSummarizeIndex: 0, - - modelConfig: useAppConfig.getState().modelConfig, + mask: createEmptyMask(), }; } @@ -322,11 +321,11 @@ export const useChatStore = create()( const messages = session.messages.filter((msg) => !msg.isError); const n = messages.length; - const context = session.context.slice(); + const context = session.mask.context.slice(); // long term memory if ( - session.modelConfig.sendMemory && + session.mask.modelConfig.sendMemory && session.memoryPrompt && session.memoryPrompt.length > 0 ) { @@ -432,7 +431,7 @@ export const useChatStore = create()( if ( historyMsgLength > config.modelConfig.compressMessageLengthThreshold && - session.modelConfig.sendMemory + session.mask.modelConfig.sendMemory ) { requestChatStream( toBeSummarizedMsgs.concat({ @@ -485,14 +484,8 @@ export const useChatStore = create()( migrate(persistedState, version) { const state = persistedState as ChatStore; - if (version === 1) { - state.sessions.forEach((s) => (s.context = [])); - } - if (version < 2) { - state.sessions.forEach( - (s) => (s.modelConfig = { ...DEFAULT_CONFIG.modelConfig }), - ); + state.sessions.forEach((s) => (s.mask = createEmptyMask())); } return state; diff --git a/app/store/mask.ts b/app/store/mask.ts index 168761cc..9a9d985e 100644 --- a/app/store/mask.ts +++ b/app/store/mask.ts @@ -1,8 +1,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { getLang, Lang } from "../locales"; -import { Message } from "./chat"; -import { ModelConfig, useAppConfig } from "./config"; +import { DEFAULT_TOPIC, Message } from "./chat"; +import { ModelConfig, ModelType, useAppConfig } from "./config"; export const MASK_KEY = "mask-store"; @@ -11,7 +11,7 @@ export type Mask = { avatar: string; name: string; context: Message[]; - config: ModelConfig; + modelConfig: ModelConfig; lang: Lang; }; @@ -29,6 +29,18 @@ type MaskStore = MaskState & { getAll: () => Mask[]; }; +export const DEFAULT_MASK_ID = 1145141919810; +export const DEFAULT_MASK_AVATAR = "gpt-bot"; +export const createEmptyMask = () => + ({ + id: DEFAULT_MASK_ID, + avatar: DEFAULT_MASK_AVATAR, + name: DEFAULT_TOPIC, + context: [], + modelConfig: useAppConfig.getState().modelConfig, + lang: getLang(), + } as Mask); + export const useMaskStore = create()( persist( (set, get) => ({ @@ -39,12 +51,8 @@ export const useMaskStore = create()( const id = get().globalMaskId; const masks = get().masks; masks[id] = { + ...createEmptyMask(), id, - avatar: "1f916", - name: "", - config: useAppConfig.getState().modelConfig, - context: [], - lang: getLang(), ...mask, }; diff --git a/app/styles/globals.scss b/app/styles/globals.scss index 549f254b..9caf663d 100644 --- a/app/styles/globals.scss +++ b/app/styles/globals.scss @@ -1,3 +1,6 @@ +@import "./animation.scss"; +@import "./window.scss"; + @mixin light { --theme: light; diff --git a/app/components/window.scss b/app/styles/window.scss similarity index 100% rename from app/components/window.scss rename to app/styles/window.scss