Merge pull request #45 from Yidadaa/bugfix-0326
v1.3 Stop and Retry Button
@ -27,6 +27,7 @@ import Locale from "../locales";
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { REPO_URL } from "../constant";
|
import { REPO_URL } from "../constant";
|
||||||
|
import { ControllerPool } from "../requests";
|
||||||
|
|
||||||
export function Loading(props: { noLogo?: boolean }) {
|
export function Loading(props: { noLogo?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@ -146,28 +147,67 @@ function useSubmitHandler() {
|
|||||||
export function Chat(props: { showSideBar?: () => void }) {
|
export function Chat(props: { showSideBar?: () => void }) {
|
||||||
type RenderMessage = Message & { preview?: boolean };
|
type RenderMessage = Message & { preview?: boolean };
|
||||||
|
|
||||||
const session = useChatStore((state) => state.currentSession());
|
const [session, sessionIndex] = useChatStore((state) => [
|
||||||
|
state.currentSession(),
|
||||||
|
state.currentSessionIndex,
|
||||||
|
]);
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { submitKey, shouldSubmit } = useSubmitHandler();
|
const { submitKey, shouldSubmit } = useSubmitHandler();
|
||||||
|
|
||||||
const onUserInput = useChatStore((state) => state.onUserInput);
|
const onUserInput = useChatStore((state) => state.onUserInput);
|
||||||
|
|
||||||
|
// submit user input
|
||||||
const onUserSubmit = () => {
|
const onUserSubmit = () => {
|
||||||
if (userInput.length <= 0) return;
|
if (userInput.length <= 0) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
onUserInput(userInput).then(() => setIsLoading(false));
|
onUserInput(userInput).then(() => setIsLoading(false));
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// stop response
|
||||||
|
const onUserStop = (messageIndex: number) => {
|
||||||
|
console.log(ControllerPool, sessionIndex, messageIndex);
|
||||||
|
ControllerPool.stop(sessionIndex, messageIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// check if should send message
|
||||||
const onInputKeyDown = (e: KeyboardEvent) => {
|
const onInputKeyDown = (e: KeyboardEvent) => {
|
||||||
if (shouldSubmit(e)) {
|
if (shouldSubmit(e)) {
|
||||||
onUserSubmit();
|
onUserSubmit();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const onRightClick = (e: any, message: Message) => {
|
||||||
|
// auto fill user input
|
||||||
|
if (message.role === "user") {
|
||||||
|
setUserInput(message.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy to clipboard
|
||||||
|
if (selectOrCopy(e.currentTarget, message.content)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResend = (botIndex: number) => {
|
||||||
|
// find last user input message and resend
|
||||||
|
for (let i = botIndex; i >= 0; i -= 1) {
|
||||||
|
if (messages[i].role === "user") {
|
||||||
|
setIsLoading(true);
|
||||||
|
onUserInput(messages[i].content).then(() => setIsLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// for auto-scroll
|
||||||
const latestMessageRef = useRef<HTMLDivElement>(null);
|
const latestMessageRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [hoveringMessage, setHoveringMessage] = useState(false);
|
// wont scroll while hovering messages
|
||||||
|
const [autoScroll, setAutoScroll] = useState(false);
|
||||||
|
|
||||||
|
// preview messages
|
||||||
const messages = (session.messages as RenderMessage[])
|
const messages = (session.messages as RenderMessage[])
|
||||||
.concat(
|
.concat(
|
||||||
isLoading
|
isLoading
|
||||||
@ -194,10 +234,11 @@ export function Chat(props: { showSideBar?: () => void }) {
|
|||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// auto scroll
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const dom = latestMessageRef.current;
|
const dom = latestMessageRef.current;
|
||||||
if (dom && !isIOS() && !hoveringMessage) {
|
if (dom && !isIOS() && autoScroll) {
|
||||||
dom.scrollIntoView({
|
dom.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
block: "end",
|
block: "end",
|
||||||
@ -252,15 +293,7 @@ export function Chat(props: { showSideBar?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={styles["chat-body"]}>
|
||||||
className={styles["chat-body"]}
|
|
||||||
onMouseOver={() => {
|
|
||||||
setHoveringMessage(true);
|
|
||||||
}}
|
|
||||||
onMouseOut={() => {
|
|
||||||
setHoveringMessage(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.map((message, i) => {
|
{messages.map((message, i) => {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
@ -283,13 +316,20 @@ export function Chat(props: { showSideBar?: () => void }) {
|
|||||||
<div className={styles["chat-message-item"]}>
|
<div className={styles["chat-message-item"]}>
|
||||||
{!isUser && (
|
{!isUser && (
|
||||||
<div className={styles["chat-message-top-actions"]}>
|
<div className={styles["chat-message-top-actions"]}>
|
||||||
{message.streaming && (
|
{message.streaming ? (
|
||||||
<div
|
<div
|
||||||
className={styles["chat-message-top-action"]}
|
className={styles["chat-message-top-action"]}
|
||||||
onClick={() => showToast(Locale.WIP)}
|
onClick={() => onUserStop(i)}
|
||||||
>
|
>
|
||||||
{Locale.Chat.Actions.Stop}
|
{Locale.Chat.Actions.Stop}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={styles["chat-message-top-action"]}
|
||||||
|
onClick={() => onResend(i)}
|
||||||
|
>
|
||||||
|
{Locale.Chat.Actions.Retry}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -306,11 +346,7 @@ export function Chat(props: { showSideBar?: () => void }) {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="markdown-body"
|
className="markdown-body"
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => onRightClick(e, message)}
|
||||||
if (selectOrCopy(e.currentTarget, message.content)) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Markdown content={message.content} />
|
<Markdown content={message.content} />
|
||||||
</div>
|
</div>
|
||||||
@ -341,6 +377,9 @@ export function Chat(props: { showSideBar?: () => void }) {
|
|||||||
onInput={(e) => setUserInput(e.currentTarget.value)}
|
onInput={(e) => setUserInput(e.currentTarget.value)}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onKeyDown={(e) => onInputKeyDown(e as any)}
|
onKeyDown={(e) => onInputKeyDown(e as any)}
|
||||||
|
onFocus={() => setAutoScroll(true)}
|
||||||
|
onBlur={() => setAutoScroll(false)}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<SendWhiteIcon />}
|
icon={<SendWhiteIcon />}
|
||||||
|
@ -14,6 +14,7 @@ const cn = {
|
|||||||
Export: "导出聊天记录",
|
Export: "导出聊天记录",
|
||||||
Copy: "复制",
|
Copy: "复制",
|
||||||
Stop: "停止",
|
Stop: "停止",
|
||||||
|
Retry: "重试",
|
||||||
},
|
},
|
||||||
Typing: "正在输入…",
|
Typing: "正在输入…",
|
||||||
Input: (submitKey: string) => `输入消息,${submitKey} 发送`,
|
Input: (submitKey: string) => `输入消息,${submitKey} 发送`,
|
||||||
|
@ -17,6 +17,7 @@ const en: LocaleType = {
|
|||||||
Export: "Export All Messages as Markdown",
|
Export: "Export All Messages as Markdown",
|
||||||
Copy: "Copy",
|
Copy: "Copy",
|
||||||
Stop: "Stop",
|
Stop: "Stop",
|
||||||
|
Retry: "Retry",
|
||||||
},
|
},
|
||||||
Typing: "Typing…",
|
Typing: "Typing…",
|
||||||
Input: (submitKey: string) =>
|
Input: (submitKey: string) =>
|
||||||
|
@ -60,6 +60,7 @@ export async function requestChatStream(
|
|||||||
modelConfig?: ModelConfig;
|
modelConfig?: ModelConfig;
|
||||||
onMessage: (message: string, done: boolean) => void;
|
onMessage: (message: string, done: boolean) => void;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
|
onController?: (controller: AbortController) => void;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const req = makeRequestParam(messages, {
|
const req = makeRequestParam(messages, {
|
||||||
@ -96,12 +97,12 @@ export async function requestChatStream(
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(res);
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
options?.onController?.(controller);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// handle time out, will stop if no response in 10 secs
|
// handle time out, will stop if no response in 10 secs
|
||||||
const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
|
const resTimeoutId = setTimeout(() => finish(), TIME_OUT_MS);
|
||||||
@ -146,3 +147,34 @@ export async function requestWithPrompt(messages: Message[], prompt: string) {
|
|||||||
|
|
||||||
return res.choices.at(0)?.message?.content ?? "";
|
return res.choices.at(0)?.message?.content ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// To store message streaming controller
|
||||||
|
export const ControllerPool = {
|
||||||
|
controllers: {} as Record<string, AbortController>,
|
||||||
|
|
||||||
|
addController(
|
||||||
|
sessionIndex: number,
|
||||||
|
messageIndex: number,
|
||||||
|
controller: AbortController
|
||||||
|
) {
|
||||||
|
const key = this.key(sessionIndex, messageIndex);
|
||||||
|
this.controllers[key] = controller;
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
|
||||||
|
stop(sessionIndex: number, messageIndex: number) {
|
||||||
|
const key = this.key(sessionIndex, messageIndex);
|
||||||
|
const controller = this.controllers[key];
|
||||||
|
console.log(controller);
|
||||||
|
controller?.abort();
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(sessionIndex: number, messageIndex: number) {
|
||||||
|
const key = this.key(sessionIndex, messageIndex);
|
||||||
|
delete this.controllers[key];
|
||||||
|
},
|
||||||
|
|
||||||
|
key(sessionIndex: number, messageIndex: number) {
|
||||||
|
return `${sessionIndex},${messageIndex}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -2,7 +2,11 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
import { type ChatCompletionResponseMessage } from "openai";
|
import { type ChatCompletionResponseMessage } from "openai";
|
||||||
import { requestChatStream, requestWithPrompt } from "../requests";
|
import {
|
||||||
|
ControllerPool,
|
||||||
|
requestChatStream,
|
||||||
|
requestWithPrompt,
|
||||||
|
} from "../requests";
|
||||||
import { trimTopic } from "../utils";
|
import { trimTopic } from "../utils";
|
||||||
|
|
||||||
import Locale from "../locales";
|
import Locale from "../locales";
|
||||||
@ -296,6 +300,8 @@ export const useChatStore = create<ChatStore>()(
|
|||||||
// get recent messages
|
// get recent messages
|
||||||
const recentMessages = get().getMessagesWithMemory();
|
const recentMessages = get().getMessagesWithMemory();
|
||||||
const sendMessages = recentMessages.concat(userMessage);
|
const sendMessages = recentMessages.concat(userMessage);
|
||||||
|
const sessionIndex = get().currentSessionIndex;
|
||||||
|
const messageIndex = get().currentSession().messages.length + 1;
|
||||||
|
|
||||||
// save user's and bot's message
|
// save user's and bot's message
|
||||||
get().updateCurrentSession((session) => {
|
get().updateCurrentSession((session) => {
|
||||||
@ -303,13 +309,16 @@ export const useChatStore = create<ChatStore>()(
|
|||||||
session.messages.push(botMessage);
|
session.messages.push(botMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// make request
|
||||||
console.log("[User Input] ", sendMessages);
|
console.log("[User Input] ", sendMessages);
|
||||||
requestChatStream(sendMessages, {
|
requestChatStream(sendMessages, {
|
||||||
onMessage(content, done) {
|
onMessage(content, done) {
|
||||||
|
// stream response
|
||||||
if (done) {
|
if (done) {
|
||||||
botMessage.streaming = false;
|
botMessage.streaming = false;
|
||||||
botMessage.content = content;
|
botMessage.content = content;
|
||||||
get().onNewMessage(botMessage);
|
get().onNewMessage(botMessage);
|
||||||
|
ControllerPool.remove(sessionIndex, messageIndex);
|
||||||
} else {
|
} else {
|
||||||
botMessage.content = content;
|
botMessage.content = content;
|
||||||
set(() => ({}));
|
set(() => ({}));
|
||||||
@ -319,6 +328,15 @@ export const useChatStore = create<ChatStore>()(
|
|||||||
botMessage.content += "\n\n" + Locale.Store.Error;
|
botMessage.content += "\n\n" + Locale.Store.Error;
|
||||||
botMessage.streaming = false;
|
botMessage.streaming = false;
|
||||||
set(() => ({}));
|
set(() => ({}));
|
||||||
|
ControllerPool.remove(sessionIndex, messageIndex);
|
||||||
|
},
|
||||||
|
onController(controller) {
|
||||||
|
// collect controller for stop/retry
|
||||||
|
ControllerPool.addController(
|
||||||
|
sessionIndex,
|
||||||
|
messageIndex,
|
||||||
|
controller
|
||||||
|
);
|
||||||
},
|
},
|
||||||
filterBot: !get().config.sendBotMessages,
|
filterBot: !get().config.sendBotMessages,
|
||||||
modelConfig: get().config.modelConfig,
|
modelConfig: get().config.modelConfig,
|
||||||
|
Before Width: | Height: | Size: 728 B After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 728 B After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 728 B After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 633 B |
Before Width: | Height: | Size: 728 B After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 15 KiB |