feat: #138 add context prompt, close #330 #321

This commit is contained in:
Yifei Zhang 2023-04-02 17:48:43 +00:00
parent c978de2c10
commit b85245e317
14 changed files with 296 additions and 69 deletions

View File

@ -6,19 +6,21 @@
justify-content: center; justify-content: center;
padding: 10px; padding: 10px;
box-shadow: var(--card-shadow);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
} }
.shadow {
box-shadow: var(--card-shadow);
}
.border { .border {
border: var(--border-in-light); border: var(--border-in-light);
} }
.icon-button:hover { .icon-button:hover {
filter: brightness(0.9);
border-color: var(--primary); border-color: var(--primary);
} }
@ -36,24 +38,6 @@
} }
} }
@mixin dark-button {
div:not(:global(.no-dark))>.icon-button-icon {
filter: invert(0.5);
}
.icon-button:hover {
filter: brightness(1.2);
}
}
:global(.dark) {
@include dark-button;
}
@media (prefers-color-scheme: dark) {
@include dark-button;
}
.icon-button-text { .icon-button-text {
margin-left: 5px; margin-left: 5px;
font-size: 12px; font-size: 12px;

View File

@ -7,6 +7,7 @@ export function IconButton(props: {
icon: JSX.Element; icon: JSX.Element;
text?: string; text?: string;
bordered?: boolean; bordered?: boolean;
shadow?: boolean;
className?: string; className?: string;
title?: string; title?: string;
}) { }) {
@ -14,10 +15,13 @@ export function IconButton(props: {
<div <div
className={ className={
styles["icon-button"] + styles["icon-button"] +
` ${props.bordered && styles.border} ${props.className ?? ""}` ` ${props.bordered && styles.border} ${props.shadow && styles.shadow} ${
props.className ?? ""
} clickable`
} }
onClick={props.onClick} onClick={props.onClick}
title={props.title} title={props.title}
role="button"
> >
<div className={styles["icon-button-icon"]}>{props.icon}</div> <div className={styles["icon-button-icon"]}>{props.icon}</div>
{props.text && ( {props.text && (

View File

@ -11,6 +11,7 @@ import {
} from "../store"; } from "../store";
import Locale from "../locales"; import Locale from "../locales";
import { isMobileScreen } from "../utils";
export function ChatItem(props: { export function ChatItem(props: {
onClick?: () => void; onClick?: () => void;
@ -61,7 +62,10 @@ export function ChatList() {
key={i} key={i}
selected={i === selectedIndex} selected={i === selectedIndex}
onClick={() => selectSession(i)} onClick={() => selectSession(i)}
onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)} onDelete={() =>
(!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
removeSession(i)
}
/> />
))} ))}
</div> </div>

View File

@ -0,0 +1,71 @@
.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;
.prompt-toast-content {
margin-left: 10px;
}
}
}
.context-prompt {
.context-prompt-row {
display: flex;
justify-content: center;
width: 100%;
margin-bottom: 10px;
.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-top: 20px;
.memory-prompt-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 10px;
}
.memory-prompt-content {
background-color: var(--gray);
border-radius: 6px;
padding: 10px;
font-size: 12px;
user-select: text;
}
}

View File

@ -9,6 +9,8 @@ import CopyIcon from "../icons/copy.svg";
import DownloadIcon from "../icons/download.svg"; import DownloadIcon from "../icons/download.svg";
import LoadingIcon from "../icons/three-dots.svg"; import LoadingIcon from "../icons/three-dots.svg";
import BotIcon from "../icons/bot.svg"; import BotIcon from "../icons/bot.svg";
import AddIcon from "../icons/add.svg";
import DeleteIcon from "../icons/delete.svg";
import { import {
Message, Message,
@ -16,6 +18,7 @@ import {
useChatStore, useChatStore,
ChatSession, ChatSession,
BOT_HELLO, BOT_HELLO,
ROLES,
} from "../store"; } from "../store";
import { import {
@ -33,8 +36,9 @@ import Locale from "../locales";
import { IconButton } from "./button"; import { IconButton } from "./button";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
import chatStyle from "./chat.module.scss";
import { showModal, showToast } from "./ui-lib"; import { Modal, showModal, showToast } from "./ui-lib";
const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />, loading: () => <LoadingIcon />,
@ -94,26 +98,130 @@ function exportMessages(messages: Message[], topic: string) {
}); });
} }
function showMemoryPrompt(session: ChatSession) { function PromptToast(props: {
showModal({ showModal: boolean;
title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`, setShowModal: (_: boolean) => void;
children: ( }) {
<div className="markdown-body"> const chatStore = useChatStore();
<pre className={styles["export-content"]}> const session = chatStore.currentSession();
{session.memoryPrompt || Locale.Memory.EmptyContent} const context = session.context;
</pre>
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 (
<div className={chatStyle["prompt-toast"]} key="prompt-toast">
<div
className={chatStyle["prompt-toast-inner"] + " clickable"}
role="button"
onClick={() => props.setShowModal(true)}
>
<BrainIcon />
<span className={chatStyle["prompt-toast-content"]}>
{context.length}
</span>
</div> </div>
), {props.showModal && (
actions: [ <div className="modal-mask">
<IconButton <Modal
key="copy" title="编辑前置上下文"
icon={<CopyIcon />} onClose={() => props.setShowModal(false)}
bordered actions={[
text={Locale.Memory.Copy} <IconButton
onClick={() => copyToClipboard(session.memoryPrompt)} key="copy"
/>, icon={<CopyIcon />}
], bordered
}); text={Locale.Memory.Copy}
onClick={() => copyToClipboard(session.memoryPrompt)}
/>,
]}
>
<>
{" "}
<div className={chatStyle["context-prompt"]}>
{context.map((c, i) => (
<div className={chatStyle["context-prompt-row"]} key={i}>
<select
value={c.role}
className={chatStyle["context-role"]}
onChange={(e) =>
updateContextPrompt(i, {
...c,
role: e.target.value as any,
})
}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
value={c.content}
type="text"
className={chatStyle["context-content"]}
onChange={(e) =>
updateContextPrompt(i, {
...c,
content: e.target.value as any,
})
}
></input>
<IconButton
icon={<DeleteIcon />}
className={chatStyle["context-delete-button"]}
onClick={() => removeContextPrompt(i)}
/>
</div>
))}
<div className={chatStyle["context-prompt-row"]}>
<IconButton
icon={<AddIcon />}
text="新增"
bordered
className={chatStyle["context-prompt-button"]}
onClick={() =>
addContextPrompt({
role: "system",
content: "",
date: "",
})
}
/>
</div>
</div>
<div className={chatStyle["memory-prompt"]}>
<div className={chatStyle["memory-prompt-title"]}>
{Locale.Memory.Title} ({session.lastSummarizeIndex} of{" "}
{session.messages.length})
</div>
<div className={chatStyle["memory-prompt-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
</div>
</div>
</>
</Modal>
</div>
)}
</div>
);
} }
function useSubmitHandler() { function useSubmitHandler() {
@ -172,9 +280,8 @@ function useScrollToBottom() {
// auto scroll // auto scroll
useLayoutEffect(() => { useLayoutEffect(() => {
const dom = scrollRef.current; const dom = scrollRef.current;
if (dom && autoScroll) { if (dom && autoScroll) {
dom.scrollTop = dom.scrollHeight; setTimeout(() => (dom.scrollTop = dom.scrollHeight), 500);
} }
}); });
@ -243,8 +350,12 @@ export function Chat(props: {
setPromptHints([]); setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) { } else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion // check if need to trigger auto completion
if (text.startsWith("/") && text.length > 1) { if (text.startsWith("/")) {
onSearch(text.slice(1)); let searchText = text.slice(1);
if (searchText.length === 0) {
searchText = " ";
}
onSearch(searchText);
} }
} }
}; };
@ -299,8 +410,18 @@ export function Chat(props: {
const config = useChatStore((state) => state.config); const config = useChatStore((state) => state.config);
const context: RenderMessage[] = session.context.slice();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
context.push(BOT_HELLO);
}
// preview messages // preview messages
const messages = (session.messages as RenderMessage[]) const messages = context
.concat(session.messages as RenderMessage[])
.concat( .concat(
isLoading isLoading
? [ ? [
@ -326,6 +447,8 @@ export function Chat(props: {
: [], : [],
); );
const [showPromptModal, setShowPromptModal] = useState(false);
return ( return (
<div className={styles.chat} key={session.id}> <div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}> <div className={styles["window-header"]}>
@ -365,7 +488,7 @@ export function Chat(props: {
bordered bordered
title={Locale.Chat.Actions.CompressedHistory} title={Locale.Chat.Actions.CompressedHistory}
onClick={() => { onClick={() => {
showMemoryPrompt(session); setShowPromptModal(true);
}} }}
/> />
</div> </div>
@ -380,6 +503,11 @@ export function Chat(props: {
/> />
</div> </div>
</div> </div>
<PromptToast
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
</div> </div>
<div className={styles["chat-body"]} ref={scrollRef}> <div className={styles["chat-body"]} ref={scrollRef}>
@ -402,7 +530,10 @@ export function Chat(props: {
{Locale.Chat.Typing} {Locale.Chat.Typing}
</div> </div>
)} )}
<div className={styles["chat-message-item"]}> <div
className={styles["chat-message-item"]}
onMouseOver={() => inputRef.current?.blur()}
>
{!isUser && {!isUser &&
!(message.preview || message.content.length === 0) && ( !(message.preview || message.content.length === 0) && (
<div className={styles["chat-message-top-actions"]}> <div className={styles["chat-message-top-actions"]}>
@ -467,7 +598,7 @@ export function Chat(props: {
ref={inputRef} ref={inputRef}
className={styles["chat-input"]} className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)} placeholder={Locale.Chat.Input(submitKey)}
rows={4} rows={2}
onInput={(e) => onInput(e.currentTarget.value)} onInput={(e) => onInput(e.currentTarget.value)}
value={userInput} value={userInput}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}

View File

@ -218,6 +218,7 @@
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: 20px; padding: 20px;
position: relative;
} }
.chat-body-title { .chat-body-title {

View File

@ -149,11 +149,12 @@ export function Home() {
setOpenSettings(true); setOpenSettings(true);
setShowSideBar(false); setShowSideBar(false);
}} }}
shadow
/> />
</div> </div>
<div className={styles["sidebar-action"]}> <div className={styles["sidebar-action"]}>
<a href={REPO_URL} target="_blank"> <a href={REPO_URL} target="_blank">
<IconButton icon={<GithubIcon />} /> <IconButton icon={<GithubIcon />} shadow />
</a> </a>
</div> </div>
</div> </div>
@ -165,6 +166,7 @@ export function Home() {
createNewSession(); createNewSession();
setShowSideBar(false); setShowSideBar(false);
}} }}
shadow
/> />
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
.window-header { .window-header {
padding: 14px 20px; padding: 14px 20px;
border-bottom: rgba(0, 0, 0, 0.1) 1px solid; border-bottom: rgba(0, 0, 0, 0.1) 1px solid;
position: relative;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -138,7 +138,7 @@ const cn = {
Topic: Topic:
"使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”", "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,如果没有主题,请直接返回“闲聊”",
Summarize: Summarize:
"简要总结一下你和用户的对话,用作后续的上下文提示 prompt控制在 50 字以内", "简要总结一下你和用户的对话,用作后续的上下文提示 prompt控制在 200 字以内",
}, },
ConfirmClearAll: "确认清除所有聊天、设置数据?", ConfirmClearAll: "确认清除所有聊天、设置数据?",
}, },

View File

@ -142,7 +142,7 @@ const en: LocaleType = {
Topic: Topic:
"Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.", "Please generate a four to five word title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, or additional text. Remove enclosing quotation marks.",
Summarize: Summarize:
"Summarize our discussion briefly in 50 characters or less to use as a prompt for future context.", "Summarize our discussion briefly in 200 words or less to use as a prompt for future context.",
}, },
ConfirmClearAll: "Confirm to clear all chat and setting data?", ConfirmClearAll: "Confirm to clear all chat and setting data?",
}, },

View File

@ -142,7 +142,7 @@ const es: LocaleType = {
Topic: Topic:
"Por favor, genera un título de cuatro a cinco palabras que resuma nuestra conversación sin ningún inicio, puntuación, comillas, puntos, símbolos o texto adicional. Elimina las comillas que lo envuelven.", "Por favor, genera un título de cuatro a cinco palabras que resuma nuestra conversación sin ningún inicio, puntuación, comillas, puntos, símbolos o texto adicional. Elimina las comillas que lo envuelven.",
Summarize: Summarize:
"Resuma nuestra discusión brevemente en 50 caracteres o menos para usarlo como un recordatorio para futuros contextos.", "Resuma nuestra discusión brevemente en 200 caracteres o menos para usarlo como un recordatorio para futuros contextos.",
}, },
ConfirmClearAll: ConfirmClearAll:
"¿Confirmar para borrar todos los datos de chat y configuración?", "¿Confirmar para borrar todos los datos de chat y configuración?",

View File

@ -137,7 +137,7 @@ const tw: LocaleType = {
"這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content, "這是 AI 與用戶的歷史聊天總結,作為前情提要:" + content,
Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」", Topic: "直接返回這句話的簡要主題,無須解釋,若無主題,請直接返回「閒聊」",
Summarize: Summarize:
"簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 50 字以內", "簡要總結一下你和用戶的對話,作為後續的上下文提示 prompt且字數控制在 200 字以內",
}, },
ConfirmClearAll: "確認清除所有對話、設定數據?", ConfirmClearAll: "確認清除所有對話、設定數據?",
}, },

View File

@ -53,6 +53,8 @@ export interface ChatConfig {
export type ModelConfig = ChatConfig["modelConfig"]; export type ModelConfig = ChatConfig["modelConfig"];
export const ROLES: Message["role"][] = ["system", "user", "assistant"];
const ENABLE_GPT4 = true; const ENABLE_GPT4 = true;
export const ALL_MODELS = [ export const ALL_MODELS = [
@ -151,6 +153,7 @@ export interface ChatSession {
id: number; id: number;
topic: string; topic: string;
memoryPrompt: string; memoryPrompt: string;
context: Message[];
messages: Message[]; messages: Message[];
stat: ChatStat; stat: ChatStat;
lastUpdate: string; lastUpdate: string;
@ -158,7 +161,7 @@ export interface ChatSession {
} }
const DEFAULT_TOPIC = Locale.Store.DefaultTopic; const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
export const BOT_HELLO = { export const BOT_HELLO: Message = {
role: "assistant", role: "assistant",
content: Locale.Store.BotHello, content: Locale.Store.BotHello,
date: "", date: "",
@ -171,6 +174,7 @@ function createEmptySession(): ChatSession {
id: Date.now(), id: Date.now(),
topic: DEFAULT_TOPIC, topic: DEFAULT_TOPIC,
memoryPrompt: "", memoryPrompt: "",
context: [],
messages: [], messages: [],
stat: { stat: {
tokenCount: 0, tokenCount: 0,
@ -380,16 +384,18 @@ export const useChatStore = create<ChatStore>()(
const session = get().currentSession(); const session = get().currentSession();
const config = get().config; const config = get().config;
const n = session.messages.length; const n = session.messages.length;
const recentMessages = session.messages.slice(
Math.max(0, n - config.historyMessageCount),
);
const memoryPrompt = get().getMemoryPrompt(); const context = session.context.slice();
if (session.memoryPrompt) { if (session.memoryPrompt && session.memoryPrompt.length > 0) {
recentMessages.unshift(memoryPrompt); const memoryPrompt = get().getMemoryPrompt();
context.push(memoryPrompt);
} }
const recentMessages = context.concat(
session.messages.slice(Math.max(0, n - config.historyMessageCount)),
);
return recentMessages; return recentMessages;
}, },
@ -427,11 +433,13 @@ export const useChatStore = create<ChatStore>()(
let toBeSummarizedMsgs = session.messages.slice( let toBeSummarizedMsgs = session.messages.slice(
session.lastSummarizeIndex, session.lastSummarizeIndex,
); );
const historyMsgLength = countMessages(toBeSummarizedMsgs); const historyMsgLength = countMessages(toBeSummarizedMsgs);
if (historyMsgLength > 4000) { if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
const n = toBeSummarizedMsgs.length;
toBeSummarizedMsgs = toBeSummarizedMsgs.slice( toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
-config.historyMessageCount, Math.max(0, n - config.historyMessageCount),
); );
} }
@ -494,7 +502,16 @@ export const useChatStore = create<ChatStore>()(
}), }),
{ {
name: LOCAL_KEY, name: LOCAL_KEY,
version: 1, version: 1.1,
migrate(persistedState, version) {
const state = persistedState as ChatStore;
if (version === 1) {
state.sessions.forEach((s) => (s.context = []));
}
return state;
},
}, },
), ),
); );

View File

@ -117,7 +117,7 @@ body {
select { select {
border: var(--border-in-light); border: var(--border-in-light);
padding: 8px 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
appearance: none; appearance: none;
cursor: pointer; cursor: pointer;
@ -188,7 +188,7 @@ input[type="text"] {
appearance: none; appearance: none;
border-radius: 10px; border-radius: 10px;
border: var(--border-in-light); border: var(--border-in-light);
height: 32px; height: 36px;
box-sizing: border-box; box-sizing: border-box;
background: var(--white); background: var(--white);
color: var(--black); color: var(--black);
@ -256,3 +256,15 @@ pre {
} }
} }
} }
.clickable {
cursor: pointer;
div:not(.no-dark) > svg {
filter: invert(0.5);
}
&:hover {
filter: brightness(0.9);
}
}