mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-19 04:00:16 +09:00
feat: realtime chat ui
This commit is contained in:
parent
fbb9385f23
commit
d544eead38
@ -45,6 +45,14 @@
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
|
||||
&-end {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chat-input-action {
|
||||
display: inline-flex;
|
||||
@ -62,10 +70,6 @@
|
||||
width: var(--icon-width);
|
||||
overflow: hidden;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
padding-left: 5px;
|
||||
@ -231,10 +235,12 @@
|
||||
|
||||
animation: slide-in ease 0.3s;
|
||||
|
||||
$linear: linear-gradient(to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0));
|
||||
$linear: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, 0),
|
||||
rgba(0, 0, 0, 1),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
mask-image: $linear;
|
||||
|
||||
@mixin show {
|
||||
@ -373,7 +379,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container {
|
||||
.chat-message-user > .chat-message-container {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@ -443,6 +449,25 @@
|
||||
transition: all ease 0.3s;
|
||||
}
|
||||
|
||||
.chat-message-audio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: var(--border-in-light);
|
||||
position: relative;
|
||||
transition: all ease 0.3s;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
user-select: text;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
audio {
|
||||
height: 30px; /* 调整高度 */
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
@ -471,23 +496,27 @@
|
||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
$calc-image-width: calc(100vw/3*2/var(--image-count));
|
||||
$calc-image-width: calc(100vw / 3 * 2 / var(--image-count));
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $calc-image-width;
|
||||
height: $calc-image-width;
|
||||
}
|
||||
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(100vw/3*2);
|
||||
max-width: calc(100vw / 3 * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
||||
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
||||
$max-image-width: calc(
|
||||
calc(1200px - var(--sidebar-width)) / 3 * 2 / var(--image-count)
|
||||
);
|
||||
$image-width: calc(
|
||||
calc(var(--window-width) - var(--sidebar-width)) / 3 * 2 /
|
||||
var(--image-count)
|
||||
);
|
||||
|
||||
.chat-message-item-image-multi {
|
||||
width: $image-width;
|
||||
@ -497,7 +526,7 @@
|
||||
}
|
||||
|
||||
.chat-message-item-image {
|
||||
max-width: calc(calc(1200px - var(--sidebar-width))/3*2);
|
||||
max-width: calc(calc(1200px - var(--sidebar-width)) / 3 * 2);
|
||||
}
|
||||
}
|
||||
|
||||
@ -515,7 +544,7 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-message-user>.chat-message-container>.chat-message-item {
|
||||
.chat-message-user > .chat-message-container > .chat-message-item {
|
||||
background-color: var(--second);
|
||||
|
||||
&:hover {
|
||||
@ -626,7 +655,8 @@
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.chat-input:focus {}
|
||||
.chat-input:focus {
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
background-color: var(--primary);
|
||||
@ -693,4 +723,31 @@
|
||||
.shortcut-key span {
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
.chat-body-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-side-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--white);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
transform: translateX(100%);
|
||||
transition: all ease 0.3s;
|
||||
&-show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
1
app/components/realtime-chat/index.ts
Normal file
1
app/components/realtime-chat/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./realtime-chat";
|
73
app/components/realtime-chat/realtime-chat.module.scss
Normal file
73
app/components/realtime-chat/realtime-chat.module.scss
Normal file
@ -0,0 +1,73 @@
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
257
app/components/realtime-chat/realtime-chat.tsx
Normal file
257
app/components/realtime-chat/realtime-chat.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import VoiceIcon from "@/app/icons/voice.svg";
|
||||
import VoiceOffIcon from "@/app/icons/voice-off.svg";
|
||||
import Close24Icon from "@/app/icons/close-24.svg";
|
||||
import styles from "./realtime-chat.module.scss";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
|
||||
import { useAccessStore, useChatStore, ChatMessage } from "@/app/store";
|
||||
|
||||
interface RealtimeChatProps {
|
||||
onClose?: () => void;
|
||||
onStartVoice?: () => void;
|
||||
onPausedVoice?: () => void;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
export function RealtimeChat({
|
||||
onClose,
|
||||
onStartVoice,
|
||||
onPausedVoice,
|
||||
sampleRate = 24000,
|
||||
}: RealtimeChatProps) {
|
||||
const [isVoicePaused, setIsVoicePaused] = useState(true);
|
||||
const clientRef = useRef<null>(null);
|
||||
const currentItemId = useRef<string>("");
|
||||
const currentBotMessage = useRef<ChatMessage | null>();
|
||||
const currentUserMessage = useRef<ChatMessage | null>();
|
||||
const accessStore = useAccessStore.getState();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// useEffect(() => {
|
||||
// if (
|
||||
// clientRef.current?.getTurnDetectionType() === "server_vad" &&
|
||||
// audioData
|
||||
// ) {
|
||||
// // console.log("appendInputAudio", audioData);
|
||||
// // 将录制的16PCM音频发送给openai
|
||||
// clientRef.current?.appendInputAudio(audioData);
|
||||
// }
|
||||
// }, [audioData]);
|
||||
|
||||
// useEffect(() => {
|
||||
// console.log("isRecording", isRecording);
|
||||
// if (!isRecording.current) return;
|
||||
// if (!clientRef.current) {
|
||||
// const apiKey = accessStore.openaiApiKey;
|
||||
// const client = (clientRef.current = new RealtimeClient({
|
||||
// url: "wss://api.openai.com/v1/realtime",
|
||||
// apiKey,
|
||||
// dangerouslyAllowAPIKeyInBrowser: true,
|
||||
// debug: true,
|
||||
// }));
|
||||
// client
|
||||
// .connect()
|
||||
// .then(() => {
|
||||
// // TODO 设置真实的上下文
|
||||
// client.sendUserMessageContent([
|
||||
// {
|
||||
// type: `input_text`,
|
||||
// text: `Hi`,
|
||||
// // text: `For testing purposes, I want you to list ten car brands. Number each item, e.g. "one (or whatever number you are one): the item name".`
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// // 配置服务端判断说话人开启还是结束
|
||||
// client.updateSession({
|
||||
// turn_detection: { type: "server_vad" },
|
||||
// });
|
||||
|
||||
// client.on("realtime.event", (realtimeEvent) => {
|
||||
// // 调试
|
||||
// console.log("realtime.event", realtimeEvent);
|
||||
// });
|
||||
|
||||
// client.on("conversation.interrupted", async () => {
|
||||
// if (currentBotMessage.current) {
|
||||
// stopPlaying();
|
||||
// try {
|
||||
// client.cancelResponse(
|
||||
// currentBotMessage.current?.id,
|
||||
// currentTime(),
|
||||
// );
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// client.on("conversation.updated", async (event: any) => {
|
||||
// // console.log("currentSession", chatStore.currentSession());
|
||||
// // const items = client.conversation.getItems();
|
||||
// const content = event?.item?.content?.[0]?.transcript || "";
|
||||
// const text = event?.item?.content?.[0]?.text || "";
|
||||
// // console.log(
|
||||
// // "conversation.updated",
|
||||
// // event,
|
||||
// // "content[0]",
|
||||
// // event?.item?.content?.[0]?.transcript,
|
||||
// // "formatted",
|
||||
// // event?.item?.formatted?.transcript,
|
||||
// // "content",
|
||||
// // content,
|
||||
// // "text",
|
||||
// // text,
|
||||
// // event?.item?.status,
|
||||
// // event?.item?.role,
|
||||
// // items.length,
|
||||
// // items,
|
||||
// // );
|
||||
// const { item, delta } = event;
|
||||
// const { role, id, status, formatted } = item || {};
|
||||
// if (id && role == "assistant") {
|
||||
// if (
|
||||
// !currentBotMessage.current ||
|
||||
// currentBotMessage.current?.id != id
|
||||
// ) {
|
||||
// // create assistant message and save to session
|
||||
// currentBotMessage.current = createMessage({ id, role });
|
||||
// chatStore.updateCurrentSession((session) => {
|
||||
// session.messages = session.messages.concat([
|
||||
// currentBotMessage.current!,
|
||||
// ]);
|
||||
// });
|
||||
// }
|
||||
// if (currentBotMessage.current?.id != id) {
|
||||
// stopPlaying();
|
||||
// }
|
||||
// if (content) {
|
||||
// currentBotMessage.current.content = content;
|
||||
// chatStore.updateCurrentSession((session) => {
|
||||
// session.messages = session.messages.concat();
|
||||
// });
|
||||
// }
|
||||
// if (delta?.audio) {
|
||||
// // typeof delta.audio is Int16Array
|
||||
// // 直接播放
|
||||
// addInt16PCM(delta.audio);
|
||||
// }
|
||||
// // console.log(
|
||||
// // "updated try save wavFile",
|
||||
// // status,
|
||||
// // currentBotMessage.current?.audio_url,
|
||||
// // formatted?.audio,
|
||||
// // );
|
||||
// if (
|
||||
// status == "completed" &&
|
||||
// !currentBotMessage.current?.audio_url &&
|
||||
// formatted?.audio?.length
|
||||
// ) {
|
||||
// // 转换为wav文件保存 TODO 使用mp3格式会更节省空间
|
||||
// const botMessage = currentBotMessage.current;
|
||||
// const wavFile = new WavPacker().pack(sampleRate, {
|
||||
// bitsPerSample: 16,
|
||||
// channelCount: 1,
|
||||
// data: formatted?.audio,
|
||||
// });
|
||||
// // 这里将音频文件放到对象里面wavFile.url可以使用<audio>标签播放
|
||||
// item.formatted.file = wavFile;
|
||||
// uploadImageRemote(wavFile.blob).then((audio_url) => {
|
||||
// botMessage.audio_url = audio_url;
|
||||
// chatStore.updateCurrentSession((session) => {
|
||||
// session.messages = session.messages.concat();
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
// if (
|
||||
// status == "completed" &&
|
||||
// !currentBotMessage.current?.content
|
||||
// ) {
|
||||
// chatStore.updateCurrentSession((session) => {
|
||||
// session.messages = session.messages.filter(
|
||||
// (m) => m.id !== currentBotMessage.current?.id,
|
||||
// );
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// if (id && role == "user" && !text) {
|
||||
// if (
|
||||
// !currentUserMessage.current ||
|
||||
// currentUserMessage.current?.id != id
|
||||
// ) {
|
||||
// // create assistant message and save to session
|
||||
// currentUserMessage.current = createMessage({ id, role });
|
||||
// chatStore.updateCurrentSession((session) => {
|
||||
// session.messages = session.messages.concat([
|
||||
// currentUserMessage.current!,
|
||||
// ]);
|
||||
// });
|
||||
// }
|
||||
// if (content) {
|
||||
// // 转换为wav文件保存 TODO 使用mp3格式会更节省空间
|
||||
// const userMessage = currentUserMessage.current;
|
||||
// const wavFile = new WavPacker().pack(sampleRate, {
|
||||
// bitsPerSample: 16,
|
||||
// channelCount: 1,
|
||||
// data: formatted?.audio,
|
||||
// });
|
||||
// // 这里将音频文件放到对象里面wavFile.url可以使用<audio>标签播放
|
||||
// item.formatted.file = wavFile;
|
||||
// uploadImageRemote(wavFile.blob).then((audio_url) => {
|
||||
// // update message content
|
||||
// userMessage.content = content;
|
||||
// // update message audio_url
|
||||
// userMessage.audio_url = audio_url;
|
||||
// chatStore.updateCurrentSession((session) => {
|
||||
// session.messages = session.messages.concat();
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// })
|
||||
// .catch((e) => {
|
||||
// console.error("Error", e);
|
||||
// });
|
||||
// }
|
||||
// return () => {
|
||||
// stop();
|
||||
// // TODO close client
|
||||
// clientRef.current?.disconnect();
|
||||
// };
|
||||
// }, [isRecording.current]);
|
||||
|
||||
const handleStartVoice = useCallback(() => {
|
||||
onStartVoice?.();
|
||||
setIsVoicePaused(false);
|
||||
}, []);
|
||||
|
||||
const handlePausedVoice = () => {
|
||||
onPausedVoice?.();
|
||||
setIsVoicePaused(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["realtime-chat"]}>
|
||||
<div
|
||||
className={clsx(styles["circle-mic"], {
|
||||
[styles["pulse"]]: true,
|
||||
})}
|
||||
>
|
||||
<div className={styles["icon-center"]}></div>
|
||||
</div>
|
||||
<div className={styles["bottom-icons"]}>
|
||||
<div className={styles["icon-left"]}>
|
||||
{isVoicePaused ? (
|
||||
<VoiceOffIcon onClick={handleStartVoice} />
|
||||
) : (
|
||||
<VoiceIcon onClick={handlePausedVoice} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles["icon-right"]} onClick={onClose}>
|
||||
<Close24Icon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
7
app/icons/close-24.svg
Normal file
7
app/icons/close-24.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 8L40 40" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M8 40L40 8" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 369 B |
11
app/icons/headphone.svg
Normal file
11
app/icons/headphone.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 28C4 26.8954 4.89543 26 6 26H10V38H6C4.89543 38 4 37.1046 4 36V28Z" fill="none" />
|
||||
<path d="M38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||
fill="none" />
|
||||
<path
|
||||
d="M10 36V24C10 16.268 16.268 10 24 10C31.732 10 38 16.268 38 24V36M10 26H6C4.89543 26 4 26.8954 4 28V36C4 37.1046 4.89543 38 6 38H10V26ZM38 26H42C43.1046 26 44 26.8954 44 28V36C44 37.1046 43.1046 38 42 38H38V26Z"
|
||||
stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 32H20L22 26L26 38L28 32H32" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 808 B |
13
app/icons/voice-off.svg
Normal file
13
app/icons/voice-off.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M31 24V11C31 7.13401 27.866 4 24 4C20.134 4 17 7.13401 17 11V24C17 27.866 20.134 31 24 31C27.866 31 31 27.866 31 24Z"
|
||||
stroke="#d0021b" stroke-width="4" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M9 23C9 31.2843 15.7157 38 24 38C25.7532 38 27.4361 37.6992 29 37.1465M39 23C39 25.1333 38.5547 27.1626 37.7519 29"
|
||||
stroke="#d0021b" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 38V44" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M42 42L6 6" stroke="#d0021b" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 811 B |
9
app/icons/voice.svg
Normal file
9
app/icons/voice.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="17" y="4" width="14" height="27" rx="7" fill="none" stroke="#333" stroke-width="4"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M9 23C9 31.2843 15.7157 38 24 38C32.2843 38 39 31.2843 39 23" stroke="#333"
|
||||
stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M24 38V44" stroke="#333" stroke-width="4" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
After Width: | Height: | Size: 549 B |
@ -52,6 +52,7 @@ export type ChatMessage = RequestMessage & {
|
||||
id: string;
|
||||
model?: ModelType;
|
||||
tools?: ChatMessageTool[];
|
||||
audio_url?: string;
|
||||
};
|
||||
|
||||
export function createMessage(override: Partial<ChatMessage>): ChatMessage {
|
||||
|
Loading…
Reference in New Issue
Block a user