realtime-chat.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import VoiceIcon from "@/app/icons/voice.svg";
  2. import VoiceOffIcon from "@/app/icons/voice-off.svg";
  3. import Close24Icon from "@/app/icons/close-24.svg";
  4. import styles from "./realtime-chat.module.scss";
  5. import clsx from "clsx";
  6. import { useState, useRef, useCallback } from "react";
  7. import { useAccessStore, useChatStore, ChatMessage } from "@/app/store";
  8. interface RealtimeChatProps {
  9. onClose?: () => void;
  10. onStartVoice?: () => void;
  11. onPausedVoice?: () => void;
  12. sampleRate?: number;
  13. }
  14. export function RealtimeChat({
  15. onClose,
  16. onStartVoice,
  17. onPausedVoice,
  18. sampleRate = 24000,
  19. }: RealtimeChatProps) {
  20. const [isVoicePaused, setIsVoicePaused] = useState(true);
  21. const clientRef = useRef<null>(null);
  22. const currentItemId = useRef<string>("");
  23. const currentBotMessage = useRef<ChatMessage | null>();
  24. const currentUserMessage = useRef<ChatMessage | null>();
  25. const accessStore = useAccessStore.getState();
  26. const chatStore = useChatStore();
  27. // useEffect(() => {
  28. // if (
  29. // clientRef.current?.getTurnDetectionType() === "server_vad" &&
  30. // audioData
  31. // ) {
  32. // // console.log("appendInputAudio", audioData);
  33. // // 将录制的16PCM音频发送给openai
  34. // clientRef.current?.appendInputAudio(audioData);
  35. // }
  36. // }, [audioData]);
  37. // useEffect(() => {
  38. // console.log("isRecording", isRecording);
  39. // if (!isRecording.current) return;
  40. // if (!clientRef.current) {
  41. // const apiKey = accessStore.openaiApiKey;
  42. // const client = (clientRef.current = new RealtimeClient({
  43. // url: "wss://api.openai.com/v1/realtime",
  44. // apiKey,
  45. // dangerouslyAllowAPIKeyInBrowser: true,
  46. // debug: true,
  47. // }));
  48. // client
  49. // .connect()
  50. // .then(() => {
  51. // // TODO 设置真实的上下文
  52. // client.sendUserMessageContent([
  53. // {
  54. // type: `input_text`,
  55. // text: `Hi`,
  56. // // 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".`
  57. // },
  58. // ]);
  59. // // 配置服务端判断说话人开启还是结束
  60. // client.updateSession({
  61. // turn_detection: { type: "server_vad" },
  62. // });
  63. // client.on("realtime.event", (realtimeEvent) => {
  64. // // 调试
  65. // console.log("realtime.event", realtimeEvent);
  66. // });
  67. // client.on("conversation.interrupted", async () => {
  68. // if (currentBotMessage.current) {
  69. // stopPlaying();
  70. // try {
  71. // client.cancelResponse(
  72. // currentBotMessage.current?.id,
  73. // currentTime(),
  74. // );
  75. // } catch (e) {
  76. // console.error(e);
  77. // }
  78. // }
  79. // });
  80. // client.on("conversation.updated", async (event: any) => {
  81. // // console.log("currentSession", chatStore.currentSession());
  82. // // const items = client.conversation.getItems();
  83. // const content = event?.item?.content?.[0]?.transcript || "";
  84. // const text = event?.item?.content?.[0]?.text || "";
  85. // // console.log(
  86. // // "conversation.updated",
  87. // // event,
  88. // // "content[0]",
  89. // // event?.item?.content?.[0]?.transcript,
  90. // // "formatted",
  91. // // event?.item?.formatted?.transcript,
  92. // // "content",
  93. // // content,
  94. // // "text",
  95. // // text,
  96. // // event?.item?.status,
  97. // // event?.item?.role,
  98. // // items.length,
  99. // // items,
  100. // // );
  101. // const { item, delta } = event;
  102. // const { role, id, status, formatted } = item || {};
  103. // if (id && role == "assistant") {
  104. // if (
  105. // !currentBotMessage.current ||
  106. // currentBotMessage.current?.id != id
  107. // ) {
  108. // // create assistant message and save to session
  109. // currentBotMessage.current = createMessage({ id, role });
  110. // chatStore.updateCurrentSession((session) => {
  111. // session.messages = session.messages.concat([
  112. // currentBotMessage.current!,
  113. // ]);
  114. // });
  115. // }
  116. // if (currentBotMessage.current?.id != id) {
  117. // stopPlaying();
  118. // }
  119. // if (content) {
  120. // currentBotMessage.current.content = content;
  121. // chatStore.updateCurrentSession((session) => {
  122. // session.messages = session.messages.concat();
  123. // });
  124. // }
  125. // if (delta?.audio) {
  126. // // typeof delta.audio is Int16Array
  127. // // 直接播放
  128. // addInt16PCM(delta.audio);
  129. // }
  130. // // console.log(
  131. // // "updated try save wavFile",
  132. // // status,
  133. // // currentBotMessage.current?.audio_url,
  134. // // formatted?.audio,
  135. // // );
  136. // if (
  137. // status == "completed" &&
  138. // !currentBotMessage.current?.audio_url &&
  139. // formatted?.audio?.length
  140. // ) {
  141. // // 转换为wav文件保存 TODO 使用mp3格式会更节省空间
  142. // const botMessage = currentBotMessage.current;
  143. // const wavFile = new WavPacker().pack(sampleRate, {
  144. // bitsPerSample: 16,
  145. // channelCount: 1,
  146. // data: formatted?.audio,
  147. // });
  148. // // 这里将音频文件放到对象里面wavFile.url可以使用<audio>标签播放
  149. // item.formatted.file = wavFile;
  150. // uploadImageRemote(wavFile.blob).then((audio_url) => {
  151. // botMessage.audio_url = audio_url;
  152. // chatStore.updateCurrentSession((session) => {
  153. // session.messages = session.messages.concat();
  154. // });
  155. // });
  156. // }
  157. // if (
  158. // status == "completed" &&
  159. // !currentBotMessage.current?.content
  160. // ) {
  161. // chatStore.updateCurrentSession((session) => {
  162. // session.messages = session.messages.filter(
  163. // (m) => m.id !== currentBotMessage.current?.id,
  164. // );
  165. // });
  166. // }
  167. // }
  168. // if (id && role == "user" && !text) {
  169. // if (
  170. // !currentUserMessage.current ||
  171. // currentUserMessage.current?.id != id
  172. // ) {
  173. // // create assistant message and save to session
  174. // currentUserMessage.current = createMessage({ id, role });
  175. // chatStore.updateCurrentSession((session) => {
  176. // session.messages = session.messages.concat([
  177. // currentUserMessage.current!,
  178. // ]);
  179. // });
  180. // }
  181. // if (content) {
  182. // // 转换为wav文件保存 TODO 使用mp3格式会更节省空间
  183. // const userMessage = currentUserMessage.current;
  184. // const wavFile = new WavPacker().pack(sampleRate, {
  185. // bitsPerSample: 16,
  186. // channelCount: 1,
  187. // data: formatted?.audio,
  188. // });
  189. // // 这里将音频文件放到对象里面wavFile.url可以使用<audio>标签播放
  190. // item.formatted.file = wavFile;
  191. // uploadImageRemote(wavFile.blob).then((audio_url) => {
  192. // // update message content
  193. // userMessage.content = content;
  194. // // update message audio_url
  195. // userMessage.audio_url = audio_url;
  196. // chatStore.updateCurrentSession((session) => {
  197. // session.messages = session.messages.concat();
  198. // });
  199. // });
  200. // }
  201. // }
  202. // });
  203. // })
  204. // .catch((e) => {
  205. // console.error("Error", e);
  206. // });
  207. // }
  208. // return () => {
  209. // stop();
  210. // // TODO close client
  211. // clientRef.current?.disconnect();
  212. // };
  213. // }, [isRecording.current]);
  214. const handleStartVoice = useCallback(() => {
  215. onStartVoice?.();
  216. setIsVoicePaused(false);
  217. }, []);
  218. const handlePausedVoice = () => {
  219. onPausedVoice?.();
  220. setIsVoicePaused(true);
  221. };
  222. return (
  223. <div className={styles["realtime-chat"]}>
  224. <div
  225. className={clsx(styles["circle-mic"], {
  226. [styles["pulse"]]: true,
  227. })}
  228. >
  229. <div className={styles["icon-center"]}></div>
  230. </div>
  231. <div className={styles["bottom-icons"]}>
  232. <div className={styles["icon-left"]}>
  233. {isVoicePaused ? (
  234. <VoiceOffIcon onClick={handleStartVoice} />
  235. ) : (
  236. <VoiceIcon onClick={handlePausedVoice} />
  237. )}
  238. </div>
  239. <div className={styles["icon-right"]} onClick={onClose}>
  240. <Close24Icon />
  241. </div>
  242. </div>
  243. </div>
  244. );
  245. }