feat: support voice input

This commit is contained in:
Hk-Gosuto
2024-03-13 23:02:28 +08:00
parent e66766f85d
commit bc061fe0f2
13 changed files with 361 additions and 94 deletions

View File

@@ -10,6 +10,7 @@ import React, {
} from "react";
import SendWhiteIcon from "../icons/send-white.svg";
import VoiceWhiteIcon from "../icons/voice-white.svg";
import BrainIcon from "../icons/brain.svg";
import RenameIcon from "../icons/rename.svg";
import ExportIcon from "../icons/share.svg";
@@ -73,7 +74,7 @@ import dynamic from "next/dynamic";
import { ChatControllerPool } from "../client/controller";
import { Prompt, usePromptStore } from "../store/prompt";
import Locale from "../locales";
import Locale, { getLang, getSTTLang } from "../locales";
import { IconButton } from "./button";
import styles from "./chat.module.scss";
@@ -799,6 +800,27 @@ function _Chat() {
}
};
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<any>(null);
const startListening = () => {
if (recognition) {
recognition.start();
setIsListening(true);
}
};
const stopListening = () => {
if (recognition) {
recognition.stop();
setIsListening(false);
}
};
const onRecognitionEnd = (finalTranscript: string) => {
console.log(finalTranscript);
if (finalTranscript) setUserInput(finalTranscript);
};
const doSubmit = (userInput: string) => {
if (userInput.trim() === "") return;
const matchCommand = chatCommands.match(userInput);
@@ -869,6 +891,26 @@ function _Chat() {
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
if (typeof window !== "undefined") {
const SpeechRecognition =
(window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition;
const recognitionInstance = new SpeechRecognition();
recognitionInstance.continuous = true;
recognitionInstance.interimResults = true;
let lang = getSTTLang();
recognitionInstance.lang = lang;
recognitionInstance.onresult = (event: any) => {
const result = event.results[event.results.length - 1];
if (result.isFinal) {
if (!isListening) {
onRecognitionEnd(result[0].transcript);
}
}
};
setRecognition(recognitionInstance);
}
}, []);
// check if should send message
@@ -1649,13 +1691,26 @@ function _Chat() {
})}
</div>
)}
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
{config.sttConfig.enable ? (
<IconButton
icon={<VoiceWhiteIcon />}
text={
isListening ? Locale.Chat.StopSpeak : Locale.Chat.StartSpeak
}
className={styles["chat-input-send"]}
type="primary"
onClick={() => (isListening ? stopListening() : startListening())}
/>
) : (
<IconButton
icon={<SendWhiteIcon />}
text={Locale.Chat.Send}
className={styles["chat-input-send"]}
type="primary"
onClick={() => doSubmit(userInput)}
/>
)}
</label>
</div>

View File

