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;
padding: 10px;
box-shadow: var(--card-shadow);
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden;
user-select: none;
}
.shadow {
box-shadow: var(--card-shadow);
}
.border {
border: var(--border-in-light);
}
.icon-button:hover {
filter: brightness(0.9);
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 {
margin-left: 5px;
font-size: 12px;

View File

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

View File

@ -11,6 +11,7 @@ import {
} from "../store";
import Locale from "../locales";
import { isMobileScreen } from "../utils";
export function ChatItem(props: {
onClick?: () => void;
@ -61,7 +62,10 @@ export function ChatList() {
key={i}
selected={i === selectedIndex}
onClick={() => selectSession(i)}
onDelete={() => confirm(Locale.Home.DeleteChat) && removeSession(i)}
onDelete={() =>
(!isMobileScreen() || confirm(Locale.Home.DeleteChat)) &&
removeSession(i)
}
/>
))}
</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 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 {
Message,
@ -16,6 +18,7 @@ import {
useChatStore,
ChatSession,
BOT_HELLO,
ROLES,
} from "../store";
import {
@ -33,8 +36,9 @@ import Locale from "../locales";
import { IconButton } from "./button";
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, {
loading: () => <LoadingIcon />,
@ -94,17 +98,50 @@ function exportMessages(messages: Message[], topic: string) {
});
}
function showMemoryPrompt(session: ChatSession) {
showModal({
title: `${Locale.Memory.Title} (${session.lastSummarizeIndex} of ${session.messages.length})`,
children: (
<div className="markdown-body">
<pre className={styles["export-content"]}>
{session.memoryPrompt || Locale.Memory.EmptyContent}
</pre>
function PromptToast(props: {
showModal: boolean;
setShowModal: (_: boolean) => void;
}) {
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 (
<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>
),
actions: [
{props.showModal && (
<div className="modal-mask">
<Modal
title="编辑前置上下文"
onClose={() => props.setShowModal(false)}
actions={[
<IconButton
key="copy"
icon={<CopyIcon />}
@ -112,8 +149,79 @@ function showMemoryPrompt(session: ChatSession) {
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() {
@ -172,9 +280,8 @@ function useScrollToBottom() {
// auto scroll
useLayoutEffect(() => {
const dom = scrollRef.current;
if (dom && autoScroll) {
dom.scrollTop = dom.scrollHeight;
setTimeout(() => (dom.scrollTop = dom.scrollHeight), 500);
}
});
@ -243,8 +350,12 @@ export function Chat(props: {
setPromptHints([]);
} else if (!chatStore.config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
if (text.startsWith("/") && text.length > 1) {
onSearch(text.slice(1));
if (text.startsWith("/")) {
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 context: RenderMessage[] = session.context.slice();
if (
context.length === 0 &&
session.messages.at(0)?.content !== BOT_HELLO.content
) {
context.push(BOT_HELLO);
}
// preview messages
const messages = (session.messages as RenderMessage[])
const messages = context
.concat(session.messages as RenderMessage[])
.concat(
isLoading
? [
@ -326,6 +447,8 @@ export function Chat(props: {
: [],
);
const [showPromptModal, setShowPromptModal] = useState(false);
return (
<div className={styles.chat} key={session.id}>
<div className={styles["window-header"]}>
@ -365,7 +488,7 @@ export function Chat(props: {
bordered
title={Locale.Chat.Actions.CompressedHistory}
onClick={() => {
showMemoryPrompt(session);
setShowPromptModal(true);
}}
/>
</div>
@ -380,6 +503,11 @@ export function Chat(props: {
/>
</div>
</div>
<PromptToast
showModal={showPromptModal}
setShowModal={setShowPromptModal}
/>
</div>
<div className={styles["chat-body"]} ref={scrollRef}>
@ -402,7 +530,10 @@ export function Chat(props: {
{Locale.Chat.Typing}
</div>
)}
<div className={styles["chat-message-item"]}>
<div
className={styles["chat-message-item"]}
onMouseOver={() => inputRef.current?.blur()}
>
{!isUser &&
!(message.preview || message.content.length === 0) && (
<div className={styles["chat-message-top-actions"]}>
@ -467,7 +598,7 @@ export function Chat(props: {
ref={inputRef}
className={styles["chat-input"]}
placeholder={Locale.Chat.Input(submitKey)}
rows={4}
rows={2}
onInput={(e) => onInput(e.currentTarget.value)}
value={userInput}
onKeyDown={onInputKeyDown}

View File

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

View File

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

View File

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

View File

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

View File

@ -142,7 +142,7 @@ const en: LocaleType = {
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.",
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?",
},

View File

@ -142,7 +142,7 @@ const es: LocaleType = {
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.",
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:
"¿Confirmar para borrar todos los datos de chat y configuración?",

View File

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

View File

@ -53,6 +53,8 @@ export interface ChatConfig {
export type ModelConfig = ChatConfig["modelConfig"];
export const ROLES: Message["role"][] = ["system", "user", "assistant"];
const ENABLE_GPT4 = true;
export const ALL_MODELS = [
@ -151,6 +153,7 @@ export interface ChatSession {
id: number;
topic: string;
memoryPrompt: string;
context: Message[];
messages: Message[];
stat: ChatStat;
lastUpdate: string;
@ -158,7 +161,7 @@ export interface ChatSession {
}
const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
export const BOT_HELLO = {
export const BOT_HELLO: Message = {
role: "assistant",
content: Locale.Store.BotHello,
date: "",
@ -171,6 +174,7 @@ function createEmptySession(): ChatSession {
id: Date.now(),
topic: DEFAULT_TOPIC,
memoryPrompt: "",
context: [],
messages: [],
stat: {
tokenCount: 0,
@ -380,16 +384,18 @@ export const useChatStore = create<ChatStore>()(
const session = get().currentSession();
const config = get().config;
const n = session.messages.length;
const recentMessages = session.messages.slice(
Math.max(0, n - config.historyMessageCount),
);
const context = session.context.slice();
if (session.memoryPrompt && session.memoryPrompt.length > 0) {
const memoryPrompt = get().getMemoryPrompt();
if (session.memoryPrompt) {
recentMessages.unshift(memoryPrompt);
context.push(memoryPrompt);
}
const recentMessages = context.concat(
session.messages.slice(Math.max(0, n - config.historyMessageCount)),
);
return recentMessages;
},
@ -427,11 +433,13 @@ export const useChatStore = create<ChatStore>()(
let toBeSummarizedMsgs = session.messages.slice(
session.lastSummarizeIndex,
);
const historyMsgLength = countMessages(toBeSummarizedMsgs);
if (historyMsgLength > 4000) {
if (historyMsgLength > get().config?.modelConfig?.max_tokens ?? 4000) {
const n = toBeSummarizedMsgs.length;
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
-config.historyMessageCount,
Math.max(0, n - config.historyMessageCount),
);
}
@ -494,7 +502,16 @@ export const useChatStore = create<ChatStore>()(
}),
{
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 {
border: var(--border-in-light);
padding: 8px 10px;
padding: 10px;
border-radius: 10px;
appearance: none;
cursor: pointer;
@ -188,7 +188,7 @@ input[type="text"] {
appearance: none;
border-radius: 10px;
border: var(--border-in-light);
height: 32px;
height: 36px;
box-sizing: border-box;
background: var(--white);
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);
}
}