diff --git a/app/components/realtime-chat/index.ts b/app/components/realtime-chat/index.ts
new file mode 100644
index 00000000000..fdf090f4195
--- /dev/null
+++ b/app/components/realtime-chat/index.ts
@@ -0,0 +1 @@
+export * from "./realtime-chat";
diff --git a/app/components/realtime-chat/realtime-chat.module.scss b/app/components/realtime-chat/realtime-chat.module.scss
new file mode 100644
index 00000000000..ef58bebb655
--- /dev/null
+++ b/app/components/realtime-chat/realtime-chat.module.scss
@@ -0,0 +1,74 @@
+.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;
+ align-items: center;
+ 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;
+ }
+}
diff --git a/app/components/realtime-chat/realtime-chat.tsx b/app/components/realtime-chat/realtime-chat.tsx
new file mode 100644
index 00000000000..faa36373a2c
--- /dev/null
+++ b/app/components/realtime-chat/realtime-chat.tsx
@@ -0,0 +1,359 @@
+import VoiceIcon from "@/app/icons/voice.svg";
+import VoiceOffIcon from "@/app/icons/voice-off.svg";
+import PowerIcon from "@/app/icons/power.svg";
+
+import styles from "./realtime-chat.module.scss";
+import clsx from "clsx";
+
+import { useState, useRef, useEffect } from "react";
+
+import { useChatStore, createMessage, useAppConfig } from "@/app/store";
+
+import { IconButton } from "@/app/components/button";
+
+import {
+ Modality,
+ RTClient,
+ RTInputAudioItem,
+ RTResponse,
+ TurnDetection,
+} from "rt-client";
+import { AudioHandler } from "@/app/lib/audio";
+import { uploadImage } from "@/app/utils/chat";
+import { VoicePrint } from "@/app/components/voice-print";
+
+interface RealtimeChatProps {
+ onClose?: () => void;
+ onStartVoice?: () => void;
+ onPausedVoice?: () => void;
+}
+
+export function RealtimeChat({
+ onClose,
+ onStartVoice,
+ onPausedVoice,
+}: RealtimeChatProps) {
+ const chatStore = useChatStore();
+ const session = chatStore.currentSession();
+ const config = useAppConfig();
+ const [status, setStatus] = useState("");
+ const [isRecording, setIsRecording] = useState(false);
+ const [isConnected, setIsConnected] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [modality, setModality] = useState("audio");
+ const [useVAD, setUseVAD] = useState(true);
+ const [frequencies, setFrequencies] = useState();
+
+ const clientRef = useRef(null);
+ const audioHandlerRef = useRef(null);
+ const initRef = useRef(false);
+
+ const temperature = config.realtimeConfig.temperature;
+ const apiKey = config.realtimeConfig.apiKey;
+ const model = config.realtimeConfig.model;
+ const azure = config.realtimeConfig.provider === "Azure";
+ const azureEndpoint = config.realtimeConfig.azure.endpoint;
+ const azureDeployment = config.realtimeConfig.azure.deployment;
+ const voice = config.realtimeConfig.voice;
+
+ const handleConnect = async () => {
+ if (isConnecting) return;
+ if (!isConnected) {
+ try {
+ setIsConnecting(true);
+ clientRef.current = azure
+ ? new RTClient(
+ new URL(azureEndpoint),
+ { key: apiKey },
+ { deployment: azureDeployment },
+ )
+ : new RTClient({ key: apiKey }, { model });
+ const modalities: Modality[] =
+ modality === "audio" ? ["text", "audio"] : ["text"];
+ const turnDetection: TurnDetection = useVAD
+ ? { type: "server_vad" }
+ : null;
+ await clientRef.current.configure({
+ instructions: "",
+ voice,
+ input_audio_transcription: { model: "whisper-1" },
+ turn_detection: turnDetection,
+ tools: [],
+ temperature,
+ modalities,
+ });
+ startResponseListener();
+
+ setIsConnected(true);
+ // TODO
+ // try {
+ // const recentMessages = chatStore.getMessagesWithMemory();
+ // for (const message of recentMessages) {
+ // const { role, content } = message;
+ // if (typeof content === "string") {
+ // await clientRef.current.sendItem({
+ // type: "message",
+ // role: role as any,
+ // content: [
+ // {
+ // type: (role === "assistant" ? "text" : "input_text") as any,
+ // text: content as string,
+ // },
+ // ],
+ // });
+ // }
+ // }
+ // // await clientRef.current.generateResponse();
+ // } catch (error) {
+ // console.error("Set message failed:", error);
+ // }
+ } catch (error) {
+ console.error("Connection failed:", error);
+ setStatus("Connection failed");
+ } finally {
+ setIsConnecting(false);
+ }
+ } else {
+ await disconnect();
+ }
+ };
+
+ const disconnect = async () => {
+ if (clientRef.current) {
+ try {
+ await clientRef.current.close();
+ clientRef.current = null;
+ setIsConnected(false);
+ } catch (error) {
+ console.error("Disconnect failed:", error);
+ }
+ }
+ };
+
+ const startResponseListener = async () => {
+ if (!clientRef.current) return;
+
+ try {
+ for await (const serverEvent of clientRef.current.events()) {
+ if (serverEvent.type === "response") {
+ await handleResponse(serverEvent);
+ } else if (serverEvent.type === "input_audio") {
+ await handleInputAudio(serverEvent);
+ }
+ }
+ } catch (error) {
+ if (clientRef.current) {
+ console.error("Response iteration error:", error);
+ }
+ }
+ };
+
+ const handleResponse = async (response: RTResponse) => {
+ for await (const item of response) {
+ if (item.type === "message" && item.role === "assistant") {
+ const botMessage = createMessage({
+ role: item.role,
+ content: "",
+ });
+ // add bot message first
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat([botMessage]);
+ });
+ let hasAudio = false;
+ for await (const content of item) {
+ if (content.type === "text") {
+ for await (const text of content.textChunks()) {
+ botMessage.content += text;
+ }
+ } else if (content.type === "audio") {
+ const textTask = async () => {
+ for await (const text of content.transcriptChunks()) {
+ botMessage.content += text;
+ }
+ };
+ const audioTask = async () => {
+ audioHandlerRef.current?.startStreamingPlayback();
+ for await (const audio of content.audioChunks()) {
+ hasAudio = true;
+ audioHandlerRef.current?.playChunk(audio);
+ }
+ };
+ await Promise.all([textTask(), audioTask()]);
+ }
+ // update message.content
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ }
+ if (hasAudio) {
+ // upload audio get audio_url
+ const blob = audioHandlerRef.current?.savePlayFile();
+ uploadImage(blob!).then((audio_url) => {
+ botMessage.audio_url = audio_url;
+ // update text and audio_url
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ });
+ }
+ }
+ }
+ };
+
+ const handleInputAudio = async (item: RTInputAudioItem) => {
+ await item.waitForCompletion();
+ if (item.transcription) {
+ const userMessage = createMessage({
+ role: "user",
+ content: item.transcription,
+ });
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat([userMessage]);
+ });
+ // save input audio_url, and update session
+ const { audioStartMillis, audioEndMillis } = item;
+ // upload audio get audio_url
+ const blob = audioHandlerRef.current?.saveRecordFile(
+ audioStartMillis,
+ audioEndMillis,
+ );
+ uploadImage(blob!).then((audio_url) => {
+ userMessage.audio_url = audio_url;
+ chatStore.updateTargetSession(session, (session) => {
+ session.messages = session.messages.concat();
+ });
+ });
+ }
+ // stop streaming play after get input audio.
+ audioHandlerRef.current?.stopStreamingPlayback();
+ };
+
+ const toggleRecording = async () => {
+ if (!isRecording && clientRef.current) {
+ try {
+ if (!audioHandlerRef.current) {
+ audioHandlerRef.current = new AudioHandler();
+ await audioHandlerRef.current.initialize();
+ }
+ await audioHandlerRef.current.startRecording(async (chunk) => {
+ await clientRef.current?.sendAudio(chunk);
+ });
+ setIsRecording(true);
+ } catch (error) {
+ console.error("Failed to start recording:", error);
+ }
+ } else if (audioHandlerRef.current) {
+ try {
+ audioHandlerRef.current.stopRecording();
+ if (!useVAD) {
+ const inputAudio = await clientRef.current?.commitAudio();
+ await handleInputAudio(inputAudio!);
+ await clientRef.current?.generateResponse();
+ }
+ setIsRecording(false);
+ } catch (error) {
+ console.error("Failed to stop recording:", error);
+ }
+ }
+ };
+
+ useEffect(() => {
+ // 防止重复初始化
+ if (initRef.current) return;
+ initRef.current = true;
+
+ const initAudioHandler = async () => {
+ const handler = new AudioHandler();
+ await handler.initialize();
+ audioHandlerRef.current = handler;
+ await handleConnect();
+ await toggleRecording();
+ };
+
+ initAudioHandler().catch((error) => {
+ setStatus(error);
+ console.error(error);
+ });
+
+ return () => {
+ if (isRecording) {
+ toggleRecording();
+ }
+ audioHandlerRef.current?.close().catch(console.error);
+ disconnect();
+ };
+ }, []);
+
+ useEffect(() => {
+ let animationFrameId: number;
+
+ if (isConnected && isRecording) {
+ const animationFrame = () => {
+ if (audioHandlerRef.current) {
+ const freqData = audioHandlerRef.current.getByteFrequencyData();
+ setFrequencies(freqData);
+ }
+ animationFrameId = requestAnimationFrame(animationFrame);
+ };
+
+ animationFrameId = requestAnimationFrame(animationFrame);
+ } else {
+ setFrequencies(undefined);
+ }
+
+ return () => {
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ }
+ };
+ }, [isConnected, isRecording]);
+
+ // update session params
+ useEffect(() => {
+ clientRef.current?.configure({ voice });
+ }, [voice]);
+ useEffect(() => {
+ clientRef.current?.configure({ temperature });
+ }, [temperature]);
+
+ const handleClose = async () => {
+ onClose?.();
+ if (isRecording) {
+ await toggleRecording();
+ }
+ disconnect().catch(console.error);
+ };
+
+ return (
+
+
+
+
+
+
+
+ : }
+ onClick={toggleRecording}
+ disabled={!isConnected}
+ shadow
+ bordered
+ />
+
+
{status}
+
+ }
+ onClick={handleClose}
+ shadow
+ bordered
+ />
+
+
+
+ );
+}
diff --git a/app/components/realtime-chat/realtime-config.tsx b/app/components/realtime-chat/realtime-config.tsx
new file mode 100644
index 00000000000..08809afda2f
--- /dev/null
+++ b/app/components/realtime-chat/realtime-config.tsx
@@ -0,0 +1,173 @@
+import { RealtimeConfig } from "@/app/store";
+
+import Locale from "@/app/locales";
+import { ListItem, Select, PasswordInput } from "@/app/components/ui-lib";
+
+import { InputRange } from "@/app/components/input-range";
+import { Voice } from "rt-client";
+import { ServiceProvider } from "@/app/constant";
+
+const providers = [ServiceProvider.OpenAI, ServiceProvider.Azure];
+
+const models = ["gpt-4o-realtime-preview-2024-10-01"];
+
+const voice = ["alloy", "shimmer", "echo"];
+
+export function RealtimeConfigList(props: {
+ realtimeConfig: RealtimeConfig;
+ updateConfig: (updater: (config: RealtimeConfig) => void) => void;
+}) {
+ const azureConfigComponent = props.realtimeConfig.provider ===
+ ServiceProvider.Azure && (
+ <>
+
+ {
+ props.updateConfig(
+ (config) => (config.azure.endpoint = e.currentTarget.value),
+ );
+ }}
+ />
+
+
+ {
+ props.updateConfig(
+ (config) => (config.azure.deployment = e.currentTarget.value),
+ );
+ }}
+ />
+
+ >
+ );
+
+ return (
+ <>
+
+
+ props.updateConfig(
+ (config) => (config.enable = e.currentTarget.checked),
+ )
+ }
+ >
+
+
+ {props.realtimeConfig.enable && (
+ <>
+
+
+
+
+
+
+
+ {
+ props.updateConfig(
+ (config) => (config.apiKey = e.currentTarget.value),
+ );
+ }}
+ />
+
+ {azureConfigComponent}
+
+
+
+
+ {
+ props.updateConfig(
+ (config) =>
+ (config.temperature = e.currentTarget.valueAsNumber),
+ );
+ }}
+ >
+
+ >
+ )}
+ >
+ );
+}
diff --git a/app/components/sd/sd-panel.tsx b/app/components/sd/sd-panel.tsx
index a71e560ddef..15aff0ab608 100644
--- a/app/components/sd/sd-panel.tsx
+++ b/app/components/sd/sd-panel.tsx
@@ -4,6 +4,7 @@ import { Select } from "@/app/components/ui-lib";
import { IconButton } from "@/app/components/button";
import Locale from "@/app/locales";
import { useSdStore } from "@/app/store/sd";
+import clsx from "clsx";
export const params = [
{
@@ -136,7 +137,7 @@ export function ControlParamItem(props: {
className?: string;
}) {
return (
-
+
diff --git a/app/components/sd/sd.tsx b/app/components/sd/sd.tsx
index 0ace62a83cb..1ccc0647e4c 100644
--- a/app/components/sd/sd.tsx
+++ b/app/components/sd/sd.tsx
@@ -36,6 +36,7 @@ import { removeImage } from "@/app/utils/chat";
import { SideBar } from "./sd-sidebar";
import { WindowContent } from "@/app/components/home";
import { params } from "./sd-panel";
+import clsx from "clsx";
function getSdTaskStatus(item: any) {
let s: string;
@@ -104,7 +105,7 @@ export function Sd() {
return (
<>
-
+
@@ -121,7 +122,10 @@ export function Sd() {
)}
Stability AI
diff --git a/app/components/settings.module.scss b/app/components/settings.module.scss
index c6aec4203cf..60664f93aec 100644
--- a/app/components/settings.module.scss
+++ b/app/components/settings.module.scss
@@ -72,3 +72,9 @@
}
}
}
+
+.subtitle-button {
+ button {
+ overflow:visible ;
+ }
+}
diff --git a/app/components/settings.tsx b/app/components/settings.tsx
index ca0a5a18796..a74ff17b1f5 100644
--- a/app/components/settings.tsx
+++ b/app/components/settings.tsx
@@ -9,6 +9,7 @@ import CopyIcon from "../icons/copy.svg";
import ClearIcon from "../icons/clear.svg";
import LoadingIcon from "../icons/three-dots.svg";
import EditIcon from "../icons/edit.svg";
+import FireIcon from "../icons/fire.svg";
import EyeIcon from "../icons/eye.svg";
import DownloadIcon from "../icons/download.svg";
import UploadIcon from "../icons/upload.svg";
@@ -18,7 +19,7 @@ import ConfirmIcon from "../icons/confirm.svg";
import ConnectionIcon from "../icons/connection.svg";
import CloudSuccessIcon from "../icons/cloud-success.svg";
import CloudFailIcon from "../icons/cloud-fail.svg";
-
+import { trackSettingsPageGuideToCPaymentClick } from "../utils/auth-settings-events";
import {
Input,
List,
@@ -48,7 +49,7 @@ import Locale, {
changeLang,
getLang,
} from "../locales";
-import { copyToClipboard } from "../utils";
+import { copyToClipboard, clientUpdate, semverCompare } from "../utils";
import Link from "next/link";
import {
Anthropic,
@@ -58,6 +59,7 @@ import {
ByteDance,
Alibaba,
Moonshot,
+ XAI,
Google,
GoogleSafetySettingsThreshold,
OPENAI_BASE_URL,
@@ -69,6 +71,8 @@ import {
UPDATE_URL,
Stability,
Iflytek,
+ SAAS_CHAT_URL,
+ ChatGLM,
} from "../constant";
import { Prompt, SearchService, usePromptStore } from "../store/prompt";
import { ErrorBoundary } from "./error";
@@ -80,6 +84,8 @@ import { useSyncStore } from "../store/sync";
import { nanoid } from "nanoid";
import { useMaskStore } from "../store/mask";
import { ProviderType } from "../utils/cloud";
+import { TTSConfigList } from "./tts-config";
+import { RealtimeConfigList } from "./realtime-chat/realtime-config";
function EditPromptModal(props: { id: string; onClose: () => void }) {
const promptStore = usePromptStore();
@@ -582,7 +588,7 @@ export function Settings() {
const [checkingUpdate, setCheckingUpdate] = useState(false);
const currentVersion = updateStore.formatVersion(updateStore.version);
const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
- const hasNewVersion = currentVersion !== remoteId;
+ const hasNewVersion = semverCompare(currentVersion, remoteId) === -1;
const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
function checkUpdate(force = false) {
@@ -685,6 +691,31 @@ export function Settings() {
);
+ const saasStartComponent = (
+
+ }
+ type={"primary"}
+ text={Locale.Settings.Access.SaasStart.ChatNow}
+ onClick={() => {
+ trackSettingsPageGuideToCPaymentClick();
+ window.location.href = SAAS_CHAT_URL;
+ }}
+ />
+
+ );
+
const useCustomConfigComponent = // Conditionally render the following ListItem based on clientConfig.isApp
!clientConfig?.isApp && ( // only show if isApp is false
);
+ const XAIConfigComponent = accessStore.provider === ServiceProvider.XAI && (
+ <>
+
+
+ accessStore.update(
+ (access) => (access.xaiUrl = e.currentTarget.value),
+ )
+ }
+ >
+
+
+ {
+ accessStore.update(
+ (access) => (access.xaiApiKey = e.currentTarget.value),
+ );
+ }}
+ />
+
+ >
+ );
+
+ const chatglmConfigComponent = accessStore.provider ===
+ ServiceProvider.ChatGLM && (
+ <>
+
+
+ accessStore.update(
+ (access) => (access.chatglmUrl = e.currentTarget.value),
+ )
+ }
+ >
+
+
+ {
+ accessStore.update(
+ (access) => (access.chatglmApiKey = e.currentTarget.value),
+ );
+ }}
+ />
+
+ >
+ );
+
const stabilityConfigComponent = accessStore.provider ===
ServiceProvider.Stability && (
<>
@@ -1329,9 +1440,17 @@ export function Settings() {
{checkingUpdate ? (
) : hasNewVersion ? (
-
- {Locale.Settings.Update.GoToUpdate}
-
+ clientConfig?.isApp ? (
+
}
+ text={Locale.Settings.Update.GoToUpdate}
+ onClick={() => clientUpdate()}
+ />
+ ) : (
+
+ {Locale.Settings.Update.GoToUpdate}
+
+ )
) : (
}
@@ -1464,6 +1583,39 @@ export function Settings() {
}
>
+
+
+
+ updateConfig(
+ (config) =>
+ (config.enableArtifacts = e.currentTarget.checked),
+ )
+ }
+ >
+
+
+
+ updateConfig(
+ (config) => (config.enableCodeFold = e.currentTarget.checked),
+ )
+ }
+ >
+
@@ -1540,6 +1692,7 @@ export function Settings() {
+ {saasStartComponent}
{accessCodeComponent}
{!accessStore.hideUserApiKey && (
@@ -1582,6 +1735,8 @@ export function Settings() {
{moonshotConfigComponent}
{stabilityConfigComponent}
{lflytekConfigComponent}
+ {XAIConfigComponent}
+ {chatglmConfigComponent}
>
)}
>
@@ -1616,9 +1771,11 @@ export function Settings() {
setShowPromptModal(false)} />
)}
+
+ {
+ const realtimeConfig = { ...config.realtimeConfig };
+ updater(realtimeConfig);
+ config.update(
+ (config) => (config.realtimeConfig = realtimeConfig),
+ );
+ }}
+ />
+
+
+ {
+ const ttsConfig = { ...config.ttsConfig };
+ updater(ttsConfig);
+ config.update((config) => (config.ttsConfig = ttsConfig));
+ }}
+ />
+
diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx
index 4ec0f8c84f5..a5e33b15ea3 100644
--- a/app/components/sidebar.tsx
+++ b/app/components/sidebar.tsx
@@ -7,7 +7,6 @@ import SettingsIcon from "../icons/settings.svg";
import GithubIcon from "../icons/github.svg";
import ChatGptIcon from "../icons/chatgpt.svg";
import AddIcon from "../icons/add.svg";
-import CloseIcon from "../icons/close.svg";
import DeleteIcon from "../icons/delete.svg";
import MaskIcon from "../icons/mask.svg";
import DragIcon from "../icons/drag.svg";
@@ -31,6 +30,7 @@ import { Link, useNavigate } from "react-router-dom";
import { isIOS, useMobileScreen } from "../utils";
import dynamic from "next/dynamic";
import { showConfirm, Selector } from "./ui-lib";
+import clsx from "clsx";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null,
@@ -142,9 +142,9 @@ export function SideBarContainer(props: {
const { children, className, onDragStart, shouldNarrow } = props;
return (
-
+
{children}
@@ -228,6 +234,7 @@ export function SideBar(props: { className?: string }) {
title="NextChat"
subTitle="Build your own AI assistant."
logo={
}
+ shouldNarrow={shouldNarrow}
>
{
return {
title: item.name,
@@ -285,7 +287,7 @@ export function SideBar(props: { className?: string }) {
-
+
}
onClick={async () => {
diff --git a/app/components/tts-config.tsx b/app/components/tts-config.tsx
new file mode 100644
index 00000000000..39ae85730c2
--- /dev/null
+++ b/app/components/tts-config.tsx
@@ -0,0 +1,133 @@
+import { TTSConfig, TTSConfigValidator } from "../store";
+
+import Locale from "../locales";
+import { ListItem, Select } from "./ui-lib";
+import {
+ DEFAULT_TTS_ENGINE,
+ DEFAULT_TTS_ENGINES,
+ DEFAULT_TTS_MODELS,
+ DEFAULT_TTS_VOICES,
+} from "../constant";
+import { InputRange } from "./input-range";
+
+export function TTSConfigList(props: {
+ ttsConfig: TTSConfig;
+ updateConfig: (updater: (config: TTSConfig) => void) => void;
+}) {
+ return (
+ <>
+
+
+ props.updateConfig(
+ (config) => (config.enable = e.currentTarget.checked),
+ )
+ }
+ >
+
+ {/*
+
+ props.updateConfig(
+ (config) => (config.autoplay = e.currentTarget.checked),
+ )
+ }
+ >
+ */}
+
+
+
+ {props.ttsConfig.engine === DEFAULT_TTS_ENGINE && (
+ <>
+
+
+
+
+
+
+
+ {
+ props.updateConfig(
+ (config) =>
+ (config.speed = TTSConfigValidator.speed(
+ e.currentTarget.valueAsNumber,
+ )),
+ );
+ }}
+ >
+
+ >
+ )}
+ >
+ );
+}
diff --git a/app/components/tts.module.scss b/app/components/tts.module.scss
new file mode 100644
index 00000000000..ba9f382e40b
--- /dev/null
+++ b/app/components/tts.module.scss
@@ -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;
+ }
+ }
+ }
+ }
+}
diff --git a/app/components/ui-lib.module.scss b/app/components/ui-lib.module.scss
index 20bf62a188b..56aeac311ae 100644
--- a/app/components/ui-lib.module.scss
+++ b/app/components/ui-lib.module.scss
@@ -62,14 +62,14 @@
}
}
- &.vertical{
+ &.vertical {
flex-direction: column;
align-items: start;
- .list-header{
- .list-item-title{
+ .list-header {
+ .list-item-title {
margin-bottom: 5px;
}
- .list-item-sub-title{
+ .list-item-sub-title {
margin-bottom: 2px;
}
}
@@ -310,7 +310,7 @@
justify-content: center;
z-index: 999;
- .selector-item-disabled{
+ .selector-item-disabled {
opacity: 0.6;
}
@@ -336,3 +336,4 @@
}
}
}
+
diff --git a/app/components/ui-lib.tsx b/app/components/ui-lib.tsx
index 4af37dbba1c..a642652358f 100644
--- a/app/components/ui-lib.tsx
+++ b/app/components/ui-lib.tsx
@@ -23,6 +23,7 @@ import React, {
useRef,
} from "react";
import { IconButton } from "./button";
+import clsx from "clsx";
export function Popover(props: {
children: JSX.Element;
@@ -45,7 +46,7 @@ export function Popover(props: {
export function Card(props: { children: JSX.Element[]; className?: string }) {
return (
-
{props.children}
+
{props.children}
);
}
@@ -60,11 +61,13 @@ export function ListItem(props: {
}) {
return (
@@ -135,9 +138,9 @@ export function Modal(props: ModalProps) {
return (
{props.title}
@@ -260,7 +263,7 @@ export function Input(props: InputProps) {
return (
);
}
@@ -301,9 +304,13 @@ export function Select(
const { className, children, align, ...otherProps } = props;
return (