mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-10-31 22:33:45 +08:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			v2.12.1
			...
			feat/voice
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 52316785d1 | ||
|  | e2b15f785a | ||
|  | 42d04d473e | ||
|  | 2f53107581 | 
| @@ -58,7 +58,7 @@ | ||||
|     box-shadow: var(--card-shadow); | ||||
|     transition: width ease 0.3s; | ||||
|     align-items: center; | ||||
|     height: 16px; | ||||
|     height: 24px; | ||||
|     width: var(--icon-width); | ||||
|     overflow: hidden; | ||||
|  | ||||
| @@ -68,7 +68,6 @@ | ||||
|  | ||||
|     .text { | ||||
|       white-space: nowrap; | ||||
|       padding-left: 5px; | ||||
|       opacity: 0; | ||||
|       transform: translateX(-5px); | ||||
|       transition: all ease 0.3s; | ||||
| @@ -610,10 +609,6 @@ | ||||
| .chat-input-send { | ||||
|   background-color: var(--primary); | ||||
|   color: white; | ||||
|  | ||||
|   position: absolute; | ||||
|   right: 30px; | ||||
|   bottom: 32px; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 600px) { | ||||
|   | ||||
| @@ -97,7 +97,7 @@ import { ExportMessageModal } from "./exporter"; | ||||
| import { getClientConfig } from "../config/client"; | ||||
| import { useAllModels } from "../utils/hooks"; | ||||
| import { MultimodalContent } from "../client/api"; | ||||
|  | ||||
| import SpeechRecorder from "./chat/speechRecorder"; | ||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||
|   loading: () => <LoadingIcon />, | ||||
| }); | ||||
| @@ -347,7 +347,7 @@ function ChatAction(props: { | ||||
|     full: 16, | ||||
|     icon: 16, | ||||
|   }); | ||||
|  | ||||
|   const [isActive, setIsActive] = useState(false); | ||||
|   function updateWidth() { | ||||
|     if (!iconRef.current || !textRef.current) return; | ||||
|     const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; | ||||
| @@ -361,25 +361,22 @@ function ChatAction(props: { | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className={`${styles["chat-input-action"]} clickable`} | ||||
|       className={`${styles["chat-input-action"]} clickable group`} | ||||
|       onClick={() => { | ||||
|         props.onClick(); | ||||
|         setTimeout(updateWidth, 1); | ||||
|       }} | ||||
|       onMouseEnter={updateWidth} | ||||
|       onTouchStart={updateWidth} | ||||
|       style={ | ||||
|         { | ||||
|           "--icon-width": `${width.icon}px`, | ||||
|           "--full-width": `${width.full}px`, | ||||
|         } as React.CSSProperties | ||||
|       } | ||||
|     > | ||||
|       <div ref={iconRef} className={styles["icon"]}> | ||||
|         {props.icon} | ||||
|       </div> | ||||
|       <div className={styles["text"]} ref={textRef}> | ||||
|         {props.text} | ||||
|       <div className="flex"> | ||||
|         <div ref={iconRef} className={styles["icon"]}> | ||||
|           {props.icon} | ||||
|         </div> | ||||
|         <div | ||||
|           className={`${styles["text"]} transition-all duration-1000 w-0 group-hover:w-[60px]`} | ||||
|           ref={textRef} | ||||
|         > | ||||
|           {props.text} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| @@ -424,6 +421,7 @@ export function ChatActions(props: { | ||||
|   showPromptModal: () => void; | ||||
|   scrollToBottom: () => void; | ||||
|   showPromptHints: () => void; | ||||
|   setUserInput: (text: string) => void; | ||||
|   hitBottom: boolean; | ||||
|   uploading: boolean; | ||||
| }) { | ||||
| @@ -1462,6 +1460,7 @@ function _Chat() { | ||||
|           scrollToBottom={scrollToBottom} | ||||
|           hitBottom={hitBottom} | ||||
|           uploading={uploading} | ||||
|           setUserInput={setUserInput} | ||||
|           showPromptHints={() => { | ||||
|             // Click again to close | ||||
|             if (promptHints.length > 0) { | ||||
| @@ -1522,13 +1521,19 @@ function _Chat() { | ||||
|               })} | ||||
|             </div> | ||||
|           )} | ||||
|           <IconButton | ||||
|             icon={<SendWhiteIcon />} | ||||
|             text={Locale.Chat.Send} | ||||
|             className={styles["chat-input-send"]} | ||||
|             type="primary" | ||||
|             onClick={() => doSubmit(userInput)} | ||||
|           /> | ||||
|           <div className="flex gap-2 absolute left-[30px] bottom-[32px]"> | ||||
|             <SpeechRecorder textUpdater={setUserInput}></SpeechRecorder> | ||||
|           </div> | ||||
|  | ||||
|           <div className="flex gap-2 absolute right-[30px] bottom-[32px]"> | ||||
|             <IconButton | ||||
|               icon={<SendWhiteIcon />} | ||||
|               text={Locale.Chat.Send} | ||||
|               className={styles["chat-input-send"]} | ||||
|               type="primary" | ||||
|               onClick={() => doSubmit(userInput)} | ||||
|             /> | ||||
|           </div> | ||||
|         </label> | ||||
|       </div> | ||||
|  | ||||
|   | ||||
							
								
								
									
										64
									
								
								app/components/chat/speechRecorder.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								app/components/chat/speechRecorder.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import React, { useState, useEffect } from "react"; | ||||
| import VoiceIcon from "@/app/icons/voice.svg"; | ||||
| import { getLang, formatLang } from "@/app/locales"; | ||||
| type SpeechRecognitionType = | ||||
|   | typeof window.SpeechRecognition | ||||
|   | typeof window.webkitSpeechRecognition; | ||||
|  | ||||
| export default function SpeechRecorder({ | ||||
|   textUpdater, | ||||
|   onStop, | ||||
| }: { | ||||
|   textUpdater: (text: string) => void; | ||||
|   onStop?: () => void; | ||||
| }) { | ||||
|   const [speechRecognition, setSpeechRecognition] = | ||||
|     useState<SpeechRecognitionType | null>(null); | ||||
|   const [isRecording, setIsRecording] = useState(false); | ||||
|   useEffect(() => { | ||||
|     if ("SpeechRecognition" in window) { | ||||
|       setSpeechRecognition(new (window as any).SpeechRecognition()); | ||||
|     } else if ("webkitSpeechRecognition" in window) { | ||||
|       setSpeechRecognition(new (window as any).webkitSpeechRecognition()); | ||||
|     } | ||||
|   }, []); | ||||
|   return ( | ||||
|     <> | ||||
|       {speechRecognition && ( | ||||
|         <div> | ||||
|           <button | ||||
|             onClick={() => { | ||||
|               if (!isRecording && speechRecognition) { | ||||
|                 speechRecognition.continuous = true; | ||||
|                 speechRecognition.lang = formatLang(getLang()); | ||||
|                 console.log(speechRecognition.lang); | ||||
|                 speechRecognition.interimResults = true; | ||||
|                 speechRecognition.start(); | ||||
|                 speechRecognition.onresult = function (event: any) { | ||||
|                   console.log(event); | ||||
|                   var transcript = event.results[0][0].transcript; | ||||
|                   console.log(transcript); | ||||
|                   textUpdater(transcript); | ||||
|                 }; | ||||
|                 setIsRecording(true); | ||||
|               } else { | ||||
|                 speechRecognition.stop(); | ||||
|                 setIsRecording(false); | ||||
|               } | ||||
|             }} | ||||
|           > | ||||
|             {isRecording ? ( | ||||
|               <button className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 ring-4 ring-blue-200 transition animate-pulse"> | ||||
|                 <VoiceIcon fill={"white"} /> | ||||
|               </button> | ||||
|             ) : ( | ||||
|               <button className="p-2 rounded-full bg-zinc-100 hover:bg-zinc-200 transition"> | ||||
|                 <VoiceIcon fill={"#8282A5"} /> | ||||
|               </button> | ||||
|             )} | ||||
|           </button> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										11
									
								
								app/icons/voice.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/icons/voice.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||||
|     <path fill-rule="evenodd" clip-rule="evenodd" | ||||
|         d="M5.9375 5.3125C5.9375 3.06884 7.75634 1.25 10 1.25C12.2437 1.25 14.0625 3.06884 14.0625 5.3125V8.75C14.0625 10.9937 12.2437 12.8125 10 12.8125C7.75634 12.8125 5.9375 10.9937 5.9375 8.75V5.3125Z" | ||||
|         fill="auto" /> | ||||
|     <path fill-rule="evenodd" clip-rule="evenodd" | ||||
|         d="M3.35938 7.8125C3.79085 7.8125 4.14062 8.16228 4.14062 8.59375V8.75C4.14062 11.986 6.76396 14.6094 10 14.6094C13.236 14.6094 15.8594 11.986 15.8594 8.75V8.59375C15.8594 8.16228 16.2092 7.8125 16.6406 7.8125C17.0721 7.8125 17.4219 8.16228 17.4219 8.59375V8.75C17.4219 12.849 14.099 16.1719 10 16.1719C5.90101 16.1719 2.57812 12.849 2.57812 8.75V8.59375C2.57812 8.16228 2.9279 7.8125 3.35938 7.8125Z" | ||||
|         fill="auto" /> | ||||
|     <path | ||||
|         d="M9.21875 15.4688C9.21875 15.0373 9.56853 14.6875 10 14.6875C10.4315 14.6875 10.7812 15.0373 10.7812 15.4688V17.9688C10.7812 18.4002 10.4315 18.75 10 18.75C9.56853 18.75 9.21875 18.4002 9.21875 17.9688V15.4688Z" | ||||
|         fill="auto" /> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
| @@ -70,6 +70,28 @@ export const ALL_LANG_OPTIONS: Record<Lang, string> = { | ||||
|   sk: "Slovensky", | ||||
| }; | ||||
|  | ||||
| const LANG_CODE_MAPPING = { | ||||
|   cn: "zh-CN", | ||||
|   en: "en-US", | ||||
|   tw: "zh-TW", | ||||
|   pt: "pt-BR", | ||||
|   jp: "ja-JP", | ||||
|   ko: "ko-KR", | ||||
|   id: "id-ID", | ||||
|   fr: "fr-FR", | ||||
|   es: "es-ES", | ||||
|   it: "it-IT", | ||||
|   tr: "tr-TR", | ||||
|   de: "de-DE", | ||||
|   vi: "vi-VN", | ||||
|   ru: "ru-RU", | ||||
|   cs: "cs-CZ", | ||||
|   no: "nb-NO", | ||||
|   ar: "ar-SA", | ||||
|   bn: "bn-BD", | ||||
|   sk: "sk-SK", | ||||
| }; | ||||
|  | ||||
| const LANG_KEY = "lang"; | ||||
| const DEFAULT_LANG = "en"; | ||||
|  | ||||
| @@ -81,6 +103,13 @@ merge(fallbackLang, targetLang); | ||||
|  | ||||
| export default fallbackLang as LocaleType; | ||||
|  | ||||
| export const formatLang = (languageCode: string) => { | ||||
|   return ( | ||||
|     LANG_CODE_MAPPING[languageCode as keyof typeof LANG_CODE_MAPPING] || | ||||
|     languageCode | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| function getItem(key: string) { | ||||
|   try { | ||||
|     return localStorage.getItem(key); | ||||
|   | ||||
| @@ -1,3 +1,6 @@ | ||||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
| @import "./animation.scss"; | ||||
| @import "./window.scss"; | ||||
|  | ||||
|   | ||||
| @@ -50,6 +50,7 @@ | ||||
|     "@types/react-dom": "^18.2.7", | ||||
|     "@types/react-katex": "^3.0.0", | ||||
|     "@types/spark-md5": "^3.0.4", | ||||
|     "autoprefixer": "^10.4.18", | ||||
|     "cross-env": "^7.0.3", | ||||
|     "eslint": "^8.49.0", | ||||
|     "eslint-config-next": "13.4.19", | ||||
| @@ -57,12 +58,14 @@ | ||||
|     "eslint-plugin-prettier": "^4.2.1", | ||||
|     "husky": "^8.0.0", | ||||
|     "lint-staged": "^13.2.2", | ||||
|     "postcss": "^8.4.35", | ||||
|     "prettier": "^3.0.2", | ||||
|     "tailwindcss": "^3.4.1", | ||||
|     "typescript": "5.2.2", | ||||
|     "webpack": "^5.88.1" | ||||
|   }, | ||||
|   "resolutions": { | ||||
|     "lint-staged/yaml": "^2.2.2" | ||||
|   }, | ||||
|   } | ||||
|   "packageManager": "yarn@1.22.19" | ||||
| } | ||||
|   | ||||
							
								
								
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| module.exports = { | ||||
|   plugins: { | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {}, | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										15
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| /** @type {import('tailwindcss').Config} */ | ||||
| module.exports = { | ||||
|   content: [ | ||||
|     "./app/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|     "./pages/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|     "./components/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|  | ||||
|     // Or if using `src` directory: | ||||
|     "./src/**/*.{js,ts,jsx,tsx,mdx}", | ||||
|   ], | ||||
|   theme: { | ||||
|     extend: {}, | ||||
|   }, | ||||
|   plugins: [], | ||||
| } | ||||
| @@ -23,6 +23,12 @@ | ||||
|       "@/*": ["./*"] | ||||
|     } | ||||
|   }, | ||||
|   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/calcTextareaHeight.ts"], | ||||
|   "include": [ | ||||
|     "next-env.d.ts", | ||||
|     "**/*.ts", | ||||
|     "**/*.tsx", | ||||
|     ".next/types/**/*.ts", | ||||
|     "app/calcTextareaHeight.ts" | ||||
|   ], | ||||
|   "exclude": ["node_modules"] | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user