mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-19 12:10:17 +09:00
feat: realtime chat ui
This commit is contained in:
parent
fbb9385f23
commit
d544eead38
@ -45,6 +45,14 @@
|
|||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
&-end {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-action {
|
.chat-input-action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -62,10 +70,6 @@
|
|||||||
width: var(--icon-width);
|
width: var(--icon-width);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
@ -231,10 +235,12 @@
|
|||||||
|
|
||||||
animation: slide-in ease 0.3s;
|
animation: slide-in ease 0.3s;
|
||||||
|
|
||||||
$linear: linear-gradient(to right,
|
$linear: linear-gradient(
|
||||||
|
to right,
|
||||||
rgba(0, 0, 0, 0),
|
rgba(0, 0, 0, 0),
|
||||||
rgba(0, 0, 0, 1),
|
rgba(0, 0, 0, 1),
|
||||||
rgba(0, 0, 0, 0));
|
rgba(0, 0, 0, 0)
|
||||||
|
);
|
||||||
mask-image: $linear;
|
mask-image: $linear;
|
||||||
|
|
||||||
@mixin show {
|
@mixin show {
|
||||||
@ -373,7 +379,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-user>.chat-message-container {
|
.chat-message-user > .chat-message-container {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,6 +449,25 @@
|
|||||||
transition: all ease 0.3s;
|
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 {
|
.chat-message-item-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@ -471,9 +496,8 @@
|
|||||||
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
border: rgba($color: #888, $alpha: 0.2) 1px solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media only screen and (max-width: 600px) {
|
@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 {
|
.chat-message-item-image-multi {
|
||||||
width: $calc-image-width;
|
width: $calc-image-width;
|
||||||
@ -481,13 +505,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-item-image {
|
.chat-message-item-image {
|
||||||
max-width: calc(100vw/3*2);
|
max-width: calc(100vw / 3 * 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 600px) {
|
@media screen and (min-width: 600px) {
|
||||||
$max-image-width: calc(calc(1200px - var(--sidebar-width))/3*2/var(--image-count));
|
$max-image-width: calc(
|
||||||
$image-width: calc(calc(var(--window-width) - var(--sidebar-width))/3*2/var(--image-count));
|
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 {
|
.chat-message-item-image-multi {
|
||||||
width: $image-width;
|
width: $image-width;
|
||||||
@ -497,7 +526,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message-item-image {
|
.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;
|
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);
|
background-color: var(--second);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -626,7 +655,8 @@
|
|||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input:focus {}
|
.chat-input:focus {
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-send {
|
.chat-input-send {
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
@ -694,3 +724,30 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--black);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -46,7 +46,7 @@ import StyleIcon from "../icons/palette.svg";
|
|||||||
import PluginIcon from "../icons/plugin.svg";
|
import PluginIcon from "../icons/plugin.svg";
|
||||||
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
|
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
|
||||||
import ReloadIcon from "../icons/reload.svg";
|
import ReloadIcon from "../icons/reload.svg";
|
||||||
|
import HeadphoneIcon from "../icons/headphone.svg";
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
SubmitKey,
|
SubmitKey,
|
||||||
@ -121,6 +121,7 @@ import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
|
|||||||
|
|
||||||
import { isEmpty } from "lodash-es";
|
import { isEmpty } from "lodash-es";
|
||||||
import { getModelProvider } from "../utils/model";
|
import { getModelProvider } from "../utils/model";
|
||||||
|
import { RealtimeChat } from "@/app/components/realtime-chat";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
const localStorage = safeLocalStorage();
|
const localStorage = safeLocalStorage();
|
||||||
@ -462,6 +463,7 @@ export function ChatActions(props: {
|
|||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowShortcutKeyModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setUserInput: (input: string) => void;
|
setUserInput: (input: string) => void;
|
||||||
|
setShowChatSidePanel: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}) {
|
}) {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -555,6 +557,7 @@ export function ChatActions(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles["chat-input-actions"]}>
|
<div className={styles["chat-input-actions"]}>
|
||||||
|
<>
|
||||||
{couldStop && (
|
{couldStop && (
|
||||||
<ChatAction
|
<ChatAction
|
||||||
onClick={stopAll}
|
onClick={stopAll}
|
||||||
@ -659,7 +662,8 @@ export function ChatActions(props: {
|
|||||||
if (providerName == "ByteDance") {
|
if (providerName == "ByteDance") {
|
||||||
const selectedModel = models.find(
|
const selectedModel = models.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.name == model && m?.provider?.providerName == providerName,
|
m.name == model &&
|
||||||
|
m?.provider?.providerName == providerName,
|
||||||
);
|
);
|
||||||
showToast(selectedModel?.displayName ?? "");
|
showToast(selectedModel?.displayName ?? "");
|
||||||
} else {
|
} else {
|
||||||
@ -787,6 +791,14 @@ export function ChatActions(props: {
|
|||||||
icon={<ShortcutkeyIcon />}
|
icon={<ShortcutkeyIcon />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
<div className={styles["chat-input-actions-end"]}>
|
||||||
|
<ChatAction
|
||||||
|
onClick={() => props.setShowChatSidePanel(true)}
|
||||||
|
text={"Realtime Chat"}
|
||||||
|
icon={<HeadphoneIcon />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1580,7 +1592,10 @@ function _Chat() {
|
|||||||
};
|
};
|
||||||
}, [messages, chatStore, navigate]);
|
}, [messages, chatStore, navigate]);
|
||||||
|
|
||||||
|
const [showChatSidePanel, setShowChatSidePanel] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className={styles.chat} key={session.id}>
|
<div className={styles.chat} key={session.id}>
|
||||||
<div className="window-header" data-tauri-drag-region>
|
<div className="window-header" data-tauri-drag-region>
|
||||||
{isMobileScreen && (
|
{isMobileScreen && (
|
||||||
@ -1596,7 +1611,9 @@ function _Chat() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={clsx("window-header-title", styles["chat-body-title"])}>
|
<div
|
||||||
|
className={clsx("window-header-title", styles["chat-body-title"])}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"window-header-main-title",
|
"window-header-main-title",
|
||||||
@ -1666,7 +1683,8 @@ function _Chat() {
|
|||||||
setShowModal={setShowPromptModal}
|
setShowModal={setShowPromptModal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles["chat-main"]}>
|
||||||
|
<div className={styles["chat-body-container"]}>
|
||||||
<div
|
<div
|
||||||
className={styles["chat-body"]}
|
className={styles["chat-body"]}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@ -1686,13 +1704,16 @@ function _Chat() {
|
|||||||
!isContext;
|
!isContext;
|
||||||
const showTyping = message.preview || message.streaming;
|
const showTyping = message.preview || message.streaming;
|
||||||
|
|
||||||
const shouldShowClearContextDivider = i === clearContextIndex - 1;
|
const shouldShowClearContextDivider =
|
||||||
|
i === clearContextIndex - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={message.id}>
|
<Fragment key={message.id}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isUser ? styles["chat-message-user"] : styles["chat-message"]
|
isUser
|
||||||
|
? styles["chat-message-user"]
|
||||||
|
: styles["chat-message"]
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={styles["chat-message-container"]}>
|
<div className={styles["chat-message-container"]}>
|
||||||
@ -1712,7 +1733,9 @@ function _Chat() {
|
|||||||
newMessage;
|
newMessage;
|
||||||
const images = getMessageImages(message);
|
const images = getMessageImages(message);
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
newContent = [{ type: "text", text: newMessage }];
|
newContent = [
|
||||||
|
{ type: "text", text: newMessage },
|
||||||
|
];
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
newContent.push({
|
newContent.push({
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
@ -1746,7 +1769,8 @@ function _Chat() {
|
|||||||
<MaskAvatar
|
<MaskAvatar
|
||||||
avatar={session.mask.avatar}
|
avatar={session.mask.avatar}
|
||||||
model={
|
model={
|
||||||
message.model || session.mask.modelConfig.model
|
message.model ||
|
||||||
|
session.mask.modelConfig.model
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1811,7 +1835,9 @@ function _Chat() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openaiSpeech(getMessageTextContent(message))
|
openaiSpeech(
|
||||||
|
getMessageTextContent(message),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1875,10 +1901,11 @@ function _Chat() {
|
|||||||
)}
|
)}
|
||||||
{getMessageImages(message).length > 1 && (
|
{getMessageImages(message).length > 1 && (
|
||||||
<div
|
<div
|
||||||
className={clsx(styles["chat-message-item-images"])}
|
className={styles["chat-message-item-images"]}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--image-count": getMessageImages(message).length,
|
"--image-count":
|
||||||
|
getMessageImages(message).length,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -1897,6 +1924,11 @@ function _Chat() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{message?.audio_url && (
|
||||||
|
<div className={styles["chat-message-audio"]}>
|
||||||
|
<audio src={message.audio_url} controls />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles["chat-message-action-date"]}>
|
<div className={styles["chat-message-action-date"]}>
|
||||||
{isContext
|
{isContext
|
||||||
@ -1910,9 +1942,11 @@ function _Chat() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles["chat-input-panel"]}>
|
<div className={styles["chat-input-panel"]}>
|
||||||
<PromptHints prompts={promptHints} onPromptSelect={onPromptSelect} />
|
<PromptHints
|
||||||
|
prompts={promptHints}
|
||||||
|
onPromptSelect={onPromptSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
<ChatActions
|
<ChatActions
|
||||||
uploadImage={uploadImage}
|
uploadImage={uploadImage}
|
||||||
@ -1935,6 +1969,7 @@ function _Chat() {
|
|||||||
}}
|
}}
|
||||||
setShowShortcutKeyModal={setShowShortcutKeyModal}
|
setShowShortcutKeyModal={setShowShortcutKeyModal}
|
||||||
setUserInput={setUserInput}
|
setUserInput={setUserInput}
|
||||||
|
setShowChatSidePanel={setShowChatSidePanel}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
className={clsx(styles["chat-input-panel-inner"], {
|
className={clsx(styles["chat-input-panel-inner"], {
|
||||||
@ -1993,7 +2028,24 @@ function _Chat() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(styles["chat-side-panel"], {
|
||||||
|
[styles["mobile"]]: isMobileScreen,
|
||||||
|
[styles["chat-side-panel-show"]]: showChatSidePanel,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RealtimeChat
|
||||||
|
onClose={() => {
|
||||||
|
setShowChatSidePanel(false);
|
||||||
|
}}
|
||||||
|
onStartVoice={async () => {
|
||||||
|
console.log("start voice");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{showExport && (
|
{showExport && (
|
||||||
<ExportMessageModal onClose={() => setShowExport(false)} />
|
<ExportMessageModal onClose={() => setShowExport(false)} />
|
||||||
)}
|
)}
|
||||||
@ -2009,7 +2061,7 @@ function _Chat() {
|
|||||||
{showShortcutKeyModal && (
|
{showShortcutKeyModal && (
|
||||||
<ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
|
<ShortcutKeyModal onClose={() => setShowShortcutKeyModal(false)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
id: string;
|
||||||
model?: ModelType;
|
model?: ModelType;
|
||||||
tools?: ChatMessageTool[];
|
tools?: ChatMessageTool[];
|
||||||
|
audio_url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMessage(override: Partial<ChatMessage>): ChatMessage {
|
export function createMessage(override: Partial<ChatMessage>): ChatMessage {
|
||||||
|
Loading…
Reference in New Issue
Block a user