@@ -73,6 +73,7 @@ import { PluginConfigList } from "./plugin-config";
import { useMaskStore } from "../store/mask";
import { ProviderType } from "../utils/cloud";
import { TTSConfigList } from "./tts-config";
import { STTConfigList } from "./stt-config";
function EditPromptModal(props: { id: string; onClose: () => void }) {
const promptStore = usePromptStore();
@@ -1212,6 +1213,17 @@ export function Settings() {
/>
</List>
<List>
<STTConfigList
sttConfig={config.sttConfig}
updateConfig={(updater) => {
const sttConfig = { ...config.sttConfig };
updater(sttConfig);
config.update((config) => (config.sttConfig = sttConfig));
}}
/>
</List>
<DangerItems />
</div>
</ErrorBoundary>

View File

@@ -0,0 +1,28 @@
import { STTConfig } from "../store";
import Locale from "../locales";
import { ListItem } from "./ui-lib";
export function STTConfigList(props: {
sttConfig: STTConfig;
updateConfig: (updater: (config: STTConfig) => void) => void;
}) {
return (
<>
<ListItem
title={Locale.Settings.STT.Enable.Title}
subTitle={Locale.Settings.STT.Enable.SubTitle}
>
<input
type="checkbox"
checked={props.sttConfig.enable}
onChange={(e) =>
props.updateConfig(
(config) => (config.enable = e.currentTarget.checked),
)
}
></input>
</ListItem>
</>
);
}

View File

@@ -0,0 +1,119 @@
@import "../styles/animation.scss";
.plugin-page {
height: 100%;
display: flex;
flex-direction: column;
.plugin-page-body {
padding: 20px;
overflow-y: auto;
.plugin-filter {
width: 100%;
max-width: 100%;
margin-bottom: 20px;
animation: slide-in ease 0.3s;
height: 40px;
display: flex;
.search-bar {
flex-grow: 1;
max-width: 100%;
min-width: 0;
outline: none;
}
.search-bar:focus {
border: 1px solid var(--primary);
}
.plugin-filter-lang {
height: 100%;
margin-left: 10px;
}
.plugin-create {
height: 100%;
margin-left: 10px;
box-sizing: border-box;
min-width: 80px;
}
}
.plugin-item {
display: flex;
justify-content: space-between;
padding: 20px;
border: var(--border-in-light);
animation: slide-in ease 0.3s;
&:not(:last-child) {
border-bottom: 0;
}
&:first-child {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
&:last-child {
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.plugin-header {
display: flex;
align-items: center;
.plugin-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.plugin-title {
.plugin-name {
font-size: 14px;
font-weight: bold;
}
.plugin-info {
font-size: 12px;
}
.plugin-runtime-warning {
font-size: 12px;
color: #f86c6c;
}
}
}
.plugin-actions {
display: flex;
flex-wrap: nowrap;
transition: all ease 0.3s;
justify-content: center;
align-items: center;
}
@media screen and (max-width: 600px) {
display: flex;
flex-direction: column;
padding-bottom: 10px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: var(--card-shadow);
&:not(:last-child) {
border-bottom: var(--border-in-light);
}
.plugin-actions {
width: 100%;
justify-content: space-between;
padding-top: 10px;
}
}
}
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="#fff" class="w-5 h-5 translate-y-[0.5px]"><path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z"></path><path d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"></path></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -79,6 +79,8 @@ const cn = {
return inputHints + "/ 触发补全,: 触发命令";
},
Send: "发送",
StartSpeak: "开始说话",
StopSpeak: "停止说话",
Config: {
Reset: "清除记忆",
SaveAs: "存为面具",
@@ -395,6 +397,12 @@ const cn = {
SubTitle: "生成语音的速度",
},
},
STT: {
Enable: {
Title: "启用语音转文本",
SubTitle: "启用语音转文本",
},
},
},
Store: {
DefaultTopic: "新的聊天",

View File

@@ -81,6 +81,8 @@ const en: LocaleType = {
return inputHints + ", / to search prompts, : to use commands";
},
Send: "Send",
StartSpeak: "Start talking",
StopSpeak: "Stop talking",
Config: {
Reset: "Reset to Default",
SaveAs: "Save as Mask",
@@ -401,6 +403,12 @@ const en: LocaleType = {
SubTitle: "The speed of the generated audio",
},
},
STT: {
Enable: {
Title: "Enable STT",
SubTitle: "Enable Speech-to-Text",
},
},
},
Store: {
DefaultTopic: "New Conversation",

View File

@@ -103,6 +103,36 @@ function getLanguage() {
}
}
const DEFAULT_STT_LANG = "zh-CN";
export const STT_LANG_MAP: Record<Lang, string> = {
cn: "zh-CN",
en: "en-US",
pt: "pt-BR",
tw: "zh-TW",
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: "no-NO",
ar: "ar-SA",
bn: "bn-BD",
sk: "sk-SK",
};
export function getSTTLang(): string {
try {
return STT_LANG_MAP[getLang()];
} catch {
return DEFAULT_STT_LANG;
}
}
export function getLang(): Lang {
const savedLang = getItem(LANG_KEY);

View File

@@ -78,6 +78,10 @@ export const DEFAULT_CONFIG = {
voice: DEFAULT_TTS_VOICE,
speed: 1.0,
},
sttConfig: {
enable: false,
},
};
export type ChatConfig = typeof DEFAULT_CONFIG;
@@ -85,6 +89,7 @@ export type ChatConfig = typeof DEFAULT_CONFIG;
export type ModelConfig = ChatConfig["modelConfig"];
export type PluginConfig = ChatConfig["pluginConfig"];
export type TTSConfig = ChatConfig["ttsConfig"];
export type STTConfig = ChatConfig["sttConfig"];
export function limitNumber(
x: number,