-
+ <>
{couldStop && (
}
/>
+
+ }
+ onClick={() => {
+ chatStore.updateTargetSession(session, (session) => {
+ if (session.clearContextIndex === session.messages.length) {
+ session.clearContextIndex = undefined;
+ } else {
+ session.clearContextIndex = session.messages.length;
+ session.memoryPrompt = ""; // will clear memory
+ }
+ });
+ }}
+ />
+
{config.pluginConfig.enable && isFunctionCallModel(currentModel) && (
setShowModelSelector(false)}
onSelection={(s) => {
if (s.length === 0) return;
- const [model, providerName] = s[0].split("@");
- chatStore.updateCurrentSession((session) => {
+ const [model, providerName] = getModelProvider(s[0]);
+ chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.model = model as ModelType;
session.mask.modelConfig.providerName =
providerName as ServiceProvider;
@@ -744,7 +778,7 @@ export function ChatActions(props: {
onSelection={(s) => {
if (s.length === 0) return;
const size = s[0];
- chatStore.updateCurrentSession((session) => {
+ chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.size = size;
});
showToast(size);
@@ -771,7 +805,7 @@ export function ChatActions(props: {
onSelection={(q) => {
if (q.length === 0) return;
const quality = q[0];
- chatStore.updateCurrentSession((session) => {
+ chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.quality = quality;
});
showToast(quality);
@@ -798,29 +832,60 @@ export function ChatActions(props: {
onSelection={(s) => {
if (s.length === 0) return;
const style = s[0];
- chatStore.updateCurrentSession((session) => {
+ chatStore.updateTargetSession(session, (session) => {
session.mask.modelConfig.style = style;
});
showToast(style);
}}
/>
)}
-
-
-
}
- onClick={() => {
- chatStore.updateCurrentSession((session) => {
- if (session.clearContextIndex === session.messages.length) {
- session.clearContextIndex = undefined;
+
+ {showPlugins(currentProviderName, currentModel) && (
+
{
+ if (pluginStore.getAll().length == 0) {
+ navigate(Path.Plugins);
} else {
- session.clearContextIndex = session.messages.length;
- session.memoryPrompt = ""; // will clear memory
+ setShowPluginSelector(true);
}
- });
- }}
- />
+ }}
+ text={Locale.Plugin.Name}
+ icon={}
+ />
+ )}
+ {showPluginSelector && (
+ ({
+ title: `${item?.title}@${item?.version}`,
+ value: item?.id,
+ }))}
+ onClose={() => setShowPluginSelector(false)}
+ onSelection={(s) => {
+ chatStore.updateTargetSession(session, (session) => {
+ session.mask.plugin = s as string[];
+ });
+ }}
+ />
+ )}
+
+ {!isMobileScreen && (
+ props.setShowShortcutKeyModal(true)}
+ text={Locale.Chat.ShortcutKey.Title}
+ icon={}
+ />
+ )}
+ >
+
+ {config.realtimeConfig.enable && (
+ props.setShowChatSidePanel(true)}
+ text={Locale.Settings.Realtime.Enable.Title}
+ icon={}
+ />
+ )}
);
@@ -851,7 +916,8 @@ export function EditMessageModal(props: { onClose: () => void }) {
icon={
}
key="ok"
onClick={() => {
- chatStore.updateCurrentSession(
+ chatStore.updateTargetSession(
+ session,
(session) => (session.messages = messages),
);
props.onClose();
@@ -868,7 +934,8 @@ export function EditMessageModal(props: { onClose: () => void }) {
type="text"
value={session.topic}
onInput={(e) =>
- chatStore.updateCurrentSession(
+ chatStore.updateTargetSession(
+ session,
(session) => (session.topic = e.currentTarget.value),
)
}
@@ -904,6 +971,67 @@ export function DeleteFileButton(props: { deleteFile: () => void }) {
);
}
+export function ShortcutKeyModal(props: { onClose: () => void }) {
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
+ const shortcuts = [
+ {
+ title: Locale.Chat.ShortcutKey.newChat,
+ keys: isMac ? ["⌘", "Shift", "O"] : ["Ctrl", "Shift", "O"],
+ },
+ { title: Locale.Chat.ShortcutKey.focusInput, keys: ["Shift", "Esc"] },
+ {
+ title: Locale.Chat.ShortcutKey.copyLastCode,
+ keys: isMac ? ["⌘", "Shift", ";"] : ["Ctrl", "Shift", ";"],
+ },
+ {
+ title: Locale.Chat.ShortcutKey.copyLastMessage,
+ keys: isMac ? ["⌘", "Shift", "C"] : ["Ctrl", "Shift", "C"],
+ },
+ {
+ title: Locale.Chat.ShortcutKey.showShortcutKey,
+ keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"],
+ },
+ ];
+ return (
+
+
}
+ key="ok"
+ onClick={() => {
+ props.onClose();
+ }}
+ />,
+ ]}
+ >
+
+
+ {shortcuts.map((shortcut, index) => (
+
+
+ {shortcut.title}
+
+
+ {shortcut.keys.map((key, i) => (
+
+ {key}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ );
+}
+
function _Chat() {
type RenderMessage = ChatMessage & { preview?: boolean };
@@ -911,6 +1039,7 @@ function _Chat() {
const session = chatStore.currentSession();
const config = useAppConfig();
const fontSize = config.fontSize;
+ const fontFamily = config.fontFamily;
const [showExport, setShowExport] = useState(false);
@@ -925,9 +1054,24 @@ function _Chat() {
(scrollRef.current.scrollTop + scrollRef.current.clientHeight),
) <= 1
: false;
+ const isAttachWithTop = useMemo(() => {
+ const lastMessage = scrollRef.current?.lastElementChild as HTMLElement;
+ // if scrolllRef is not ready or no message, return false
+ if (!scrollRef?.current || !lastMessage) return false;
+ const topDistance =
+ lastMessage!.getBoundingClientRect().top -
+ scrollRef.current.getBoundingClientRect().top;
+ // leave some space for user question
+ return topDistance < 100;
+ }, [scrollRef?.current?.scrollHeight]);
+
+ const isTyping = userInput !== "";
+
+ // if user is typing, should auto scroll to bottom
+ // if user is not typing, should auto scroll to bottom only if already at bottom
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
scrollRef,
- isScrolledToBottom,
+ (isScrolledToBottom || isAttachWithTop) && !isTyping,
);
const [hitBottom, setHitBottom] = useState(true);
const isMobileScreen = useMobileScreen();
@@ -976,9 +1120,11 @@ function _Chat() {
prev: () => chatStore.nextSession(-1),
next: () => chatStore.nextSession(1),
clear: () =>
- chatStore.updateCurrentSession(
+ chatStore.updateTargetSession(
+ session,
(session) => (session.clearContextIndex = session.messages.length),
),
+ fork: () => chatStore.forkSession(),
del: () => chatStore.deleteSession(chatStore.currentSessionIndex),
});
@@ -991,7 +1137,7 @@ function _Chat() {
// clear search results
if (n === 0) {
setPromptHints([]);
- } else if (text.startsWith(ChatCommandPrefix)) {
+ } else if (text.match(ChatCommandPrefix)) {
setPromptHints(chatCommands.search(text));
} else if (!config.disablePromptHint && n < SEARCH_TEXT_LIMIT) {
// check if need to trigger auto completion
@@ -1030,7 +1176,7 @@ function _Chat() {
};
const doSubmit = (userInput: string) => {
- if (userInput.trim() === "") return;
+ if (userInput.trim() === "" && isEmpty(attachImages)) return;
const matchCommand = chatCommands.match(userInput);
if (matchCommand.matched) {
setUserInput("");
@@ -1040,11 +1186,11 @@ function _Chat() {
}
setIsLoading(true);
chatStore
- .onUserInput(userInput, attachImages, attachFiles)
+ .onUserInput(userInput, attachImages)
.then(() => setIsLoading(false));
setAttachImages([]);
setAttachFiles([]);
- localStorage.setItem(LAST_INPUT_KEY, userInput);
+ chatStore.setLastInput(userInput);
setUserInput("");
setPromptHints([]);
if (!isMobileScreen) inputRef.current?.focus();
@@ -1074,7 +1220,7 @@ function _Chat() {
};
useEffect(() => {
- chatStore.updateCurrentSession((session) => {
+ chatStore.updateTargetSession(session, (session) => {
const stopTiming = Date.now() - REQUEST_TIMEOUT_MS;
session.messages.forEach((m) => {
// check if should stop all stale messages
@@ -1110,7 +1256,7 @@ function _Chat() {
onRecognitionEnd(transcription),
),
);
- }, []);
+ }, [session]);
// check if should send message
const onInputKeyDown = (e: React.KeyboardEvent
) => {
@@ -1120,7 +1266,7 @@ function _Chat() {
userInput.length <= 0 &&
!(e.metaKey || e.altKey || e.ctrlKey)
) {
- setUserInput(localStorage.getItem(LAST_INPUT_KEY) ?? "");
+ setUserInput(chatStore.lastInput ?? "");
e.preventDefault();
return;
}
@@ -1141,7 +1287,8 @@ function _Chat() {
};
const deleteMessage = (msgId?: string) => {
- chatStore.updateCurrentSession(
+ chatStore.updateTargetSession(
+ session,
(session) =>
(session.messages = session.messages.filter((m) => m.id !== msgId)),
);
@@ -1210,7 +1357,7 @@ function _Chat() {
};
const onPinMessage = (message: ChatMessage) => {
- chatStore.updateCurrentSession((session) =>
+ chatStore.updateTargetSession(session, (session) =>
session.mask.context.push(message),
);
@@ -1222,6 +1369,7 @@ function _Chat() {
});
};
+ const accessStore = useAccessStore();
const [speechStatus, setSpeechStatus] = useState(false);
const [speechLoading, setSpeechLoading] = useState(false);
async function openaiSpeech(text: string) {
@@ -1270,7 +1418,6 @@ function _Chat() {
const context: RenderMessage[] = useMemo(() => {
return session.mask.hideContext ? [] : session.mask.context.slice();
}, [session.mask.context, session.mask.hideContext]);
- const accessStore = useAccessStore();
if (
context.length === 0 &&
@@ -1285,36 +1432,34 @@ function _Chat() {
// preview messages
const renderMessages = useMemo(() => {
- return (
- context
- .concat(session.messages as RenderMessage[])
- // .concat(
- // isLoading
- // ? [
- // {
- // ...createMessage({
- // role: "assistant",
- // content: "……",
- // }),
- // preview: true,
- // },
- // ]
- // : [],
- // )
- .concat(
- userInput.length > 0 && config.sendPreviewBubble
- ? [
- {
- ...createMessage({
- role: "user",
- content: userInput,
- }),
- preview: true,
- },
- ]
- : [],
- )
- );
+ return context
+ .concat(session.messages as RenderMessage[])
+ .concat(
+ isLoading
+ ? [
+ {
+ ...createMessage({
+ role: "assistant",
+ content: "……",
+ }),
+ preview: true,
+ },
+ ]
+ : [],
+ )
+ .concat(
+ userInput.length > 0 && config.sendPreviewBubble
+ ? [
+ {
+ ...createMessage({
+ role: "user",
+ content: userInput,
+ }),
+ preview: true,
+ },
+ ]
+ : [],
+ );
}, [
config.sendPreviewBubble,
context,
@@ -1575,455 +1720,528 @@ function _Chat() {
setAttachFiles(uploadFiles);
}
- return (
-
-
- {isMobileScreen && (
-
-
- }
- bordered
- title={Locale.Chat.Actions.ChatList}
- onClick={() => navigate(Path.Home)}
- />
-
-
- )}
+ // 快捷键 shortcut keys
+ const [showShortcutKeyModal, setShowShortcutKeyModal] = useState(false);
-
-
setIsEditingMessage(true)}
- >
- {!session.topic ? DEFAULT_TOPIC : session.topic}
-
-
- {Locale.Chat.SubTitle(session.messages.length)}
-
-
-
- {!isMobileScreen && (
-
-
}
- bordered
- onClick={() => setIsEditingMessage(true)}
- />
+ useEffect(() => {
+ const handleKeyDown = (event: any) => {
+ // 打开新聊天 command + shift + o
+ if (
+ (event.metaKey || event.ctrlKey) &&
+ event.shiftKey &&
+ event.key.toLowerCase() === "o"
+ ) {
+ event.preventDefault();
+ setTimeout(() => {
+ chatStore.newSession();
+ navigate(Path.Chat);
+ }, 10);
+ }
+ // 聚焦聊天输入 shift + esc
+ else if (event.shiftKey && event.key.toLowerCase() === "escape") {
+ event.preventDefault();
+ inputRef.current?.focus();
+ }
+ // 复制最后一个代码块 command + shift + ;
+ else if (
+ (event.metaKey || event.ctrlKey) &&
+ event.shiftKey &&
+ event.code === "Semicolon"
+ ) {
+ event.preventDefault();
+ const copyCodeButton =
+ document.querySelectorAll
(".copy-code-button");
+ if (copyCodeButton.length > 0) {
+ copyCodeButton[copyCodeButton.length - 1].click();
+ }
+ }
+ // 复制最后一个回复 command + shift + c
+ else if (
+ (event.metaKey || event.ctrlKey) &&
+ event.shiftKey &&
+ event.key.toLowerCase() === "c"
+ ) {
+ event.preventDefault();
+ const lastNonUserMessage = messages
+ .filter((message) => message.role !== "user")
+ .pop();
+ if (lastNonUserMessage) {
+ const lastMessageContent = getMessageTextContent(lastNonUserMessage);
+ copyToClipboard(lastMessageContent);
+ }
+ }
+ // 展示快捷键 command + /
+ else if ((event.metaKey || event.ctrlKey) && event.key === "/") {
+ event.preventDefault();
+ setShowShortcutKeyModal(true);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [messages, chatStore, navigate]);
+
+ const [showChatSidePanel, setShowChatSidePanel] = useState(false);
+
+ return (
+ <>
+
+
+ {isMobileScreen && (
+
+
+ }
+ bordered
+ title={Locale.Chat.Actions.ChatList}
+ onClick={() => navigate(Path.Home)}
+ />
+
)}
-
-
}
- bordered
- title={Locale.Chat.Actions.Export}
- onClick={() => {
- setShowExport(true);
- }}
- />
+
+
+
setIsEditingMessage(true)}
+ >
+ {!session.topic ? DEFAULT_TOPIC : session.topic}
+
+
+ {Locale.Chat.SubTitle(session.messages.length)}
+
- {showMaxIcon && (
+
: }
+ icon={}
bordered
+ title={Locale.Chat.Actions.RefreshTitle}
onClick={() => {
- config.update(
- (config) => (config.tightBorder = !config.tightBorder),
- );
+ showToast(Locale.Chat.Actions.RefreshToast);
+ chatStore.summarizeSession(true, session);
}}
/>
- )}
+ {!isMobileScreen && (
+
+ }
+ bordered
+ title={Locale.Chat.EditMessage.Title}
+ aria={Locale.Chat.EditMessage.Title}
+ onClick={() => setIsEditingMessage(true)}
+ />
+
+ )}
+
+ }
+ bordered
+ title={Locale.Chat.Actions.Export}
+ onClick={() => {
+ setShowExport(true);
+ }}
+ />
+
+ {showMaxIcon && (
+
+ : }
+ bordered
+ title={Locale.Chat.Actions.FullScreen}
+ aria={Locale.Chat.Actions.FullScreen}
+ onClick={() => {
+ config.update(
+ (config) => (config.tightBorder = !config.tightBorder),
+ );
+ }}
+ />
+
+ )}
+
+
+
+
+
+
onChatBodyScroll(e.currentTarget)}
+ onMouseDown={() => inputRef.current?.blur()}
+ onTouchStart={() => {
+ inputRef.current?.blur();
+ setAutoScroll(false);
+ }}
+ >
+ {messages.map((message, i) => {
+ const isUser = message.role === "user";
+ const isContext = i < context.length;
+ const showActions =
+ i > 0 &&
+ !(message.preview || message.content.length === 0) &&
+ !isContext;
+ const showTyping = message.preview || message.streaming;
-
-
+ const shouldShowClearContextDivider =
+ i === clearContextIndex - 1;
-
onChatBodyScroll(e.currentTarget)}
- onMouseDown={() => inputRef.current?.blur()}
- onTouchStart={() => {
- inputRef.current?.blur();
- setAutoScroll(false);
- }}
- >
- {messages.map((message, i) => {
- const isUser = message.role === "user";
- const isContext = i < context.length;
- const showActions =
- i > 0 &&
- !(message.preview || message.content.length === 0) &&
- !isContext;
- const showTyping = message.preview || message.streaming;
-
- const shouldShowClearContextDivider = i === clearContextIndex - 1;
-
- return (
-
-
-
-
-
-
- }
- onClick={async () => {
- const newMessage = await showPrompt(
- Locale.Chat.Actions.Edit,
- getMessageTextContent(message),
- 10,
- );
- let newContent: string | MultimodalContent[] =
- newMessage;
- const images = getMessageImages(message);
- if (images.length > 0) {
- newContent = [{ type: "text", text: newMessage }];
- for (let i = 0; i < images.length; i++) {
- newContent.push({
- type: "image_url",
- image_url: {
- url: images[i],
- },
- });
- }
- }
- chatStore.updateCurrentSession((session) => {
- const m = session.mask.context
- .concat(session.messages)
- .find((m) => m.id === message.id);
- if (m) {
- m.content = newContent;
- }
- });
- }}
- >
-
- {isUser ? (
-
- ) : (
- <>
- {["system"].includes(message.role) ? (
-
- ) : (
-
- )}
- >
- )}
-
-
- {showActions && (
-
-
- {message.streaming ? (
- }
- onClick={() => onUserStop(message.id ?? i)}
- />
- ) : (
- <>
- }
- onClick={() => onResend(message)}
- />
-
- }
- onClick={() => onDelete(message.id ?? i)}
- />
-
- }
- onClick={() => onPinMessage(message)}
- />
- }
- onClick={() =>
- copyToClipboard(
- getMessageTextContent(message),
- )
- }
- />
- {config.ttsConfig.enable && (
-
- ) : (
-
- )
- }
- onClick={() =>
- openaiSpeech(getMessageTextContent(message))
- }
- />
- )}
- >
- )}
-
-
- )}
-
- {!isUser &&
- message.toolMessages &&
- message.toolMessages.map((tool, index) => (
-
-
-
- {tool.toolName}:
-
- {tool.toolInput}
-
-
-
- ))}
-
- {showTyping && (
-
- {Locale.Chat.Typing}
-
- )}
-
-
+ onRightClick(e, message)}
- onDoubleClickCapture={() => {
- if (!isMobileScreen) return;
- setUserInput(getMessageTextContent(message));
- }}
- fontSize={fontSize}
- parentRef={scrollRef}
- defaultShow={i >= messages.length - 6}
- />
- {/* {message.fileInfos && message.fileInfos.length > 0 && (
-
- )} */}
- {getMessageImages(message).length == 1 && (
-
[0]})
- )}
- {getMessageImages(message).length > 1 && (
-
- {getMessageImages(message).map((image, index) => {
- return (
+ >
+
+
+
+
+ }
+ aria={Locale.Chat.Actions.Edit}
+ onClick={async () => {
+ const newMessage = await showPrompt(
+ Locale.Chat.Actions.Edit,
+ getMessageTextContent(message),
+ 10,
+ );
+ let newContent: string | MultimodalContent[] =
+ newMessage;
+ const images = getMessageImages(message);
+ if (images.length > 0) {
+ newContent = [
+ { type: "text", text: newMessage },
+ ];
+ for (let i = 0; i < images.length; i++) {
+ newContent.push({
+ type: "image_url",
+ image_url: {
+ url: images[i],
+ },
+ });
+ }
+ }
+ chatStore.updateTargetSession(
+ session,
+ (session) => {
+ const m = session.mask.context
+ .concat(session.messages)
+ .find((m) => m.id === message.id);
+ if (m) {
+ m.content = newContent;
+ }
+ },
+ );
+ }}
+ >
+
+ {isUser ? (
+
+ ) : (
+ <>
+ {["system"].includes(message.role) ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {!isUser && (
+
+ {message.model}
+
+ )}
+
+ {showActions && (
+
+
+ {message.streaming ? (
+ }
+ onClick={() => onUserStop(message.id ?? i)}
+ />
+ ) : (
+ <>
+ }
+ onClick={() => onResend(message)}
+ />
+
+ }
+ onClick={() => onDelete(message.id ?? i)}
+ />
+
+ }
+ onClick={() => onPinMessage(message)}
+ />
+ }
+ onClick={() =>
+ copyToClipboard(
+ getMessageTextContent(message),
+ )
+ }
+ />
+ {config.ttsConfig.enable && (
+
+ ) : (
+
+ )
+ }
+ onClick={() =>
+ openaiSpeech(
+ getMessageTextContent(message),
+ )
+ }
+ />
+ )}
+ >
+ )}
+
+
+ )}
+
+ {message?.tools?.length == 0 && showTyping && (
+
+ {Locale.Chat.Typing}
+
+ )}
+ {/*@ts-ignore*/}
+ {message?.tools?.length > 0 && (
+
+ {message?.tools?.map((tool) => (
+
+ {tool.isError === false ? (
+
+ ) : tool.isError === true ? (
+
+ ) : (
+
+ )}
+ {tool?.function?.name}
+
+ ))}
+
+ )}
+
+
onRightClick(e, message)} // hard to use
+ onDoubleClickCapture={() => {
+ if (!isMobileScreen) return;
+ setUserInput(getMessageTextContent(message));
+ }}
+ fontSize={fontSize}
+ fontFamily={fontFamily}
+ parentRef={scrollRef}
+ defaultShow={i >= messages.length - 6}
+ />
+ {getMessageImages(message).length == 1 && (
- );
- })}
+ )}
+ {getMessageImages(message).length > 1 && (
+
+ {getMessageImages(message).map((image, index) => {
+ return (
+

+ );
+ })}
+
+ )}
+
+ {message?.audio_url && (
+
+ )}
+
+
+ {isContext
+ ? Locale.Chat.IsContext
+ : message.date.toLocaleString()}
+
- )}
-
-
- {isContext
- ? Locale.Chat.IsContext
- : message.date.toLocaleString()}
-
-
-
- {shouldShowClearContextDivider &&
}
-
- );
- })}
-
-
-
-
-
-
setShowPromptModal(true)}
- scrollToBottom={scrollToBottom}
- hitBottom={hitBottom}
- uploading={uploading}
- showPromptHints={() => {
- // Click again to close
- if (promptHints.length > 0) {
- setPromptHints([]);
- return;
- }
-
- inputRef.current?.focus();
- setUserInput("/");
- onSearch("");
- }}
- />
-
-
+
+
+
setShowPromptModal(true)}
+ scrollToBottom={scrollToBottom}
+ hitBottom={hitBottom}
+ uploading={uploading}
+ showPromptHints={() => {
+ // Click again to close
+ if (promptHints.length > 0) {
+ setPromptHints([]);
+ return;
+ }
+
+ inputRef.current?.focus();
+ setUserInput("/");
+ onSearch("");
+ }}
+ setShowShortcutKeyModal={setShowShortcutKeyModal}
+ setUserInput={setUserInput}
+ setShowChatSidePanel={setShowChatSidePanel}
+ />
+
+
+
+
+ {showChatSidePanel && (
+ {
+ setShowChatSidePanel(false);
+ }}
+ onStartVoice={async () => {
+ console.log("start voice");
+ }}
+ />
+ )}
+
+
+
{showExport && (
setShowExport(false)} />
)}
@@ -2035,7 +2253,11 @@ function _Chat() {
}}
/>
)}
-
+
+ {showShortcutKeyModal && (
+
setShowShortcutKeyModal(false)} />
+ )}
+ >
);
}
diff --git a/app/components/realtime-chat/index.ts b/app/components/realtime-chat/index.ts
new file mode 100644
index 000000000..fdf090f41
--- /dev/null
+++ b/app/components/realtime-chat/index.ts
@@ -0,0 +1 @@
+export * from "./realtime-chat";
diff --git a/app/components/realtime-chat/realtime-chat.module.scss b/app/components/realtime-chat/realtime-chat.module.scss
new file mode 100644
index 000000000..ef58bebb6
--- /dev/null
+++ b/app/components/realtime-chat/realtime-chat.module.scss
@@ -0,0 +1,74 @@
+.realtime-chat {
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 20px;
+ box-sizing: border-box;
+ .circle-mic {
+ width: 150px;
+ height: 150px;
+ border-radius: 50%;
+ background: linear-gradient(to bottom right, #a0d8ef, #f0f8ff);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ .icon-center {
+ font-size: 24px;
+ }
+
+ .bottom-icons {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ position: absolute;
+ bottom: 20px;
+ box-sizing: border-box;
+ padding: 0 20px;
+ }
+
+ .icon-left,
+ .icon-right {
+ width: 46px;
+ height: 46px;
+ font-size: 36px;
+ background: var(--second);
+ border-radius: 50%;
+ padding: 2px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+
+ &.mobile {
+ display: none;
+ }
+}
+
+.pulse {
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0% {
+ transform: scale(1);
+ opacity: 0.7;
+ }
+ 50% {
+ transform: scale(1.1);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 0.7;
+ }
+}
diff --git a/app/components/realtime-chat/realtime-chat.tsx b/app/components/realtime-chat/realtime-chat.tsx
new file mode 100644
index 000000000..faa36373a
--- /dev/null
+++ b/app/components/realtime-chat/realtime-chat.tsx
@@ -0,0 +1,359 @@
+import VoiceIcon from "@/app/icons/voice.svg";
+import VoiceOffIcon from "@/app/icons/voice-off.svg";
+import PowerIcon from "@/app/icons/power.svg";
+
+import styles from "./realtime-chat.module.scss";
+import clsx from "clsx";
+
+import { useState, useRef, useEffect } from "react";
+
+import { useChatStore, createMessage, useAppConfig } from "@/app/store";
+
+import { IconButton } from "@/app/components/button";
+
+import {
+ Modality,
+ RTClient,
+ RTInputAudioItem,
+ RTResponse,
+ TurnDetection,
+} from "rt-client";
+import { AudioHandler } from "@/app/lib/audio";
+import { uploadImage } from "@/app/utils/chat";
+import { VoicePrint } from "@/app/components/voice-print";
+
+interface RealtimeChatProps {
+ onClose?: () => void;
+ onStartVoice?: () => void;
+ onPausedVoice?: () => void;
+}
+
+export function RealtimeChat({
+ onClose,
+ onStartVoice,
+ onPausedVoice,
+}: RealtimeChatProps) {
+ const chatStore = useChatStore();
+ const session = chatStore.currentSession();
+ const config = useAppConfig();
+ const [status, setStatus] = useState("");
+ const [isRecording, setIsRecording] = useState(false);
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [modality, setModality] = useState("audio");
+ const [useVAD, setUseVAD] = useState(true);
+ const [frequencies, setFrequencies] = useState();
+
+ const clientRef = useRef(null);
+ const audioHandlerRef = useRef(null);
+ const initRef = useRef(false);
+
+ const temperature = config.realtimeConfig.temperature;
+ const apiKey = config.realtimeConfig.apiKey;
+ const model = config.realtimeConfig.model;
+ const azure = config.realtimeConfig.provider === "Azure";
+ const azureEndpoint = config.realtimeConfig.azure.endpoint;
+ const azureDeployment = config.realtimeConfig.azure.deployment;
+ const voice = config.realtimeConfig.voice;
+
+ const handleConnect = async () => {
+ if (isConnecting) return;
+ if (!isConnected) {
+ try {
+ setIsConnecting(true);
+ clientRef.current = azure
+ ? new RTClient(
+ new URL(azureEndpoint),
+ { key: apiKey },
+ { deployment: azureDeployment },
+ )
+ : new RTClient({ key: apiKey }, { model });
+ const modalities: Modality[] =
+ modality === "audio" ? ["text", "audio"] : ["text"];
+ const turnDetection: TurnDetection = useVAD
+ ? { type: "server_vad" }
+ : null;
+ await clientRef.current.configure({
+ instructions: "",
+ voice,
+ input_audio_transcription: { model: "whisper-1" },
+ turn_detection: turnDetection,
+ tools: [],
+ temperature,
+ modalities,
+ });
+ startResponseListener();
+
+ setIsConnected(true);
+ // TODO
+ // try {
+ // const recentMessages = chatStore.getMessagesWithMemory();
+ // for (const message of recentMessages) {
+ // const { role, content } = message;
+ // if (typeof content === "string") {
+ // await clientRef.current.sendItem({
+ // type: "message",
+ // role: role as any,
+ // content: [
+ // {
+ // type: (role === "assistant" ? "text" : "input_text") as any,
+ // text: content as string,
+ // },
+ // ],
+ // });
+ // }
+ // }
+ // // await clientRef.current.generateResponse();
+ // } catch (error) {
+ // console.error("Set message failed:", error);
+ // }
+ } catch (error) {
+ console.error("Connection failed:", error);
+ setStatus("Connection failed");
+ } finally {
+ setIsConnecting(false);
+ }
+ } else {
+ await disconnect();
+ }
+ };
+
+ const disconnect = async () => {
+ if (clientRef.current) {
+ try {
+ await clientRef.current.close();
+ clientRef.current = null;
+ setIsConnected(false);
+ } catch (error) {
+ console.error("Disconnect failed:", error);
+ }
+ }
+ };
+
+ const startResponseListener = async () => {
+ if (!clientRef.current) return;
+
+ try {
+ for await (const serverEvent of clientRef.current.events()) {
+ if (serverEvent.type === "response") {
+ await handleResponse(serverEvent);
+ } else if (serverEvent.type === "input_audio") {
+ await handleInputAudio(serverEvent);
+ }
+ }
+ } catch (error) {
+ if (clientRef.current) {
+ console.error("Response iteration error:", error);
+ }
+ }
+ };
+
+ const handleResponse = async (response: RTResponse) => {
+ for await (const item of response) {
+ if (item.type === "message" && item.role === "assistant") {
+ const botMessage = createMessage({
+ role: item.role,
+ content: "",
+ });
+ // add bot message first
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat([botMessage]);
+ });
+ let hasAudio = false;
+ for await (const content of item) {
+ if (content.type === "text") {
+ for await (const text of content.textChunks()) {
+ botMessage.content += text;
+ }
+ } else if (content.type === "audio") {
+ const textTask = async () => {
+ for await (const text of content.transcriptChunks()) {
+ botMessage.content += text;
+ }
+ };
+ const audioTask = async () => {
+ audioHandlerRef.current?.startStreamingPlayback();
+ for await (const audio of content.audioChunks()) {
+ hasAudio = true;
+ audioHandlerRef.current?.playChunk(audio);
+ }
+ };
+ await Promise.all([textTask(), audioTask()]);
+ }
+ // update message.content
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ }
+ if (hasAudio) {
+ // upload audio get audio_url
+ const blob = audioHandlerRef.current?.savePlayFile();
+ uploadImage(blob!).then((audio_url) => {
+ botMessage.audio_url = audio_url;
+ // update text and audio_url
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ });
+ }
+ }
+ }
+ };
+
+ const handleInputAudio = async (item: RTInputAudioItem) => {
+ await item.waitForCompletion();
+ if (item.transcription) {
+ const userMessage = createMessage({
+ role: "user",
+ content: item.transcription,
+ });
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat([userMessage]);
+ });
+ // save input audio_url, and update session
+ const { audioStartMillis, audioEndMillis } = item;
+ // upload audio get audio_url
+ const blob = audioHandlerRef.current?.saveRecordFile(
+ audioStartMillis,
+ audioEndMillis,
+ );
+ uploadImage(blob!).then((audio_url) => {
+ userMessage.audio_url = audio_url;
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ });
+ }
+ // stop streaming play after get input audio.
+ audioHandlerRef.current?.stopStreamingPlayback();
+ };
+
+ const toggleRecording = async () => {
+ if (!isRecording && clientRef.current) {
+ try {
+ if (!audioHandlerRef.current) {
+ audioHandlerRef.current = new AudioHandler();
+ await audioHandlerRef.current.initialize();
+ }
+ await audioHandlerRef.current.startRecording(async (chunk) => {
+ await clientRef.current?.sendAudio(chunk);
+ });
+ setIsRecording(true);
+ } catch (error) {
+ console.error("Failed to start recording:", error);
+ }
+ } else if (audioHandlerRef.current) {
+ try {
+ audioHandlerRef.current.stopRecording();
+ if (!useVAD) {
+ const inputAudio = await clientRef.current?.commitAudio();
+ await handleInputAudio(inputAudio!);
+ await clientRef.current?.generateResponse();
+ }
+ setIsRecording(false);
+ } catch (error) {
+ console.error("Failed to stop recording:", error);
+ }
+ }
+ };
+
+ useEffect(() => {
+ // 防止重复初始化
+ if (initRef.current) return;
+ initRef.current = true;
+
+ const initAudioHandler = async () => {
+ const handler = new AudioHandler();
+ await handler.initialize();
+ audioHandlerRef.current = handler;
+ await handleConnect();
+ await toggleRecording();
+ };
+
+ initAudioHandler().catch((error) => {
+ setStatus(error);
+ console.error(error);
+ });
+
+ return () => {
+ if (isRecording) {
+ toggleRecording();
+ }
+ audioHandlerRef.current?.close().catch(console.error);
+ disconnect();
+ };
+ }, []);
+
+ useEffect(() => {
+ let animationFrameId: number;
+
+ if (isConnected && isRecording) {
+ const animationFrame = () => {
+ if (audioHandlerRef.current) {
+ const freqData = audioHandlerRef.current.getByteFrequencyData();
+ setFrequencies(freqData);
+ }
+ animationFrameId = requestAnimationFrame(animationFrame);
+ };
+
+ animationFrameId = requestAnimationFrame(animationFrame);
+ } else {
+ setFrequencies(undefined);
+ }
+
+ return () => {
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ }
+ };
+ }, [isConnected, isRecording]);
+
+ // update session params
+ useEffect(() => {
+ clientRef.current?.configure({ voice });
+ }, [voice]);
+ useEffect(() => {
+ clientRef.current?.configure({ temperature });
+ }, [temperature]);
+
+ const handleClose = async () => {
+ onClose?.();
+ if (isRecording) {
+ await toggleRecording();
+ }
+ disconnect().catch(console.error);
+ };
+
+ return (
+
+
+
+
+
+
+
+ : }
+ onClick={toggleRecording}
+ disabled={!isConnected}
+ shadow
+ bordered
+ />
+
+
{status}
+
+ }
+ onClick={handleClose}
+ shadow
+ bordered
+ />
+
+
+
+ );
+}
diff --git a/app/components/realtime-chat/realtime-config.tsx b/app/components/realtime-chat/realtime-config.tsx
new file mode 100644
index 000000000..08809afda
--- /dev/null
+++ b/app/components/realtime-chat/realtime-config.tsx
@@ -0,0 +1,173 @@
+import { RealtimeConfig } from "@/app/store";
+
+import Locale from "@/app/locales";
+import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib";
+
+import { InputRange } from "@/app/components/input-range";
+import { Voice } from "rt-client";
+import { ServiceProvider } from "@/app/constant";
+
+const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure];
+
+const models = ["gpt-4o-realtime-preview-2024-10-01"];
+
+const voice = ["alloy", "shimmer", "echo"];
+
+export function RealtimeConfigList(props: {
+ realtimeConfig: RealtimeConfig;
+ updateConfig: (updater: (config: RealtimeConfig) => void) => void;
+}) {
+ const azureConfigComponent = props.realtimeConfig.provider ===
+ ServiceProvider.Azure && (
+ <>
+
+ {
+ props.updateConfig(
+ (config) => (config.azure.endpoint = e.currentTarget.value),
+ );
+ }}
+ />
+
+
+ {
+ props.updateConfig(
+ (config) => (config.azure.deployment = e.currentTarget.value),
+ );
+ }}
+ />
+
+ >
+ );
+
+ return (
+ <>
+
+
+ props.updateConfig(
+ (config) => (config.enable = e.currentTarget.checked),
+ )
+ }
+ >
+
+
+ {props.realtimeConfig.enable && (
+ <>
+
+
+
+
+
+
+
+ {
+ props.updateConfig(
+ (config) => (config.apiKey = e.currentTarget.value),
+ );
+ }}
+ />
+
+ {azureConfigComponent}
+
+
+
+
+ {
+ props.updateConfig(
+ (config) =>
+ (config.temperature = e.currentTarget.valueAsNumber),
+ );
+ }}
+ >
+
+ >
+ )}
+ >
+ );
+}
diff --git a/app/components/settings.tsx b/app/components/settings.tsx
index aa866b506..c5191e0b5 100644
--- a/app/components/settings.tsx
+++ b/app/components/settings.tsx
@@ -9,6 +9,7 @@ import CopyIcon from "../icons/copy.svg";
import ClearIcon from "../icons/clear.svg";
import LoadingIcon from "../icons/three-dots.svg";
import EditIcon from "../icons/edit.svg";
+import FireIcon from "../icons/fire.svg";
import EyeIcon from "../icons/eye.svg";
import DownloadIcon from "../icons/download.svg";
import UploadIcon from "../icons/upload.svg";
@@ -18,7 +19,7 @@ import ConfirmIcon from "../icons/confirm.svg";
import ConnectionIcon from "../icons/connection.svg";
import CloudSuccessIcon from "../icons/cloud-success.svg";
import CloudFailIcon from "../icons/cloud-fail.svg";
-
+import { trackSettingsPageGuideToCPaymentClick } from "../utils/auth-settings-events";
import {
Input,
List,
@@ -84,6 +85,7 @@ import { PluginConfigList } from "./plugin-config";
import { useMaskStore } from "../store/mask";
import { ProviderType } from "../utils/cloud";
import { TTSConfigList } from "./tts-config";
+import { RealtimeConfigList } from "./realtime-chat/realtime-config";
import { STTConfigList } from "./stt-config";
function EditPromptModal(props: { id: string; onClose: () => void }) {
@@ -1748,9 +1750,11 @@ export function Settings() {
setShowPromptModal(false)} />
)}
-
+
+ {
+ const realtimeConfig = { ...config.realtimeConfig };
+ updater(realtimeConfig);
+ config.update(
+ (config) => (config.realtimeConfig = realtimeConfig),
+ );
+ }}
+ />
+
-
(null);
+ // 存储历史频率数据,用于平滑处理
+ const historyRef = useRef([]);
+ // 控制保留的历史数据帧数,影响平滑度
+ const historyLengthRef = useRef(10);
+ // 存储动画帧ID,用于清理
+ const animationFrameRef = useRef();
+
+ /**
+ * 更新频率历史数据
+ * 使用FIFO队列维护固定长度的历史记录
+ */
+ const updateHistory = useCallback((freqArray: number[]) => {
+ historyRef.current.push(freqArray);
+ if (historyRef.current.length > historyLengthRef.current) {
+ historyRef.current.shift();
+ }
+ }, []);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ /**
+ * 处理高DPI屏幕显示
+ * 根据设备像素比例调整canvas实际渲染分辨率
+ */
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = canvas.offsetWidth * dpr;
+ canvas.height = canvas.offsetHeight * dpr;
+ ctx.scale(dpr, dpr);
+
+ /**
+ * 主要绘制函数
+ * 使用requestAnimationFrame实现平滑动画
+ * 包含以下步骤:
+ * 1. 清空画布
+ * 2. 更新历史数据
+ * 3. 计算波形点
+ * 4. 绘制上下对称的声纹
+ */
+ const draw = () => {
+ // 清空画布
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ if (!frequencies || !isActive) {
+ historyRef.current = [];
+ return;
+ }
+
+ const freqArray = Array.from(frequencies);
+ updateHistory(freqArray);
+
+ // 绘制声纹
+ const points: [number, number][] = [];
+ const centerY = canvas.height / 2;
+ const width = canvas.width;
+ const sliceWidth = width / (frequencies.length - 1);
+
+ // 绘制主波形
+ ctx.beginPath();
+ ctx.moveTo(0, centerY);
+
+ /**
+ * 声纹绘制算法:
+ * 1. 使用历史数据平均值实现平滑过渡
+ * 2. 通过正弦函数添加自然波动
+ * 3. 使用贝塞尔曲线连接点,使曲线更平滑
+ * 4. 绘制对称部分形成完整声纹
+ */
+ for (let i = 0; i < frequencies.length; i++) {
+ const x = i * sliceWidth;
+ let avgFrequency = frequencies[i];
+
+ /**
+ * 波形平滑处理:
+ * 1. 收集历史数据中对应位置的频率值
+ * 2. 计算当前值与历史值的加权平均
+ * 3. 根据平均值计算实际显示高度
+ */
+ if (historyRef.current.length > 0) {
+ const historicalValues = historyRef.current.map((h) => h[i] || 0);
+ avgFrequency =
+ (avgFrequency + historicalValues.reduce((a, b) => a + b, 0)) /
+ (historyRef.current.length + 1);
+ }
+
+ /**
+ * 波形变换:
+ * 1. 归一化频率值到0-1范围
+ * 2. 添加时间相关的正弦变换
+ * 3. 使用贝塞尔曲线平滑连接点
+ */
+ const normalized = avgFrequency / 255.0;
+ const height = normalized * (canvas.height / 2);
+ const y = centerY + height * Math.sin(i * 0.2 + Date.now() * 0.002);
+
+ points.push([x, y]);
+
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ // 使用贝塞尔曲线使波形更平滑
+ const prevPoint = points[i - 1];
+ const midX = (prevPoint[0] + x) / 2;
+ ctx.quadraticCurveTo(
+ prevPoint[0],
+ prevPoint[1],
+ midX,
+ (prevPoint[1] + y) / 2,
+ );
+ }
+ }
+
+ // 绘制对称的下半部分
+ for (let i = points.length - 1; i >= 0; i--) {
+ const [x, y] = points[i];
+ const symmetricY = centerY - (y - centerY);
+ if (i === points.length - 1) {
+ ctx.lineTo(x, symmetricY);
+ } else {
+ const nextPoint = points[i + 1];
+ const midX = (nextPoint[0] + x) / 2;
+ ctx.quadraticCurveTo(
+ nextPoint[0],
+ centerY - (nextPoint[1] - centerY),
+ midX,
+ centerY - ((nextPoint[1] + y) / 2 - centerY),
+ );
+ }
+ }
+
+ ctx.closePath();
+
+ /**
+ * 渐变效果:
+ * 从左到右应用三色渐变,带透明度
+ * 使用蓝色系配色提升视觉效果
+ */
+ const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
+ gradient.addColorStop(0, "rgba(100, 180, 255, 0.95)");
+ gradient.addColorStop(0.5, "rgba(140, 200, 255, 0.9)");
+ gradient.addColorStop(1, "rgba(180, 220, 255, 0.95)");
+
+ ctx.fillStyle = gradient;
+ ctx.fill();
+
+ animationFrameRef.current = requestAnimationFrame(draw);
+ };
+
+ // 启动动画循环
+ draw();
+
+ // 清理函数:在组件卸载时取消动画
+ return () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current);
+ }
+ };
+ }, [frequencies, isActive, updateHistory]);
+
+ return (
+
+
+
+ );
+}
diff --git a/app/icons/arrow.svg b/app/icons/arrow.svg
new file mode 100644
index 000000000..ddd69e614
--- /dev/null
+++ b/app/icons/arrow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/icons/fire.svg b/app/icons/fire.svg
new file mode 100644
index 000000000..446d532aa
--- /dev/null
+++ b/app/icons/fire.svg
@@ -0,0 +1 @@
+
diff --git a/app/icons/headphone.svg b/app/icons/headphone.svg
new file mode 100644
index 000000000..287e3add8
--- /dev/null
+++ b/app/icons/headphone.svg
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/app/icons/logo.svg b/app/icons/logo.svg
new file mode 100644
index 000000000..b80263b86
--- /dev/null
+++ b/app/icons/logo.svg
@@ -0,0 +1,19 @@
+
diff --git a/app/icons/power.svg b/app/icons/power.svg
new file mode 100644
index 000000000..f60fc4266
--- /dev/null
+++ b/app/icons/power.svg
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/icons/voice-off.svg b/app/icons/voice-off.svg
new file mode 100644
index 000000000..d4aae988a
--- /dev/null
+++ b/app/icons/voice-off.svg
@@ -0,0 +1,13 @@
+
+
\ No newline at end of file
diff --git a/app/icons/voice.svg b/app/icons/voice.svg
new file mode 100644
index 000000000..2d8536042
--- /dev/null
+++ b/app/icons/voice.svg
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/app/lib/audio.ts b/app/lib/audio.ts
new file mode 100644
index 000000000..a4937d773
--- /dev/null
+++ b/app/lib/audio.ts
@@ -0,0 +1,200 @@
+export class AudioHandler {
+ private context: AudioContext;
+ private mergeNode: ChannelMergerNode;
+ private analyserData: Uint8Array;
+ public analyser: AnalyserNode;
+ private workletNode: AudioWorkletNode | null = null;
+ private stream: MediaStream | null = null;
+ private source: MediaStreamAudioSourceNode | null = null;
+ private recordBuffer: Int16Array[] = [];
+ private readonly sampleRate = 24000;
+
+ private nextPlayTime: number = 0;
+ private isPlaying: boolean = false;
+ private playbackQueue: AudioBufferSourceNode[] = [];
+ private playBuffer: Int16Array[] = [];
+
+ constructor() {
+ this.context = new AudioContext({ sampleRate: this.sampleRate });
+ // using ChannelMergerNode to get merged audio data, and then get analyser data.
+ this.mergeNode = new ChannelMergerNode(this.context, { numberOfInputs: 2 });
+ this.analyser = new AnalyserNode(this.context, { fftSize: 256 });
+ this.analyserData = new Uint8Array(this.analyser.frequencyBinCount);
+ this.mergeNode.connect(this.analyser);
+ }
+
+ getByteFrequencyData() {
+ this.analyser.getByteFrequencyData(this.analyserData);
+ return this.analyserData;
+ }
+
+ async initialize() {
+ await this.context.audioWorklet.addModule("/audio-processor.js");
+ }
+
+ async startRecording(onChunk: (chunk: Uint8Array) => void) {
+ try {
+ if (!this.workletNode) {
+ await this.initialize();
+ }
+
+ this.stream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ channelCount: 1,
+ sampleRate: this.sampleRate,
+ echoCancellation: true,
+ noiseSuppression: true,
+ },
+ });
+
+ await this.context.resume();
+ this.source = this.context.createMediaStreamSource(this.stream);
+ this.workletNode = new AudioWorkletNode(
+ this.context,
+ "audio-recorder-processor",
+ );
+
+ this.workletNode.port.onmessage = (event) => {
+ if (event.data.eventType === "audio") {
+ const float32Data = event.data.audioData;
+ const int16Data = new Int16Array(float32Data.length);
+
+ for (let i = 0; i < float32Data.length; i++) {
+ const s = Math.max(-1, Math.min(1, float32Data[i]));
+ int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
+ }
+
+ const uint8Data = new Uint8Array(int16Data.buffer);
+ onChunk(uint8Data);
+ // save recordBuffer
+ // @ts-ignore
+ this.recordBuffer.push.apply(this.recordBuffer, int16Data);
+ }
+ };
+
+ this.source.connect(this.workletNode);
+ this.source.connect(this.mergeNode, 0, 0);
+ this.workletNode.connect(this.context.destination);
+
+ this.workletNode.port.postMessage({ command: "START_RECORDING" });
+ } catch (error) {
+ console.error("Error starting recording:", error);
+ throw error;
+ }
+ }
+
+ stopRecording() {
+ if (!this.workletNode || !this.source || !this.stream) {
+ throw new Error("Recording not started");
+ }
+
+ this.workletNode.port.postMessage({ command: "STOP_RECORDING" });
+
+ this.workletNode.disconnect();
+ this.source.disconnect();
+ this.stream.getTracks().forEach((track) => track.stop());
+ }
+ startStreamingPlayback() {
+ this.isPlaying = true;
+ this.nextPlayTime = this.context.currentTime;
+ }
+
+ stopStreamingPlayback() {
+ this.isPlaying = false;
+ this.playbackQueue.forEach((source) => source.stop());
+ this.playbackQueue = [];
+ this.playBuffer = [];
+ }
+
+ playChunk(chunk: Uint8Array) {
+ if (!this.isPlaying) return;
+
+ const int16Data = new Int16Array(chunk.buffer);
+ // @ts-ignore
+ this.playBuffer.push.apply(this.playBuffer, int16Data); // save playBuffer
+
+ const float32Data = new Float32Array(int16Data.length);
+ for (let i = 0; i < int16Data.length; i++) {
+ float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7fff);
+ }
+
+ const audioBuffer = this.context.createBuffer(
+ 1,
+ float32Data.length,
+ this.sampleRate,
+ );
+ audioBuffer.getChannelData(0).set(float32Data);
+
+ const source = this.context.createBufferSource();
+ source.buffer = audioBuffer;
+ source.connect(this.context.destination);
+ source.connect(this.mergeNode, 0, 1);
+
+ const chunkDuration = audioBuffer.length / this.sampleRate;
+
+ source.start(this.nextPlayTime);
+
+ this.playbackQueue.push(source);
+ source.onended = () => {
+ const index = this.playbackQueue.indexOf(source);
+ if (index > -1) {
+ this.playbackQueue.splice(index, 1);
+ }
+ };
+
+ this.nextPlayTime += chunkDuration;
+
+ if (this.nextPlayTime < this.context.currentTime) {
+ this.nextPlayTime = this.context.currentTime;
+ }
+ }
+ _saveData(data: Int16Array, bytesPerSample = 16): Blob {
+ const headerLength = 44;
+ const numberOfChannels = 1;
+ const byteLength = data.buffer.byteLength;
+ const header = new Uint8Array(headerLength);
+ const view = new DataView(header.buffer);
+ view.setUint32(0, 1380533830, false); // RIFF identifier 'RIFF'
+ view.setUint32(4, 36 + byteLength, true); // file length minus RIFF identifier length and file description length
+ view.setUint32(8, 1463899717, false); // RIFF type 'WAVE'
+ view.setUint32(12, 1718449184, false); // format chunk identifier 'fmt '
+ view.setUint32(16, 16, true); // format chunk length
+ view.setUint16(20, 1, true); // sample format (raw)
+ view.setUint16(22, numberOfChannels, true); // channel count
+ view.setUint32(24, this.sampleRate, true); // sample rate
+ view.setUint32(28, this.sampleRate * 4, true); // byte rate (sample rate * block align)
+ view.setUint16(32, numberOfChannels * 2, true); // block align (channel count * bytes per sample)
+ view.setUint16(34, bytesPerSample, true); // bits per sample
+ view.setUint32(36, 1684108385, false); // data chunk identifier 'data'
+ view.setUint32(40, byteLength, true); // data chunk length
+
+ // using data.buffer, so no need to setUint16 to view.
+ return new Blob([view, data.buffer], { type: "audio/mpeg" });
+ }
+ savePlayFile() {
+ // @ts-ignore
+ return this._saveData(new Int16Array(this.playBuffer));
+ }
+ saveRecordFile(
+ audioStartMillis: number | undefined,
+ audioEndMillis: number | undefined,
+ ) {
+ const startIndex = audioStartMillis
+ ? Math.floor((audioStartMillis * this.sampleRate) / 1000)
+ : 0;
+ const endIndex = audioEndMillis
+ ? Math.floor((audioEndMillis * this.sampleRate) / 1000)
+ : this.recordBuffer.length;
+ return this._saveData(
+ // @ts-ignore
+ new Int16Array(this.recordBuffer.slice(startIndex, endIndex)),
+ );
+ }
+ async close() {
+ this.recordBuffer = [];
+ this.workletNode?.disconnect();
+ this.source?.disconnect();
+ this.stream?.getTracks().forEach((track) => track.stop());
+ await this.context.close();
+ }
+}
diff --git a/app/locales/cn.ts b/app/locales/cn.ts
index 9114806f4..41998de46 100644
--- a/app/locales/cn.ts
+++ b/app/locales/cn.ts
@@ -42,6 +42,9 @@ const cn = {
PinToastAction: "查看",
Delete: "删除",
Edit: "编辑",
+ FullScreen: "全屏",
+ RefreshTitle: "刷新标题",
+ RefreshToast: "已发送刷新标题请求",
Speech: "朗读",
StopSpeech: "停止",
},
@@ -51,6 +54,7 @@ const cn = {
next: "下一个聊天",
prev: "上一个聊天",
clear: "清除上下文",
+ fork: "复制聊天",
del: "删除聊天",
},
InputActions: {
@@ -87,6 +91,14 @@ const cn = {
SaveAs: "存为面具",
},
IsContext: "预设提示词",
+ ShortcutKey: {
+ Title: "键盘快捷方式",
+ newChat: "打开新聊天",
+ focusInput: "聚焦输入框",
+ copyLastMessage: "复制最后一个回复",
+ copyLastCode: "复制最后一个代码块",
+ showShortcutKey: "显示快捷方式",
+ },
},
Export: {
Title: "分享聊天记录",
@@ -560,6 +572,39 @@ const cn = {
SubTitle: "音频转换引擎",
},
},
+ Realtime: {
+ Enable: {
+ Title: "实时聊天",
+ SubTitle: "开启实时聊天功能",
+ },
+ Provider: {
+ Title: "模型服务商",
+ SubTitle: "切换不同的服务商",
+ },
+ Model: {
+ Title: "模型",
+ SubTitle: "选择一个模型",
+ },
+ ApiKey: {
+ Title: "API Key",
+ SubTitle: "API Key",
+ Placeholder: "API Key",
+ },
+ Azure: {
+ Endpoint: {
+ Title: "接口地址",
+ SubTitle: "接口地址",
+ },
+ Deployment: {
+ Title: "部署名称",
+ SubTitle: "部署名称",
+ },
+ },
+ Temperature: {
+ Title: "随机性 (temperature)",
+ SubTitle: "值越大,回复越随机",
+ },
+ },
},
Store: {
DefaultTopic: "新的聊天",
diff --git a/app/locales/en.ts b/app/locales/en.ts
index 98ed87e50..4f3b987a8 100644
--- a/app/locales/en.ts
+++ b/app/locales/en.ts
@@ -44,6 +44,9 @@ const en: LocaleType = {
PinToastAction: "View",
Delete: "Delete",
Edit: "Edit",
+ FullScreen: "FullScreen",
+ RefreshTitle: "Refresh Title",
+ RefreshToast: "Title refresh request sent",
Speech: "Play",
StopSpeech: "Stop",
},
@@ -53,6 +56,7 @@ const en: LocaleType = {
next: "Next Chat",
prev: "Previous Chat",
clear: "Clear Context",
+ fork: "Copy Chat",
del: "Delete Chat",
},
InputActions: {
@@ -89,6 +93,14 @@ const en: LocaleType = {
SaveAs: "Save as Mask",
},
IsContext: "Contextual Prompt",
+ ShortcutKey: {
+ Title: "Keyboard Shortcuts",
+ newChat: "Open New Chat",
+ focusInput: "Focus Input Field",
+ copyLastMessage: "Copy Last Reply",
+ copyLastCode: "Copy Last Code Block",
+ showShortcutKey: "Show Shortcuts",
+ },
},
Export: {
Title: "Export Messages",
@@ -568,6 +580,39 @@ const en: LocaleType = {
SubTitle: "Text-to-Speech Engine",
},
},
+ Realtime: {
+ Enable: {
+ Title: "Realtime Chat",
+ SubTitle: "Enable realtime chat feature",
+ },
+ Provider: {
+ Title: "Model Provider",
+ SubTitle: "Switch between different providers",
+ },
+ Model: {
+ Title: "Model",
+ SubTitle: "Select a model",
+ },
+ ApiKey: {
+ Title: "API Key",
+ SubTitle: "API Key",
+ Placeholder: "API Key",
+ },
+ Azure: {
+ Endpoint: {
+ Title: "Endpoint",
+ SubTitle: "Endpoint",
+ },
+ Deployment: {
+ Title: "Deployment Name",
+ SubTitle: "Deployment Name",
+ },
+ },
+ Temperature: {
+ Title: "Randomness (temperature)",
+ SubTitle: "Higher values result in more random responses",
+ },
+ },
},
Store: {
DefaultTopic: "New Conversation",
diff --git a/app/store/chat.ts b/app/store/chat.ts
index ff57d917c..0835d7519 100644
--- a/app/store/chat.ts
+++ b/app/store/chat.ts
@@ -1,13 +1,15 @@
-import {
- trimTopic,
- getMessageTextContent,
- isFunctionCallModel,
-} from "../utils";
+import { getMessageTextContent, trimTopic } from "../utils";
-import Locale, { getLang } from "../locales";
+import { indexedDBStorage } from "@/app/utils/indexedDB-storage";
+import { nanoid } from "nanoid";
+import type {
+ ClientApi,
+ MultimodalContent,
+ RequestMessage,
+} from "../client/api";
+import { getClientApi } from "../client/api";
+import { ChatControllerPool } from "../client/controller";
import { showToast } from "../components/ui-lib";
-import { ModelConfig, ModelType, useAppConfig } from "./config";
-import { createEmptyMask, Mask } from "./mask";
import {
DEFAULT_INPUT_TEMPLATE,
DEFAULT_MODELS,
@@ -16,29 +18,24 @@ import {
StoreKey,
SUMMARIZE_MODEL,
GEMINI_SUMMARIZE_MODEL,
- MYFILES_BROWSER_TOOLS_SYSTEM_PROMPT,
+ ServiceProvider,
} from "../constant";
+import Locale, { getLang } from "../locales";
import { isDalle3, safeLocalStorage } from "../utils";
-import { getClientApi } from "../client/api";
-import type {
- ClientApi,
- RequestMessage,
- MultimodalContent,
-} from "../client/api";
-import { ChatControllerPool } from "../client/controller";
import { prettyObject } from "../utils/format";
+import { createPersistStore } from "../utils/store";
import { estimateTokenLength } from "../utils/token";
-import { nanoid } from "nanoid";
-import { Plugin, usePluginStore } from "../store/plugin";
+import { ModelConfig, ModelType, useAppConfig } from "./config";
+import { useAccessStore } from "./access";
+import { collectModelsWithDefaultModel } from "../utils/model";
+import { createEmptyMask, Mask } from "./mask";
+import { FileInfo } from "../client/platforms/utils";
+import { usePluginStore } from "./plugin";
export interface ChatToolMessage {
toolName: string;
toolInput?: string;
}
-import { createPersistStore } from "../utils/store";
-import { FileInfo } from "../client/platforms/utils";
-import { collectModelsWithDefaultModel } from "../utils/model";
-import { useAccessStore } from "./access";
const localStorage = safeLocalStorage();
@@ -52,6 +49,7 @@ export type ChatMessageTool = {
};
content?: string;
isError?: boolean;
+ errorMsg?: string;
};
export type ChatMessage = RequestMessage & {
@@ -61,6 +59,8 @@ export type ChatMessage = RequestMessage & {
isError?: boolean;
id: string;
model?: ModelType;
+ tools?: ChatMessageTool[];
+ audio_url?: string;
};
export function createMessage(override: Partial): ChatMessage {
@@ -122,9 +122,12 @@ function createEmptySession(): ChatSession {
};
}
-function getSummarizeModel(currentModel: string) {
+function getSummarizeModel(
+ currentModel: string,
+ providerName: string,
+): string[] {
// if it is using gpt-* models, force to use 4o-mini to summarize
- if (currentModel.startsWith("gpt")) {
+ if (currentModel.startsWith("gpt") || currentModel.startsWith("chatgpt")) {
const configStore = useAppConfig.getState();
const accessStore = useAccessStore.getState();
const allModel = collectModelsWithDefaultModel(
@@ -135,12 +138,17 @@ function getSummarizeModel(currentModel: string) {
const summarizeModel = allModel.find(
(m) => m.name === SUMMARIZE_MODEL && m.available,
);
- return summarizeModel?.name ?? currentModel;
+ if (summarizeModel) {
+ return [
+ summarizeModel.name,
+ summarizeModel.provider?.providerName as string,
+ ];
+ }
}
if (currentModel.startsWith("gemini")) {
- return GEMINI_SUMMARIZE_MODEL;
+ return [GEMINI_SUMMARIZE_MODEL, ServiceProvider.Google];
}
- return currentModel;
+ return [currentModel, providerName];
}
function countMessages(msgs: ChatMessage[]) {
@@ -197,6 +205,7 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) {
const DEFAULT_CHAT_STATE = {
sessions: [createEmptySession()],
currentSessionIndex: 0,
+ lastInput: "",
};
export const useChatStore = createPersistStore(
@@ -210,6 +219,28 @@ export const useChatStore = createPersistStore(
}
const methods = {
+ forkSession() {
+ // 获取当前会话
+ const currentSession = get().currentSession();
+ if (!currentSession) return;
+
+ const newSession = createEmptySession();
+
+ newSession.topic = currentSession.topic;
+ newSession.messages = [...currentSession.messages];
+ newSession.mask = {
+ ...currentSession.mask,
+ modelConfig: {
+ ...currentSession.mask.modelConfig,
+ },
+ };
+
+ set((state) => ({
+ currentSessionIndex: 0,
+ sessions: [newSession, ...state.sessions],
+ }));
+ },
+
clearSessions() {
set(() => ({
sessions: [createEmptySession()],
@@ -335,13 +366,13 @@ export const useChatStore = createPersistStore(
return session;
},
- onNewMessage(message: ChatMessage) {
- get().updateCurrentSession((session) => {
+ onNewMessage(message: ChatMessage, targetSession: ChatSession) {
+ get().updateTargetSession(targetSession, (session) => {
session.messages = session.messages.concat();
session.lastUpdate = Date.now();
});
- get().updateStat(message);
- get().summarizeSession();
+ get().updateStat(message, targetSession);
+ get().summarizeSession(false, targetSession);
},
async onUserInput(
@@ -359,44 +390,39 @@ export const useChatStore = createPersistStore(
if (attachImages && attachImages.length > 0) {
mContent = [
- {
- type: "text",
- text: userContent,
- },
+ ...(userContent
+ ? [{ type: "text" as const, text: userContent }]
+ : []),
+ ...attachImages.map((url) => ({
+ type: "image_url" as const,
+ image_url: { url },
+ })),
];
- mContent = mContent.concat(
- attachImages.map((url) => {
- return {
- type: "image_url",
- image_url: {
- url: url,
- },
- };
- }),
- );
}
+
// add file link
if (attachFiles && attachFiles.length > 0) {
mContent += ` [${attachFiles[0].originalFilename}](${attachFiles[0].filePath})`;
}
+
let userMessage: ChatMessage = createMessage({
role: "user",
content: mContent,
fileInfos: attachFiles,
});
+
const botMessage: ChatMessage = createMessage({
role: "assistant",
streaming: true,
model: modelConfig.model,
toolMessages: [],
});
- const api: ClientApi = getClientApi(modelConfig.providerName);
const isEnableRAG =
session.attachFiles && session.attachFiles.length > 0;
// get recent messages
const recentMessages = get().getMessagesWithMemory();
const sendMessages = recentMessages.concat(userMessage);
- const messageIndex = get().currentSession().messages.length + 1;
+ const messageIndex = session.messages.length + 1;
const config = useAppConfig.getState();
const pluginConfig = useAppConfig.getState().pluginConfig;
@@ -410,148 +436,86 @@ export const useChatStore = createPersistStore(
m.enable,
);
// save user's and bot's message
- get().updateCurrentSession((session) => {
+ get().updateTargetSession(session, (session) => {
const savedUserMessage = {
...userMessage,
content: mContent,
};
- session.messages.push(savedUserMessage);
- session.messages.push(botMessage);
+ session.messages = session.messages.concat([
+ savedUserMessage,
+ botMessage,
+ ]);
});
- if (
- config.pluginConfig.enable &&
- session.mask.usePlugins &&
- (allPlugins.length > 0 || isEnableRAG) &&
- isFunctionCallModel(modelConfig.model)
- ) {
- console.log("[ToolAgent] start");
- let pluginToolNames = allPlugins.map((m) => m.toolName);
- if (isEnableRAG) {
- // other plugins will affect rag
- // clear existing plugins here
- pluginToolNames = [];
- pluginToolNames.push("myfiles_browser");
- }
- const agentCall = () => {
- api.llm.toolAgentChat({
- chatSessionId: session.id,
- messages: sendMessages,
- config: { ...modelConfig, stream: true },
- agentConfig: { ...pluginConfig, useTools: pluginToolNames },
- onUpdate(message) {
- botMessage.streaming = true;
- if (message) {
- botMessage.content = message;
- }
- get().updateCurrentSession((session) => {
- session.messages = session.messages.concat();
- });
- },
- onToolUpdate(toolName, toolInput) {
- botMessage.streaming = true;
- if (toolName && toolInput) {
- botMessage.toolMessages!.push({
- toolName,
- toolInput,
- });
- }
- get().updateCurrentSession((session) => {
- session.messages = session.messages.concat();
- });
- },
- onFinish(message) {
- botMessage.streaming = false;
- if (message) {
- botMessage.content = message;
- get().onNewMessage(botMessage);
- }
- ChatControllerPool.remove(session.id, botMessage.id);
- },
- onError(error) {
- const isAborted = error.message.includes("aborted");
- botMessage.content +=
- "\n\n" +
- prettyObject({
- error: true,
- message: error.message,
- });
- botMessage.streaming = false;
- userMessage.isError = !isAborted;
- botMessage.isError = !isAborted;
- get().updateCurrentSession((session) => {
- session.messages = session.messages.concat();
- });
- ChatControllerPool.remove(
- session.id,
- botMessage.id ?? messageIndex,
- );
- console.error("[Chat] failed ", error);
- },
- onController(controller) {
- // collect controller for stop/retry
- ChatControllerPool.addController(
- session.id,
- botMessage.id ?? messageIndex,
- controller,
- );
- },
+ const api: ClientApi = getClientApi(modelConfig.providerName);
+ // make request
+ api.llm.chat({
+ messages: sendMessages,
+ config: { ...modelConfig, stream: true },
+ onUpdate(message) {
+ botMessage.streaming = true;
+ if (message) {
+ botMessage.content = message;
+ }
+ get().updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
});
- };
- agentCall();
- } else {
- // make request
- api.llm.chat({
- messages: sendMessages,
- config: { ...modelConfig, stream: true },
- onUpdate(message) {
- botMessage.streaming = true;
- if (message) {
- botMessage.content = message;
+ },
+ onFinish(message) {
+ botMessage.streaming = false;
+ if (message) {
+ botMessage.content = message;
+ botMessage.date = new Date().toLocaleString();
+ get().onNewMessage(botMessage, session);
+ }
+ ChatControllerPool.remove(session.id, botMessage.id);
+ },
+ onBeforeTool(tool: ChatMessageTool) {
+ (botMessage.tools = botMessage?.tools || []).push(tool);
+ get().updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ },
+ onAfterTool(tool: ChatMessageTool) {
+ botMessage?.tools?.forEach((t, i, tools) => {
+ if (tool.id == t.id) {
+ tools[i] = { ...tool };
}
- get().updateCurrentSession((session) => {
- session.messages = session.messages.concat();
+ });
+ get().updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ },
+ onError(error) {
+ const isAborted = error.message?.includes?.("aborted");
+ botMessage.content +=
+ "\n\n" +
+ prettyObject({
+ error: true,
+ message: error.message,
});
- },
- onFinish(message) {
- botMessage.streaming = false;
- if (message) {
- botMessage.content = message;
- get().onNewMessage(botMessage);
- }
- ChatControllerPool.remove(session.id, botMessage.id);
- },
- onError(error) {
- const isAborted = error.message.includes("aborted");
- botMessage.content +=
- "\n\n" +
- prettyObject({
- error: true,
- message: error.message,
- });
- botMessage.streaming = false;
- userMessage.isError = !isAborted;
- botMessage.isError = !isAborted;
- get().updateCurrentSession((session) => {
- session.messages = session.messages.concat();
- });
- ChatControllerPool.remove(
- session.id,
- botMessage.id ?? messageIndex,
- );
+ botMessage.streaming = false;
+ userMessage.isError = !isAborted;
+ botMessage.isError = !isAborted;
+ get().updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ ChatControllerPool.remove(
+ session.id,
+ botMessage.id ?? messageIndex,
+ );
- console.error("[Chat] failed ", error);
- },
- onController(controller) {
- // collect controller for stop/retry
- ChatControllerPool.addController(
- session.id,
- botMessage.id ?? messageIndex,
- controller,
- );
- },
- });
- }
+ console.error("[Chat] failed ", error);
+ },
+ onController(controller) {
+ // collect controller for stop/retry
+ ChatControllerPool.addController(
+ session.id,
+ botMessage.id ?? messageIndex,
+ controller,
+ );
+ },
+ });
},
getMemoryPrompt() {
@@ -579,26 +543,17 @@ export const useChatStore = createPersistStore(
// system prompts, to get close to OpenAI Web ChatGPT
const shouldInjectSystemPrompts =
modelConfig.enableInjectSystemPrompts &&
- session.mask.modelConfig.model.startsWith("gpt-");
+ (session.mask.modelConfig.model.startsWith("gpt-") ||
+ session.mask.modelConfig.model.startsWith("chatgpt-"));
var systemPrompts: ChatMessage[] = [];
- var template = DEFAULT_SYSTEM_TEMPLATE;
- if (session.attachFiles && session.attachFiles.length > 0) {
- template += MYFILES_BROWSER_TOOLS_SYSTEM_PROMPT;
- session.attachFiles.forEach((file) => {
- template += `filename: \`${file.originalFilename}\`
-partialDocument: \`\`\`
-${file.partial}
-\`\`\``;
- });
- }
systemPrompts = shouldInjectSystemPrompts
? [
createMessage({
role: "system",
content: fillTemplateWith("", {
...modelConfig,
- template: template,
+ template: DEFAULT_SYSTEM_TEMPLATE,
}),
}),
]
@@ -674,23 +629,33 @@ ${file.partial}
set(() => ({ sessions }));
},
- resetSession() {
- get().updateCurrentSession((session) => {
+ resetSession(session: ChatSession) {
+ get().updateTargetSession(session, (session) => {
session.messages = [];
session.memoryPrompt = "";
});
},
- summarizeSession() {
+ summarizeSession(
+ refreshTitle: boolean = false,
+ targetSession: ChatSession,
+ ) {
const config = useAppConfig.getState();
- const session = get().currentSession();
+ const session = targetSession;
const modelConfig = session.mask.modelConfig;
// skip summarize when using dalle3?
if (isDalle3(modelConfig.model)) {
return;
}
- const api: ClientApi = getClientApi(modelConfig.providerName);
+ // if not config compressModel, then using getSummarizeModel
+ const [model, providerName] = modelConfig.compressModel
+ ? [modelConfig.compressModel, modelConfig.compressProviderName]
+ : getSummarizeModel(
+ session.mask.modelConfig.model,
+ session.mask.modelConfig.providerName,
+ );
+ const api: ClientApi = getClientApi(providerName as ServiceProvider);
// remove error messages if any
const messages = session.messages;
@@ -698,29 +663,43 @@ ${file.partial}
// should summarize topic after chating more than 50 words
const SUMMARIZE_MIN_LEN = 50;
if (
- !process.env.NEXT_PUBLIC_DISABLE_AUTOGENERATETITLE &&
- config.enableAutoGenerateTitle &&
- session.topic === DEFAULT_TOPIC &&
- countMessages(messages) >= SUMMARIZE_MIN_LEN
+ (!process.env.NEXT_PUBLIC_DISABLE_AUTOGENERATETITLE &&
+ config.enableAutoGenerateTitle &&
+ session.topic === DEFAULT_TOPIC &&
+ countMessages(messages) >= SUMMARIZE_MIN_LEN) ||
+ refreshTitle
) {
- const topicMessages = messages.concat(
- createMessage({
- role: "user",
- content: Locale.Store.Prompt.Topic,
- }),
+ const startIndex = Math.max(
+ 0,
+ messages.length - modelConfig.historyMessageCount,
);
+ const topicMessages = messages
+ .slice(
+ startIndex < messages.length ? startIndex : messages.length - 1,
+ messages.length,
+ )
+ .concat(
+ createMessage({
+ role: "user",
+ content: Locale.Store.Prompt.Topic,
+ }),
+ );
api.llm.chat({
messages: topicMessages,
config: {
- model: getSummarizeModel(session.mask.modelConfig.model),
+ model,
stream: false,
+ providerName,
},
- onFinish(message) {
- get().updateCurrentSession(
- (session) =>
- (session.topic =
- message.length > 0 ? trimTopic(message) : DEFAULT_TOPIC),
- );
+ onFinish(message, responseRes) {
+ if (responseRes?.status === 200) {
+ get().updateTargetSession(
+ session,
+ (session) =>
+ (session.topic =
+ message.length > 0 ? trimTopic(message) : DEFAULT_TOPIC),
+ );
+ }
},
});
}
@@ -734,7 +713,7 @@ ${file.partial}
const historyMsgLength = countMessages(toBeSummarizedMsgs);
- if (historyMsgLength > modelConfig?.max_tokens ?? 4000) {
+ if (historyMsgLength > (modelConfig?.max_tokens || 4000)) {
const n = toBeSummarizedMsgs.length;
toBeSummarizedMsgs = toBeSummarizedMsgs.slice(
Math.max(0, n - modelConfig.historyMessageCount),
@@ -775,17 +754,20 @@ ${file.partial}
config: {
...modelcfg,
stream: true,
- model: getSummarizeModel(session.mask.modelConfig.model),
+ model,
+ providerName,
},
onUpdate(message) {
session.memoryPrompt = message;
},
- onFinish(message) {
- // console.log("[Memory] ", message);
- get().updateCurrentSession((session) => {
- session.lastSummarizeIndex = lastSummarizeIndex;
- session.memoryPrompt = message; // Update the memory prompt for stored it in local storage
- });
+ onFinish(message, responseRes) {
+ if (responseRes?.status === 200) {
+ console.log("[Memory] ", message);
+ get().updateTargetSession(session, (session) => {
+ session.lastSummarizeIndex = lastSummarizeIndex;
+ session.memoryPrompt = message; // Update the memory prompt for stored it in local storage
+ });
+ }
},
onError(err) {
console.error("[Summarize] ", err);
@@ -794,31 +776,39 @@ ${file.partial}
}
},
- updateStat(message: ChatMessage) {
- get().updateCurrentSession((session) => {
+ updateStat(message: ChatMessage, session: ChatSession) {
+ get().updateTargetSession(session, (session) => {
session.stat.charCount += message.content.length;
// TODO: should update chat count and word count
});
},
-
- updateCurrentSession(updater: (session: ChatSession) => void) {
+ updateTargetSession(
+ targetSession: ChatSession,
+ updater: (session: ChatSession) => void,
+ ) {
const sessions = get().sessions;
- const index = get().currentSessionIndex;
+ const index = sessions.findIndex((s) => s.id === targetSession.id);
+ if (index < 0) return;
updater(sessions[index]);
set(() => ({ sessions }));
},
-
- clearAllData() {
+ async clearAllData() {
+ await indexedDBStorage.clear();
localStorage.clear();
location.reload();
},
+ setLastInput(lastInput: string) {
+ set({
+ lastInput,
+ });
+ },
};
return methods;
},
{
name: StoreKey.Chat,
- version: 3.1,
+ version: 3.3,
migrate(persistedState, version) {
const state = persistedState as any;
const newState = JSON.parse(
@@ -865,6 +855,24 @@ ${file.partial}
});
}
+ // add default summarize model for every session
+ if (version < 3.2) {
+ newState.sessions.forEach((s) => {
+ const config = useAppConfig.getState();
+ s.mask.modelConfig.compressModel = config.modelConfig.compressModel;
+ s.mask.modelConfig.compressProviderName =
+ config.modelConfig.compressProviderName;
+ });
+ }
+ // revert default summarize model for every session
+ if (version < 3.3) {
+ newState.sessions.forEach((s) => {
+ const config = useAppConfig.getState();
+ s.mask.modelConfig.compressModel = "";
+ s.mask.modelConfig.compressProviderName = "";
+ });
+ }
+
return newState as any;
},
},
diff --git a/app/store/config.ts b/app/store/config.ts
index 564243e26..88076f8a2 100644
--- a/app/store/config.ts
+++ b/app/store/config.ts
@@ -17,6 +17,7 @@ import {
ServiceProvider,
} from "../constant";
import { createPersistStore } from "../utils/store";
+import type { Voice } from "rt-client";
export type ModelType = (typeof DEFAULT_MODELS)[number]["name"];
export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number];
@@ -105,6 +106,19 @@ export const DEFAULT_CONFIG = {
enable: false,
engine: DEFAULT_STT_ENGINE,
},
+
+ realtimeConfig: {
+ enable: false,
+ provider: "OpenAI" as ServiceProvider,
+ model: "gpt-4o-realtime-preview-2024-10-01",
+ apiKey: "",
+ azure: {
+ endpoint: "",
+ deployment: "",
+ },
+ temperature: 0.9,
+ voice: "alloy" as Voice,
+ },
};
export type ChatConfig = typeof DEFAULT_CONFIG;
@@ -113,6 +127,7 @@ export type ModelConfig = ChatConfig["modelConfig"];
export type PluginConfig = ChatConfig["pluginConfig"];
export type TTSConfig = ChatConfig["ttsConfig"];
export type STTConfig = ChatConfig["sttConfig"];
+export type RealtimeConfig = ChatConfig["realtimeConfig"];
export function limitNumber(
x: number,
diff --git a/app/store/plugin.ts b/app/store/plugin.ts
index 0344f8e66..34e1f111a 100644
--- a/app/store/plugin.ts
+++ b/app/store/plugin.ts
@@ -17,6 +17,14 @@ export type Plugin = {
builtin: boolean;
enable: boolean;
onlyNodeRuntime: boolean;
+
+ title: string;
+ version: string;
+ content: string;
+ authType?: string;
+ authLocation?: string;
+ authHeader?: string;
+ authToken?: string;
};
export const DEFAULT_PLUGIN_STATE = {
diff --git a/app/utils.ts b/app/utils.ts
index 2a698ca7f..72775be19 100644
--- a/app/utils.ts
+++ b/app/utils.ts
@@ -278,6 +278,24 @@ export function isDalle3(model: string) {
return "dall-e-3" === model;
}
+export function showPlugins(provider: ServiceProvider, model: string) {
+ if (
+ provider == ServiceProvider.OpenAI ||
+ provider == ServiceProvider.Azure ||
+ provider == ServiceProvider.Moonshot ||
+ provider == ServiceProvider.ChatGLM
+ ) {
+ return true;
+ }
+ if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
+ return true;
+ }
+ if (provider == ServiceProvider.Google && !model.includes("vision")) {
+ return true;
+ }
+ return false;
+}
+
export function isSupportRAGModel(modelName: string) {
const specialModels = [
"gpt-4-turbo",
@@ -328,24 +346,6 @@ export function isFunctionCallModel(modelName: string) {
).some((model) => model.name === modelName);
}
-export function showPlugins(provider: ServiceProvider, model: string) {
- if (
- provider == ServiceProvider.OpenAI ||
- provider == ServiceProvider.Azure ||
- provider == ServiceProvider.Moonshot ||
- provider == ServiceProvider.ChatGLM
- ) {
- return true;
- }
- if (provider == ServiceProvider.Anthropic && !model.includes("claude-2")) {
- return true;
- }
- if (provider == ServiceProvider.Google && !model.includes("vision")) {
- return true;
- }
- return false;
-}
-
export function fetch(
url: string,
options?: Record,
diff --git a/package.json b/package.json
index 6abf3ecb6..c6011d4eb 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,8 @@
"@svgr/webpack": "^6.5.1",
"@vercel/analytics": "^0.1.11",
"@vercel/speed-insights": "^1.0.2",
- "axios": "^0.26.0",
+ "axios": "^1.7.5",
+ "clsx": "^2.1.1",
"cheerio": "^1.0.0-rc.12",
"d3-dsv": "2",
"duck-duck-scrape": "^2.2.4",
@@ -76,7 +77,8 @@
"spark-md5": "^3.0.2",
"srt-parser-2": "^1.2.3",
"use-debounce": "^9.0.4",
- "zustand": "^4.3.8"
+ "zustand": "^4.3.8",
+ "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz"
},
"devDependencies": {
"@tauri-apps/api": "^1.6.0",
diff --git a/public/audio-processor.js b/public/audio-processor.js
new file mode 100644
index 000000000..4fae6ea1a
--- /dev/null
+++ b/public/audio-processor.js
@@ -0,0 +1,48 @@
+// @ts-nocheck
+class AudioRecorderProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.isRecording = false;
+ this.bufferSize = 2400; // 100ms at 24kHz
+ this.currentBuffer = [];
+
+ this.port.onmessage = (event) => {
+ if (event.data.command === "START_RECORDING") {
+ this.isRecording = true;
+ } else if (event.data.command === "STOP_RECORDING") {
+ this.isRecording = false;
+
+ if (this.currentBuffer.length > 0) {
+ this.sendBuffer();
+ }
+ }
+ };
+ }
+
+ sendBuffer() {
+ if (this.currentBuffer.length > 0) {
+ const audioData = new Float32Array(this.currentBuffer);
+ this.port.postMessage({
+ eventType: "audio",
+ audioData: audioData,
+ });
+ this.currentBuffer = [];
+ }
+ }
+
+ process(inputs) {
+ const input = inputs[0];
+ if (input.length > 0 && this.isRecording) {
+ const audioData = input[0];
+
+ this.currentBuffer.push(...audioData);
+
+ if (this.currentBuffer.length >= this.bufferSize) {
+ this.sendBuffer();
+ }
+ }
+ return true;
+ }
+}
+
+registerProcessor("audio-recorder-processor", AudioRecorderProcessor);
diff --git a/public/plugins.json b/public/plugins.json
new file mode 100644
index 000000000..c4d7ec46a
--- /dev/null
+++ b/public/plugins.json
@@ -0,0 +1,17 @@
+[
+ {
+ "id": "dalle3",
+ "name": "Dalle3",
+ "schema": "https://ghp.ci/https://raw.githubusercontent.com/ChatGPTNextWeb/NextChat-Awesome-Plugins/main/plugins/dalle/openapi.json"
+ },
+ {
+ "id": "arxivsearch",
+ "name": "ArxivSearch",
+ "schema": "https://ghp.ci/https://raw.githubusercontent.com/ChatGPTNextWeb/NextChat-Awesome-Plugins/main/plugins/arxivsearch/openapi.json"
+ },
+ {
+ "id": "duckduckgolite",
+ "name": "DuckDuckGoLiteSearch",
+ "schema": "https://ghp.ci/https://raw.githubusercontent.com/ChatGPTNextWeb/NextChat-Awesome-Plugins/main/plugins/duckduckgolite/openapi.json"
+ }
+]
diff --git a/public/serviceWorker.js b/public/serviceWorker.js
index c58b2cc5a..8656d341e 100644
--- a/public/serviceWorker.js
+++ b/public/serviceWorker.js
@@ -15,6 +15,10 @@ self.addEventListener("install", function (event) {
);
});
+function jsonify(data) {
+ return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } })
+}
+
async function upload(request, url) {
const formData = await request.formData()
const file = formData.getAll('file')[0]
@@ -33,13 +37,13 @@ async function upload(request, url) {
'server': 'ServiceWorker',
}
}))
- return Response.json({ code: 0, data: fileUrl })
+ return jsonify({ code: 0, data: fileUrl })
}
async function remove(request, url) {
const cache = await caches.open(CHATGPT_NEXT_WEB_FILE_CACHE)
const res = await cache.delete(request.url)
- return Response.json({ code: 0 })
+ return jsonify({ code: 0 })
}
self.addEventListener("fetch", (e) => {
@@ -56,4 +60,3 @@ self.addEventListener("fetch", (e) => {
}
}
});
-
diff --git a/yarn.lock b/yarn.lock
index 7e5d9a2c3..3ffe192aa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4323,12 +4323,14 @@ axe-core@^4.9.1:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.9.1.tgz#fcd0f4496dad09e0c899b44f6c4bb7848da912ae"
integrity sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==
-axios@^0.26.0:
- version "0.26.1"
- resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9"
- integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==
+axios@^1.7.5:
+ version "1.7.9"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a"
+ integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==
dependencies:
- follow-redirects "^1.14.8"
+ follow-redirects "^1.15.6"
+ form-data "^4.0.0"
+ proxy-from-env "^1.1.0"
axobject-query@~3.1.1:
version "3.1.1"
@@ -4744,6 +4746,11 @@ cliui@^8.0.1:
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
+clsx@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
+
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -6495,10 +6502,10 @@ flatted@^3.2.9:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
-follow-redirects@^1.14.8:
- version "1.15.6"
- resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
- integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
+follow-redirects@^1.15.6:
+ version "1.15.9"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
+ integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
for-each@^0.3.3:
version "0.3.3"
@@ -9493,6 +9500,11 @@ property-information@^6.0.0:
resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec"
integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==
+proxy-from-env@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+ integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
psl@^1.1.33:
version "1.9.0"
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
@@ -9898,6 +9910,12 @@ robust-predicates@^3.0.2:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
+"rt-client@https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz":
+ version "0.5.0"
+ resolved "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz#abf2e9a850201e3571b8d36830f77bc52af3de9b"
+ dependencies:
+ ws "^8.18.0"
+
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -11219,7 +11237,7 @@ write-file-atomic@^4.0.2:
imurmurhash "^0.1.4"
signal-exit "^3.0.7"
-ws@^8.11.0, ws@^8.14.2:
+ws@^8.11.0, ws@^8.14.2, ws@^8.18.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==