@@ -0,0 +1,10 @@ + + + + + + + + + + @@ -0,0 +1,10 @@ + + + + + + + + + + @@ -25,15 +25,33 @@ export async function getVoiceCloneModels(token?: string): Promise { +export type MediaVoiceOrPreset = MediaVoice | MediaPreset + +export function isMediaVoice(item: MediaVoiceOrPreset): item is MediaVoice { + return 'id' in item && item.id != null +} + +export function getMediaVoiceOrPresetKey(item: MediaVoiceOrPreset): string { + return isMediaVoice(item) ? String(item.id) : item.uid +} + +export async function getMediaVoices(token?: string): Promise { try { - const { data } = await axios.get(getApiUrl() + '/api/media/voices/?kind=voice', { + const { data } = await axios.get(getApiUrl() + '/api/media/voices/?kind=voice', { headers: { Authorization: `Bearer ${token}`, }, @@ -44,9 +62,9 @@ export async function getMediaVoices(token?: string): Promise } } -export async function getMediaPresets(token?: string): Promise { +export async function getMediaPresets(token?: string): Promise { try { - const { data } = await axios.get(getApiUrl() + '/api/media/presets/?kind=voice', { + const { data } = await axios.get(getApiUrl() + '/api/media/presets/?kind=voice', { headers: { Authorization: `Bearer ${token}`, }, @@ -57,20 +75,20 @@ export async function getMediaPresets(token?: string): Promise } } -export async function getVoicesAndPresets(token?: string): Promise<{ voices: MediaVoiceItem[]; presets: MediaVoiceItem[] }> { +export async function getVoicesAndPresets(token?: string): Promise<{ voices: MediaVoice[]; presets: MediaPreset[] }> { const [voices, presets] = await Promise.all([getMediaVoices(token), getMediaPresets(token)]) return { voices, presets } } /** POST /api/media/voices/ — загрузить голос (file обязателен: mp3, ogg, wav; title опционален) */ -export async function createMediaVoice(file: File, token?: string, title?: string): Promise> { +export async function createMediaVoice(file: File, token?: string, title?: string): Promise> { const formData = new FormData() formData.append('file', file) if (title != null && title !== '') { formData.append('title', title) } - return axios.post(getApiUrl() + '/api/media/voices/', formData, { + return axios.post(getApiUrl() + '/api/media/voices/', formData, { withCredentials: true, validateStatus: (status) => status < 500, headers: { @@ -80,8 +98,8 @@ export async function createMediaVoice(file: File, token?: string, title?: strin } /** DELETE /api/media/voices/{voice_id}/ */ -export async function deleteMediaVoice(voiceId: string, token?: string): Promise> { - return axios.delete(getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, { +export async function deleteMediaVoice(voiceId: string | number, token?: string): Promise> { + return axios.delete(getApiUrl() + `/api/media/voices/${encodeURIComponent(String(voiceId))}/`, { withCredentials: true, validateStatus: (status) => status < 500, headers: { @@ -91,9 +109,9 @@ export async function deleteMediaVoice(voiceId: string, token?: string): Promise } /** PATCH /api/media/voices/{voice_id}/ — сменить title */ -export async function patchMediaVoiceTitle(voiceId: string, title: string, token?: string): Promise> { +export async function patchMediaVoiceTitle(voiceId: string | number, title: string, token?: string): Promise> { return axios.patch( - getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, + getApiUrl() + `/api/media/voices/${encodeURIComponent(String(voiceId))}/`, { title }, { withCredentials: true, @@ -43,10 +43,13 @@ export function useCreateMediaMessage( setMessages((prev: Message[]) => { if (!prev || !prev.length) return messages + const prevUids = new Set(prev.map((m) => m.uid)) + const fresh = messages.filter((m) => !prevUids.has(m.uid)) + if (device === 'desktop') { - return [...messages, ...prev] + return [...fresh, ...prev] } - return [...prev, ...messages] + return [...prev, ...fresh] }) setIsComplete(true) @@ -9,12 +9,37 @@ import { getImagesBySlug, Message } from '#/entities/message' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { Device } from '#/shared/lib/types/entities' -export function useMediaBotPagination(deviceType: Device, type: 'image' | 'video' | 'audio' | 'voice') { +function appendUniqueByUid(prev: Message[], incoming: Message[]): Message[] { + const seen = new Set(prev.map((m) => m.uid)) + const additions: Message[] = [] + for (const m of incoming) { + if (seen.has(m.uid)) continue + seen.add(m.uid) + additions.push(m) + } + return [...prev, ...additions] +} + +function prependUniqueByUid(prev: Message[], incoming: Message[]): Message[] { + const prevUids = new Set(prev.map((m) => m.uid)) + const seenIncoming = new Set() + const additions: Message[] = [] + for (const m of incoming) { + if (seenIncoming.has(m.uid)) continue + seenIncoming.add(m.uid) + if (prevUids.has(m.uid)) continue + additions.push(m) + } + return [...additions, ...prev] +} + +export function useMediaBotPagination(deviceType: Device, type:'image' | 'video' | 'audio' | 'voice') { const refScrollMobile = useRef(null) const refScrollDesktop = useRef(null) const mobileScrollContainer = useRef(null) const offset = useRef(0) const observer = useRef(null) + const fetchLock = useRef(false) const [isHidden, setIsHidden] = useState(false) @@ -48,27 +73,36 @@ export function useMediaBotPagination(deviceType: Device, type: 'image' | 'video const fetchMessages = async (count?: number) => { if (!data) return + if (fetchLock.current) return + fetchLock.current = true setLoading(true) - const { data: answer, ...response } = await getImagesBySlug( - query.slug as string, - data.access, - type, - offset.current, - count || 10 - ) + try { + const { data: answer, ...response } = await getImagesBySlug( + query.slug as string, + data.access, + type, + offset.current, + count || 10 + ) + + if (response.status >= 400 || !Array.isArray(answer)) { + showMessage('Ошибка загрузки чата') + return + } - if (response.status >= 400 || !Array.isArray(answer)) - return showMessage('Ошибка загрузки чата') + if (deviceType === 'desktop') { + setMessages((prev) => appendUniqueByUid(prev, answer)) + } else { + const olderFirst = [...answer].reverse() + setMessages((prev) => prependUniqueByUid(prev, olderFirst)) + } - if (deviceType === 'desktop') { - setMessages((prev) => [...prev, ...answer]) - } else { - setMessages((prev) => [...answer.reverse(), ...prev]) + offset.current += answer.length + } finally { + fetchLock.current = false } - - offset.current += answer.length } const callback = async function (entries: IntersectionObserverEntry[]) { @@ -6,6 +6,8 @@ interface Props { children: React.ReactNode className?: string maxWidth?: string + /** By default tooltip stops click propagation (useful inside buttons/menus). Set false when parent needs the click. */ + stopClickPropagation?: boolean placement?: | 'bottom-end' | 'bottom-start' @@ -20,14 +22,22 @@ interface Props { | 'top-start' | 'top' } -export const TooltipCustom: React.FC = ({ children, title, placement, className, maxWidth }) => { +/** Выше оверлеев модалок (см. template.module.scss — 9999) */ +const TOOLTIP_POPPER_Z_INDEX = 10050 + +export const TooltipCustom: React.FC = ({ children, title, placement, className, maxWidth, stopClickPropagation = true }) => { return ( e.stopPropagation()} + onClick={stopClickPropagation ? (e: React.MouseEvent) => e.stopPropagation() : undefined} PopperProps={{ - onClick(e) { - e.stopPropagation() - }, + sx: { zIndex: TOOLTIP_POPPER_Z_INDEX }, + ...(stopClickPropagation + ? { + onClick(e: React.MouseEvent) { + e.stopPropagation() + }, + } + : {}), }} componentsProps={{ tooltip: { @@ -195,12 +195,12 @@ const AudioModelPage: NextPageWithLayout = () => { - {botParams && ( + {botParams && scope === 'playground' && ( { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -218,7 +218,7 @@ const AudioModelPage: NextPageWithLayout = () => { predictedPrice={predictedPrice} /> )} - + {isProgressVisible && ( )} @@ -279,7 +279,7 @@ const AudioModelPage: NextPageWithLayout = () => { { - {botParams && ( + {botParams && scope === 'playground' && ( setPrompt(value)} desktop={desktop} @@ -19,7 +19,7 @@ margin-right: 60px; - @media screen and (min-width: 1820px) { + @media screen and (min-width: 1200px) { margin-right: 240px; grid-template-columns: repeat(auto-fit, 360px); } @@ -38,7 +38,7 @@ export const AudioModelsPage: NextPageWithLayout = () => { alignItems: 'center', }} > - Аудио + Музыка {models ? ( @@ -144,7 +144,7 @@ const Page: NextPageWithLayout = () => { } let data = { ...(includeParams || {}) } - if (version !== '') data = { ...data, ...{'version': version} } + if (version !== '') data = { ...data, ...{ version: version } } sendMessage({ content: input, @@ -163,50 +163,50 @@ const Page: NextPageWithLayout = () => { if (!botParams?.parameters) { return {} } - + // Получаем параметры, актуальные для текущей версии - const validParams = botParams.parameters.filter(param => { + const validParams = botParams.parameters.filter((param) => { // Если у параметра нет версий - актуален для всех if (param.versions.length === 0) return true - + // Если нет выбранной версии - показываем все параметры if (!version) return true - + // Проверяем, актуален ли параметр для текущей версии return param.versions.includes(version) }) - + // Создаем объект: если есть значение в Redux - берем его, иначе - дефолтное - return validParams.reduce((acc, param) => ({ - ...acc, - // @ts-ignore - [param.key]: includeParams?.[param.key] ?? param.values.default - }), {}) + return validParams.reduce( + (acc, param) => ({ + ...acc, + // @ts-ignore + [param.key]: includeParams?.[param.key] ?? param.values.default, + }), + {} + ) }, [botParams?.parameters, version, includeParams]) - + const showFileExample = useMemo(() => { if (!botParams?.inputs) return false - + // Проверяем, есть ли типы кроме 'text', которые доступны для текущей версии - return botParams.inputs.some(input => { + return botParams.inputs.some((input) => { // Пропускаем текстовый тип if (input.type === 'text') return false - + // Если у input нет версий - актуален для всех if (input.versions.length === 0) return true - + // Если нет выбранной версии - показываем все inputs if (!version) return true - + // Проверяем, актуален ли input для текущей версии return input.versions.includes(version) }) }, [botParams?.inputs, version]) - const predictPriceInfo = useMemo( - () => ({ ...(includeParams || {}), ...(version ? { version } : {}) }), - [includeParams, version] - ) + const predictPriceInfo = useMemo(() => ({ ...(includeParams || {}), ...(version ? { version } : {}) }), [includeParams, version]) const predictedPrice = usePredictPrice({ modelSlug: modelType, @@ -224,11 +224,7 @@ const Page: NextPageWithLayout = () => {
- + <Title title={botParams?.title ? botParams?.title : ''} type={'Чат-боты'} linkBack={'/chat-bot'} /> <div className={styles.tags}> {botParams && botParams.tags.map((tag, index) => ( @@ -238,24 +234,31 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { </div> ))} </div> - {desktop && <ChatsContainer desktop={desktop} modelType={modelType} />} + {desktop && <ChatsContainer desktop={desktop} modelType={modelType} />} - - {desktop && ( - <Stack sx={{ display: 'flex', alignItems: 'center', width: '25%', minWidth: '25%', marginRight: 'calc(5% - 22px)', marginTop: '0px' }}> + {desktop && ( + <Stack + sx={{ + display: 'flex', + alignItems: 'center', + width: '25%', + minWidth: '25%', + marginRight: 'calc(5% - 22px)', + marginTop: '0px', + }} + > <StaticTabs scope={scope} setScope={setScope} /> </Stack> )} </div> {!desktop && ( - <Stack sx={{ display: 'flex', alignItems: 'center', width: '100%', marginBottom: '15px', marginTop: '10px' }}> - <StaticTabs scope={scope} setScope={setScope} /> - </Stack> - )} - + <Stack sx={{ display: 'flex', alignItems: 'center', width: '100%', marginBottom: '15px', marginTop: '10px' }}> + <StaticTabs scope={scope} setScope={setScope} /> + </Stack> + )} + {!desktop && <ChatsContainer desktop={desktop} modelType={modelType} />} <Box sx={{ height: 'calc(100% - 100px)' }} className={styles.main}> - <Box className={styles.chatWindow}> <Box sx={{ display: scope === 'playground' ? 'block' : 'none' }}> <AllChatWindow @@ -300,12 +303,17 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { overflowY: 'auto', }} > - <ModelApiView version={version || ''} slug={botParams?.slug || ''} APIModel='text' modelParams={filteredParams} showFileExample={showFileExample}/> + <ModelApiView + version={version || ''} + slug={botParams?.slug || ''} + APIModel='text' + modelParams={filteredParams} + showFileExample={showFileExample} + /> </Stack> </Box> </Box> <Box className={styles.settings}> - {desktop && ( <Stack id='text-models-tour-3' className='pd-30 bg-color-block border-radius-main' spacing={2}> {botParams?.versions && botParams.versions?.length !== 0 && ( @@ -13,7 +13,7 @@ margin-right: 60px; - @media screen and (min-width: 1820px) { + @media screen and (min-width: 1200px) { margin-right: 240px; grid-template-columns: repeat(auto-fit, 360px); } @@ -121,7 +121,6 @@ justify-content: flex-start; align-items: stretch; gap: 20px; - // margin-bottom: 24px; .chatWindow { width: 70%; @@ -185,12 +185,12 @@ const ImageModelPage: NextPageWithLayout = () => { id='images-models-tour-2' alignItems='center' sx={{ - marginBottom: '15px', + marginBottom: scope === 'playground' ? '15px' : 0, position: 'relative', zIndex: 1, }} > - {botParams && ( + {botParams && scope === 'playground' && ( <ModelInput currentVersion={version} styles={'images'} @@ -199,7 +199,7 @@ const ImageModelPage: NextPageWithLayout = () => { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -267,7 +267,7 @@ const ImageModelPage: NextPageWithLayout = () => { <Box sx={{ padding: '30px', - height: `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 23.5px'})`, + height: `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 93px'})`, // height: `calc(100dvh - 116px - 61px - 15px - 23.5px)`, overflowY: 'scroll', overflowX: 'hidden', @@ -296,7 +296,7 @@ const ImageModelPage: NextPageWithLayout = () => { </Box> <Stack id='images-models-tour-2' alignItems='center' sx={{ zIndex: 10, position: 'relative', margin: 1.25 }}> - {botParams && ( + {botParams && scope === 'playground' && ( <ModelInput currentVersion={version} styles={'images'} @@ -304,7 +304,7 @@ const ImageModelPage: NextPageWithLayout = () => { image={image} value={prompt} onValueChange={(value: string) => setPrompt(value)} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} desktop={desktop} loading={createLoading} imageLoad={onLoadImage} @@ -14,7 +14,7 @@ margin-right: 60px; - @media screen and (min-width: 1820px) { + @media screen and (min-width: 1200px) { margin-right: 240px; grid-template-columns: repeat(auto-fit, 360px); } @@ -2,9 +2,11 @@ overflow: hidden; padding: 0; background-color: #151518; + height: 100vh; @media screen and (max-width: 1000px) { margin: 0 auto; + overflow: hidden; } } @@ -23,17 +25,18 @@ @media screen and (max-width: 1000px) { width: 100%; - align-items: flex-start; - margin-top: 10px; + height: 100vh; + align-items: center; } } .formInner { width: 40vh; - height: 100%; + height: auto; @media screen and (max-width: 1000px) { - margin-top: 40%; + margin-top: 0; + width: 85vw; } } @@ -58,7 +61,6 @@ align-items: center; } - .orEmailWrapper { display: flex; justify-content: center; @@ -73,7 +75,6 @@ background-color: #44444A; } - .inputField { :global(.MuiInputBase-input) { padding: 12px 12px 14px 16px; @@ -25,12 +25,12 @@ export const categoryIconMap: Record<string, React.ReactNode> = { alt='Видео' /> ), - 'Аудио': ( + 'Музыка': ( <Image src='/subscription/svg-icons/audio-icon.svg' width={24} height={21} - alt='Аудио' + alt='Музыка' /> ), } @@ -110,7 +110,7 @@ export const features = [ ] }, { - 'name': 'Аудио', + 'name': 'Музыка', 'features': [ { 'name': 'Suno', @@ -193,12 +193,12 @@ const VideoModelPage: NextPageWithLayout = () => { <Stack alignItems='center' sx={{ - marginBottom: '15px', + marginBottom: scope === 'playground' ? '15px' : 0, position: 'relative', zIndex: 1, }} > - {botParams && ( + {botParams && scope === 'playground' && ( <ModelInput currentVersion={version} styles={'images'} @@ -207,7 +207,7 @@ const VideoModelPage: NextPageWithLayout = () => { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -216,7 +216,7 @@ const VideoModelPage: NextPageWithLayout = () => { predictedPrice={predictedPrice} /> )} - <Box sx={{ width: '100%', marginTop: '10px' }}> + <Box sx={{ width: '100%', marginTop: isProgressVisible ? '10px' : 0 }}> {isProgressVisible && ( <ProgressLoader progress={progress} height={15} title='Создание видео...' showPercentage={true} /> )} @@ -277,7 +277,7 @@ const VideoModelPage: NextPageWithLayout = () => { <Box sx={{ padding: '30px', - height: `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 23.5px'})`, + height: `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 106px'})`, // height: `calc(100dvh - 116px - 61px - 15px - 23.5px)`, overflowY: 'scroll', overflowX: 'hidden', @@ -302,13 +302,13 @@ const VideoModelPage: NextPageWithLayout = () => { </Box> <Stack alignItems='center' sx={{ zIndex: 10, position: 'relative', margin: 1.25 }}> - {botParams && ( + {botParams && scope === 'playground' && ( <ModelInput currentVersion={version} styles={'images'} input_types={botParams.inputs} image={image} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} @@ -19,7 +19,7 @@ margin-right: 60px; - @media screen and (min-width: 1820px) { + @media screen and (min-width: 1200px) { margin-right: 240px; grid-template-columns: repeat(auto-fit, 360px); } @@ -3,14 +3,46 @@ } .modal { + position: relative; padding: 56px 24px 24px 24px; - min-width: 450px; min-height: 300px; - width: 648px; display: flex; flex-direction: column; justify-content: center; align-items: center; + + @media (min-width: 1200px) { + width: 640px; + } +} + +.modalBack { + position: absolute; + display: flex; + top: 18px; + left: -16px; + z-index: 2; + margin: 0; + padding: 6px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: #a4aab5; + font-size: 15px; + font-family: inherit; + font-weight: 500; + cursor: pointer; + line-height: 1.2; + + &:hover { + color: #e0e0e0; + background-color: rgba(255, 255, 255, 0.06); + } + + &:focus-visible { + outline: 2px solid #8280ff; + outline-offset: 2px; + } } .upload { @@ -72,8 +104,30 @@ white-space: nowrap; } +.trainingBlock { + width: 100%; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + padding: 32px 24px; + box-sizing: border-box; +} + +.trainingText { + margin: 0; + text-align: center; + color: #fff; + font-size: 16px; + font-weight: 500; + line-height: 1.45; +} + .previewBlock { width: 100%; + padding-top: 16px; display: flex; flex-direction: column; gap: 14px; @@ -105,7 +159,25 @@ justify-content: center; border-radius: 8px; height: 38px; + width: 100%; border: 1px solid #8280FF; + color: #8280FF; +} + +.tryAgainInner { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.tryAgainIcon { + flex-shrink: 0; + display: block; +} + +.trainBtn { + color: #fff; } .uploadFromMicBtn { @@ -118,13 +190,56 @@ border: 1px solid #8280ff; } +.btnMicInner { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btnMicIcon { + font-size: 20px; + width: 1em; + height: 1em; + flex-shrink: 0; +} + +.btnStopSquare { + width: 16px; + height: 16px; + flex-shrink: 0; + border-radius: 2px; + background-color: #8280ff; +} + +.micStandalone { + width: 100%; + max-width: 440px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 16px; +} + +.allowMicBtn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 400px; + margin: 0 auto; + border-radius: 8px; + height: 38px; + border: 1px solid #8280ff; +} + .micPanel { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 440px; - margin-top: 4px; + margin-top: 0; } .micRow { @@ -155,6 +270,7 @@ justify-content: center; border-radius: 8px; height: 38px; + color: #8280FF; border: 1px solid #8280ff; } @@ -1,3 +1,4 @@ +import Mic from '@mui/icons-material/Mic' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSession } from 'next-auth/react' @@ -6,6 +7,7 @@ import { getModalById, PLATE_ADD_VOICE, PlateTemplate } from '#/features/modals' import { c, getDeviceType, getOs } from '#/shared/lib/helpers' import { useDeviceType } from '#/shared/lib/hooks' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { Loader } from '#/shared' import { CommonButton } from '#/shared/ui/button' import { CommonInput } from '#/shared/ui/common-input' import { AudioPlayer } from '#/widgets/messages/ui/audio-player' @@ -26,6 +28,37 @@ type AddVoicePlateProps = { onSuccess: () => Promise<void> | void } +function MicButtonLabel({ children, stopRecording }: { children: React.ReactNode; stopRecording?: boolean }) { + return ( + <span className={styles.btnMicInner}> + {stopRecording ? <span className={styles.btnStopSquare} aria-hidden /> : <Mic className={styles.btnMicIcon} aria-hidden />} + {children} + </span> + ) +} + +function TryAgainOutlineIcon() { + return ( + <svg + className={styles.tryAgainIcon} + width='15' + height='15' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + aria-hidden + > + <path + d='M2.5 2.50008V5.62508H2.86375M2.86375 5.62508C3.27866 4.59891 4.0223 3.73939 4.97814 3.18124C5.93399 2.62309 7.048 2.39784 8.14561 2.54079C9.24321 2.68374 10.2624 3.18682 11.0434 3.97119C11.8243 4.75555 12.323 5.77687 12.4612 6.87508M2.86375 5.62508H5.625M12.5 12.5001V9.37508H12.1369M12.1369 9.37508C11.7214 10.4006 10.9775 11.2595 10.0218 11.8171C9.06602 12.3748 7.95234 12.5998 6.85506 12.4569C5.75779 12.314 4.73887 11.8112 3.95783 11.0274C3.17679 10.2436 2.67772 9.22286 2.53875 8.12508M12.1369 9.37508H9.375' + stroke='currentColor' + strokeWidth='1.60714' + strokeLinecap='round' + strokeLinejoin='round' + /> + </svg> + ) +} + function validateAudioFile(file: File): string | null { if (file.size > MAX_BYTES) { return 'Размер файла не больше 10 МБ' @@ -62,15 +95,18 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { Preview: 'preview', } as const - /** Подэкран микрофона внутри PickSource (панель открыта по кнопке «Записать…») */ - const MicPanelPhase = { - Closed: 'closed', - LoadingList: 'loading_list', - NoMicrophones: 'no_microphones', + /** Поток записи с микрофона: сначала только запрос разрешения, затем выбор + «Начать» */ + const MicFlow = { + None: 'none', + AwaitingPermission: 'awaiting_permission', + LoadingDevices: 'loading_devices', Ready: 'ready', Recording: 'recording', + NoMicrophones: 'no_microphones', } as const + type MicFlowState = (typeof MicFlow)[keyof typeof MicFlow] + const modal = getModalById(PLATE_ADD_VOICE) const { data: session } = useSession() const { showMessage } = useShowDataStore() @@ -88,25 +124,16 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { const [pickedFile, setPickedFile] = useState<File | null>(null) const [voiceTitle, setVoiceTitle] = useState('') const [training, setTraining] = useState(false) - const [micPanelOpen, setMicPanelOpen] = useState(false) + const [micFlow, setMicFlow] = useState<MicFlowState>(MicFlow.None) const [micDevices, setMicDevices] = useState<MediaDeviceInfo[]>([]) - const [micDevicesLoading, setMicDevicesLoading] = useState(false) const [selectedMicId, setSelectedMicId] = useState('') const [isRecording, setIsRecording] = useState(false) const modalView = pickedFile ? ModalView.Preview : ModalView.PickSource - const micPanelPhase = useMemo(() => { - if (!micPanelOpen) return MicPanelPhase.Closed - if (micDevicesLoading) return MicPanelPhase.LoadingList - if (micDevices.length === 0) return MicPanelPhase.NoMicrophones - if (isRecording) return MicPanelPhase.Recording - return MicPanelPhase.Ready - }, [micPanelOpen, micDevicesLoading, micDevices.length, isRecording]) - - const showMicInvite = micPanelPhase === MicPanelPhase.Closed - const showMicPanel = micPanelPhase !== MicPanelPhase.Closed - const showMicDeviceRow = micPanelPhase === MicPanelPhase.Ready || micPanelPhase === MicPanelPhase.Recording + const inMicFlow = micFlow !== MicFlow.None + const showMicDeviceRow = micFlow === MicFlow.Ready || micFlow === MicFlow.Recording + const showModalBack = (inMicFlow || Boolean(pickedFile)) && !training const previewUrl = useMemo(() => (pickedFile ? URL.createObjectURL(pickedFile) : null), [pickedFile]) @@ -147,19 +174,21 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { const resetFile = useCallback(() => { discardRecording() + dragDepthRef.current = 0 + setIsDragging(false) setPickedFile(null) setVoiceTitle('') - setMicPanelOpen(false) + setMicFlow(MicFlow.None) setMicDevices([]) setSelectedMicId('') }, [discardRecording]) - const refreshMicDevices = useCallback(async () => { + const requestMicAccessAndLoadDevices = useCallback(async () => { if (!navigator.mediaDevices?.enumerateDevices) { showMessage('Браузер не поддерживает выбор микрофона') return } - setMicDevicesLoading(true) + setMicFlow(MicFlow.LoadingDevices) try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) stream.getTracks().forEach((t) => t.stop()) @@ -172,13 +201,15 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { }) if (!inputs.length) { showMessage('Не найдено ни одного микрофона') + setMicFlow(MicFlow.NoMicrophones) + } else { + setMicFlow(MicFlow.Ready) } } catch { showMessage('Нет доступа к микрофону') setMicDevices([]) setSelectedMicId('') - } finally { - setMicDevicesLoading(false) + setMicFlow(MicFlow.AwaitingPermission) } }, [showMessage]) @@ -186,10 +217,12 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { (e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() - setMicPanelOpen(true) - void refreshMicDevices() + discardRecording() + setMicDevices([]) + setSelectedMicId('') + setMicFlow(MicFlow.AwaitingPermission) }, - [refreshMicDevices] + [discardRecording] ) const onMicStartStop = useCallback( @@ -222,18 +255,20 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { mediaRecorderRef.current = null stopMediaStream() setIsRecording(false) + setMicFlow(MicFlow.None) + setMicDevices([]) /* бэкенд ожидает аудио WebM под расширением .weba */ const ext = mime.includes('ogg') ? 'ogg' : 'weba' - const file = new File([blob], `voice-record.${ext}`, { type: mime }) + const file = new File([blob], `Мой голос.${ext}`, { type: mime }) applyFile(file) - setMicPanelOpen(false) - setMicDevices([]) } rec.start() setIsRecording(true) + setMicFlow(MicFlow.Recording) } catch { showMessage('Не удалось начать запись') discardRecording() + setMicFlow(MicFlow.Ready) } }, [applyFile, discardRecording, isRecording, selectedMicId, showMessage, stopMediaStream] @@ -332,79 +367,61 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { closeModal={() => {}} > <div className={styles.modal}> - {modalView === ModalView.PickSource ? ( - <div - className={c(styles.upload, isDragging && styles.upload_dragging)} - onClick={openFileDialog} - onDragEnter={onDragEnter} - onDragLeave={onDragLeave} - onDragOver={onDragOver} - onDrop={onDrop} - role='button' - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - openFileDialog() - } + {showModalBack ? ( + <button + type='button' + className={styles.modalBack} + onClick={(e) => { + e.stopPropagation() + resetFile() }} > - <input - ref={fileInputRef} - type='file' - className={styles.visuallyHidden} - accept={FILE_ACCEPT} - onChange={onFileInputChange} - /> - <div className={styles.uploadIcon} aria-hidden> - <svg width='55' height='55' viewBox='0 0 55 55' fill='none' xmlns='http://www.w3.org/2000/svg'> - <path - d='M20.6276 29.7917H34.3776M27.5026 22.9167V36.6667M38.9609 48.125H16.0443C14.8287 48.125 13.6629 47.6421 12.8034 46.7826C11.9438 45.923 11.4609 44.7572 11.4609 43.5417V11.4583C11.4609 10.2428 11.9438 9.07697 12.8034 8.21743C13.6629 7.35789 14.8287 6.875 16.0443 6.875H28.8455C29.4533 6.87513 30.0361 7.11666 30.4657 7.54646L42.8728 19.9535C43.3026 20.3832 43.5441 20.966 43.5443 21.5737V43.5417C43.5443 44.7572 43.0614 45.923 42.2018 46.7826C41.3423 47.6421 40.1765 48.125 38.9609 48.125Z' - stroke='#A4AAB5' - strokeWidth='4' - strokeLinecap='round' - strokeLinejoin='round' - /> - </svg> - </div> - <h2 className={styles.title}> - Загрузите примеры голоса <br /> для клонирования - </h2> - <p className={styles.hint}> - аудиофайлы в формате <span className={styles.hintAccent}>.mp3</span> или{' '} - <span className={styles.hintAccent}>.wav</span>, не более{' '} - <span className={styles.hintAccent}>10MB</span> каждый - </p> - {showMicInvite ? ( - <> - <p className={styles.or} onClick={(e) => e.stopPropagation()}> - или - </p> + {'< Назад'} + </button> + ) : null} + {modalView === ModalView.PickSource ? ( + inMicFlow ? ( + <div className={styles.micStandalone}> + {micFlow === MicFlow.AwaitingPermission ? ( <CommonButton type='button' variant='primary' - className={styles.uploadFromMicBtn} - onClick={onUploadFromMic} + className={styles.allowMicBtn} + onClick={(e) => { + e.stopPropagation() + void requestMicAccessAndLoadDevices() + }} > - Записать аудио с микрофона + <MicButtonLabel>Разрешить доступ к микрофону</MicButtonLabel> </CommonButton> - </> - ) : null} - {showMicPanel ? ( - <div className={styles.micPanel} onClick={(e) => e.stopPropagation()}> - {micPanelPhase === MicPanelPhase.LoadingList ? ( - <p className={styles.micHint}>Загрузка списка микрофонов…</p> - ) : null} - {micPanelPhase === MicPanelPhase.NoMicrophones ? ( + ) : null} + {micFlow === MicFlow.LoadingDevices ? ( + <p className={styles.micHint}>Запрос доступа к микрофону…</p> + ) : null} + {micFlow === MicFlow.NoMicrophones ? ( + <> <p className={styles.micHint}>Нет доступных микрофонов. Проверьте разрешения браузера.</p> - ) : null} - {showMicDeviceRow ? ( + <CommonButton + type='button' + variant='outline' + className={styles.allowMicBtn} + onClick={(e) => { + e.stopPropagation() + void requestMicAccessAndLoadDevices() + }} + > + <MicButtonLabel>Повторить</MicButtonLabel> + </CommonButton> + </> + ) : null} + {showMicDeviceRow ? ( + <div className={styles.micPanel}> <div className={styles.micRow}> <select className={styles.micSelect} value={selectedMicId} onChange={(ev) => setSelectedMicId(ev.target.value)} - disabled={micPanelPhase === MicPanelPhase.Recording} + disabled={micFlow === MicFlow.Recording} aria-label='Микрофон' > {micDevices.map((d, i) => ( @@ -415,16 +432,82 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { </select> <CommonButton type='button' - variant='primary' + variant='outline' className={styles.micStartBtn} onClick={(ev) => void onMicStartStop(ev)} > - {micPanelPhase === MicPanelPhase.Recording ? 'Остановить' : 'Начать'} + <MicButtonLabel stopRecording={micFlow === MicFlow.Recording}> + {micFlow === MicFlow.Recording ? 'Стоп' : 'Начать'} + </MicButtonLabel> </CommonButton> </div> - ) : null} + </div> + ) : null} + </div> + ) : ( + <div + className={c(styles.upload, isDragging && styles.upload_dragging)} + onClick={openFileDialog} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDragOver={onDragOver} + onDrop={onDrop} + role='button' + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + openFileDialog() + } + }} + > + <input + ref={fileInputRef} + type='file' + className={styles.visuallyHidden} + accept={FILE_ACCEPT} + onChange={onFileInputChange} + /> + <div className={styles.uploadIcon} aria-hidden> + <svg width='55' height='55' viewBox='0 0 55 55' fill='none' xmlns='http://www.w3.org/2000/svg'> + <path + d='M20.6276 29.7917H34.3776M27.5026 22.9167V36.6667M38.9609 48.125H16.0443C14.8287 48.125 13.6629 47.6421 12.8034 46.7826C11.9438 45.923 11.4609 44.7572 11.4609 43.5417V11.4583C11.4609 10.2428 11.9438 9.07697 12.8034 8.21743C13.6629 7.35789 14.8287 6.875 16.0443 6.875H28.8455C29.4533 6.87513 30.0361 7.11666 30.4657 7.54646L42.8728 19.9535C43.3026 20.3832 43.5441 20.966 43.5443 21.5737V43.5417C43.5443 44.7572 43.0614 45.923 42.2018 46.7826C41.3423 47.6421 40.1765 48.125 38.9609 48.125Z' + stroke='#A4AAB5' + strokeWidth='4' + strokeLinecap='round' + strokeLinejoin='round' + /> + </svg> </div> - ) : null} + <h2 className={styles.title}> + Загрузите примеры голоса <br /> для клонирования + </h2> + <p className={styles.hint}> + аудиофайлы в формате <span className={styles.hintAccent}>.mp3</span> или{' '} + <span className={styles.hintAccent}>.wav</span>, не более{' '} + <span className={styles.hintAccent}>10MB</span> каждый + </p> + <p className={styles.or} onClick={(e) => e.stopPropagation()}> + или + </p> + <CommonButton + type='button' + variant='primary' + className={styles.uploadFromMicBtn} + onClick={onUploadFromMic} + > + <MicButtonLabel>Записать аудио с микрофона</MicButtonLabel> + </CommonButton> + </div> + ) + ) : training ? ( + <div className={styles.trainingBlock}> + <Loader size={44} thickness={2.5} sx={{ color: '#8280FF' }} /> + <p className={styles.trainingText}> + Обучаем модель + <br /> + на основе вашего аудио... + </p> </div> ) : ( <div className={styles.previewBlock}> @@ -460,7 +543,10 @@ export function AddVoicePlate({ onSuccess }: AddVoicePlateProps) { disabled={training} onClick={resetFile} > - Попробовать ещё + <span className={styles.tryAgainInner}> + Попробовать ещё + <TryAgainOutlineIcon /> + </span> </CommonButton> <CommonButton type='button' @@ -10,6 +10,74 @@ display: none; } +.deleteConfirmPaper { + overflow: visible !important; + max-width: 220px; + background-color: #4b4b4b !important; + border-radius: 10px !important; + padding: 0 !important; + margin-bottom: 6px; + box-shadow: + 0 0 4px rgba(0, 0, 0, 0.04), + 0 4px 32px rgba(0, 0, 0, 0.16) !important; + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #4b4b4b; + } +} + +.deleteConfirmContent { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 10px 14px; + max-width: 100%; + box-sizing: border-box; +} + +.deleteConfirmQuestion { + font-size: 15px !important; + font-weight: 500; + color: #fff !important; + line-height: 1.2; +} + +.deleteConfirmAction { + margin: 0; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid #fff; + background: transparent; + color: #fff; + font-size: 14px; + font-weight: 500; + font-family: inherit; + line-height: 1.2; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + + &:hover { + background-color: rgba(255, 255, 255, 0.08); + } + + &:focus-visible { + outline: 2px solid #8280ff; + outline-offset: 2px; + } +} + /* min-height синхронизирован с VOICE_SELECT_ROW_HEIGHT в voice-clone-select.tsx */ .menuItemRow { display: flex; @@ -5,6 +5,7 @@ import { IconButton, ListSubheader, MenuItem, + Popover, Select, SelectChangeEvent, Stack, @@ -12,9 +13,17 @@ import { } from '@mui/material' import type { MenuProps } from '@mui/material/Menu' import Image from 'next/image' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, type SVGProps } from 'react' -import { deleteMediaVoice, MediaVoiceItem, patchMediaVoiceTitle } from '#/entities/audio-model' +import { + deleteMediaVoice, + getMediaVoiceOrPresetKey, + isMediaVoice, + MediaPreset, + MediaVoice, + MediaVoiceOrPreset, + patchMediaVoiceTitle, +} from '#/entities/audio-model' import { baseColor } from '#/shared/lib/constants/colors' import { c } from '#/shared/lib/helpers' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' @@ -38,16 +47,39 @@ function VoiceSelectLabel({ full }: { full: string }) { ) if (!tooltip) return typo return ( - <TooltipCustom title={tooltip} placement='top'> + <TooltipCustom title={tooltip} placement='top' stopClickPropagation={false}> {typo} </TooltipCustom> ) } -/** Иконки из /public (как в api-keys, account) */ -const VOICE_ICON_EDIT = '/svg/account/pencil.svg' +/** Иконка сохранения — как раньше из /public */ const VOICE_ICON_SAVE = '/svg/tic.svg' -const VOICE_ICON_DELETE = '/svg/main_menu/trash.svg' + +function VoiceEditPencilIcon(props: SVGProps<SVGSVGElement>) { + return ( + <svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg' aria-hidden {...props}> + <path + d='M10.8672 2.8688C11.0148 2.71599 11.1914 2.59409 11.3866 2.51024C11.5818 2.42638 11.7917 2.38225 12.0042 2.3804C12.2166 2.37855 12.4273 2.41904 12.624 2.49949C12.8206 2.57994 12.9992 2.69874 13.1495 2.84897C13.2997 2.9992 13.4185 3.17784 13.499 3.37448C13.5794 3.57111 13.6199 3.7818 13.618 3.99424C13.6162 4.20669 13.5721 4.41664 13.4882 4.61185C13.4043 4.80706 13.2825 4.98361 13.1296 5.1312L12.4952 5.7656L10.2328 3.5032L10.8672 2.8688ZM9.10164 4.6344L2.39844 11.3376V13.6H4.66084L11.3648 6.8968L9.10164 4.6344Z' + fill='#fff' + /> + </svg> + ) +} + +function VoiceDeleteBucketIcon(props: SVGProps<SVGSVGElement>) { + return ( + <svg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg' aria-hidden {...props}> + <path + d='M12.6719 4.66667L12.0939 12.7613C12.0699 13.0977 11.9194 13.4125 11.6726 13.6424C11.4258 13.8722 11.1011 14 10.7639 14H5.24655C4.90931 14 4.58459 13.8722 4.3378 13.6424C4.09101 13.4125 3.94049 13.0977 3.91655 12.7613L3.33855 4.66667M6.67188 7.33333V11.3333M9.33854 7.33333V11.3333M10.0052 4.66667V2.66667C10.0052 2.48986 9.93497 2.32029 9.80995 2.19526C9.68493 2.07024 9.51535 2 9.33854 2H6.67188C6.49507 2 6.32549 2.07024 6.20046 2.19526C6.07544 2.32029 6.0052 2.48986 6.0052 2.66667V4.66667M2.67188 4.66667H13.3385' + stroke='#B01E1E' + strokeWidth='1.33333' + strokeLinecap='round' + strokeLinejoin='round' + /> + </svg> + ) +} /** Высота поля селекта и строк меню (px) */ const VOICE_SELECT_ROW_HEIGHT = 58 @@ -77,6 +109,7 @@ const voiceActionIconButtonSx = { height: 32, minWidth: 32, padding: 0, + fill: '#8280ff', borderRadius: '8px', color: '#8280ff', '&:hover': { @@ -155,16 +188,16 @@ const VOICE_SELECT_MENU_PROPS: Partial<MenuProps> = { } type Props = { - voices: MediaVoiceItem[] - presets: MediaVoiceItem[] + voices: MediaVoice[] + presets: MediaPreset[] value: string loading: boolean - onSelect: (item: MediaVoiceItem) => void + onSelect: (item: MediaVoiceOrPreset) => void /** Токен для PATCH/DELETE голосов; без него кнопки не показываются */ accessToken?: string onVoiceListChanged?: () => void - /** После удаления — сбросить выбранный uid, если удалён он */ - onDeletedVoice?: (uid: string) => void + /** После удаления — сбросить выбранный ключ, если удалён он */ + onDeletedVoice?: (key: string) => void } export function VoiceCloneSelect({ @@ -180,27 +213,29 @@ export function VoiceCloneSelect({ const { showMessage } = useShowDataStore() const all = [...voices, ...presets] const audioRef = useRef<HTMLAudioElement | null>(null) - const [playingUid, setPlayingUid] = useState<string | null>(null) - const [editingUid, setEditingUid] = useState<string | null>(null) + const [playingKey, setPlayingKey] = useState<string | null>(null) + const [editingKey, setEditingKey] = useState<string | null>(null) const [editDraft, setEditDraft] = useState('') - const [mutatingUid, setMutatingUid] = useState<string | null>(null) + const [mutatingKey, setMutatingKey] = useState<string | null>(null) + const [deleteConfirm, setDeleteConfirm] = useState<{ voice: MediaVoice; anchorEl: HTMLElement } | null>(null) const stopPreview = useCallback(() => { const el = audioRef.current if (el) { el.pause() } - setPlayingUid(null) + setPlayingKey(null) }, []) const togglePreview = useCallback( - (item: MediaVoiceItem) => { + (item: MediaVoiceOrPreset) => { const el = audioRef.current if (!el || !item.file?.trim()) return - if (playingUid === item.uid) { + const key = getMediaVoiceOrPresetKey(item) + if (playingKey === key) { el.pause() - setPlayingUid(null) + setPlayingKey(null) return } @@ -209,16 +244,16 @@ export function VoiceCloneSelect({ el.currentTime = 0 const p = el.play() if (p !== undefined) { - p.then(() => setPlayingUid(item.uid)).catch(() => setPlayingUid(null)) + p.then(() => setPlayingKey(key)).catch(() => setPlayingKey(null)) } }, - [playingUid] + [playingKey] ) useEffect(() => { const el = audioRef.current if (!el) return - const onEnded = () => setPlayingUid(null) + const onEnded = () => setPlayingKey(null) el.addEventListener('ended', onEnded) return () => { el.removeEventListener('ended', onEnded) @@ -233,8 +268,8 @@ export function VoiceCloneSelect({ ) const onChange = (e: SelectChangeEvent<string>) => { - const uid = e.target.value - const item = all.find((v) => v.uid === uid) ?? null + const key = e.target.value + const item = all.find((v) => getMediaVoiceOrPresetKey(v) === key) ?? null if (item) { onSelect(item) } @@ -242,73 +277,77 @@ export function VoiceCloneSelect({ const closeMenuExtras = useCallback(() => { stopPreview() - setEditingUid(null) + setEditingKey(null) setEditDraft('') + setDeleteConfirm(null) }, [stopPreview]) const commitRename = useCallback( - async (item: MediaVoiceItem) => { + async (voice: MediaVoice) => { if (!accessToken) return const trimmed = editDraft.trim() if (!trimmed) { showMessage('Введите название') return } - setMutatingUid(item.uid) + const key = getMediaVoiceOrPresetKey(voice) + setMutatingKey(key) try { - const res = await patchMediaVoiceTitle(item.uid, trimmed, accessToken) + const res = await patchMediaVoiceTitle(voice.id, trimmed, accessToken) if (res.status >= 400) { showMessage('Не удалось изменить название') return } - setEditingUid(null) + setEditingKey(null) setEditDraft('') onVoiceListChanged?.() showMessage('Название изменено', 'success') } finally { - setMutatingUid(null) + setMutatingKey(null) } }, [accessToken, editDraft, onVoiceListChanged, showMessage] ) - const removeVoice = useCallback( - async (item: MediaVoiceItem) => { + const executeDeleteVoice = useCallback( + async (voice: MediaVoice) => { if (!accessToken) return - if (!window.confirm('Удалить этот голос?')) return - setMutatingUid(item.uid) + setDeleteConfirm(null) + const key = getMediaVoiceOrPresetKey(voice) + setMutatingKey(key) try { - const res = await deleteMediaVoice(item.uid, accessToken) + const res = await deleteMediaVoice(voice.id, accessToken) if (res.status >= 400) { showMessage('Не удалось удалить голос') return } - if (editingUid === item.uid) { - setEditingUid(null) + if (editingKey === key) { + setEditingKey(null) setEditDraft('') } - onDeletedVoice?.(item.uid) + onDeletedVoice?.(key) onVoiceListChanged?.() showMessage('Голос удалён', 'success') } finally { - setMutatingUid(null) + setMutatingKey(null) } }, - [accessToken, editingUid, onDeletedVoice, onVoiceListChanged, showMessage] + [accessToken, editingKey, onDeletedVoice, onVoiceListChanged, showMessage] ) const flatDisabled = !loading && all.length === 0 - const renderVoiceMenuItem = (item: MediaVoiceItem, optionIndex: number, isUserVoice: boolean) => { + const renderVoiceMenuItem = (item: MediaVoiceOrPreset, optionIndex: number, isUserVoice: boolean) => { + const key = getMediaVoiceOrPresetKey(item) const hasPreview = Boolean(item.file?.trim()) - const isEditing = editingUid === item.uid - const busy = mutatingUid === item.uid - const showActions = isUserVoice && Boolean(accessToken) + const isEditing = editingKey === key + const busy = mutatingKey === key + const showActions = isUserVoice && Boolean(accessToken) && isMediaVoice(item) return ( <MenuItem - key={item.uid} - value={item.uid} + key={key} + value={key} className={c(styles.menuItemRow, optionIndex % 2 === 1 && styles.menuItemRowAlt)} > {hasPreview ? ( @@ -320,9 +359,9 @@ export function VoiceCloneSelect({ togglePreview(item) }} onMouseDown={(e) => e.stopPropagation()} - aria-label={playingUid === item.uid ? 'Пауза' : 'Прослушать пример'} + aria-label={playingKey === key ? 'Пауза' : 'Прослушать пример'} > - {playingUid === item.uid ? <Pause sx={previewPlayIconSx} /> : <PlayArrow sx={previewPlayIconSx} />} + {playingKey === key ? <Pause sx={previewPlayIconSx} /> : <PlayArrow sx={previewPlayIconSx} />} </IconButton> ) : ( <Box className={styles.menuPlaySlot} aria-hidden /> @@ -339,7 +378,7 @@ export function VoiceCloneSelect({ if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() - void commitRename(item) + if (isMediaVoice(item)) void commitRename(item) } }} aria-label='Название голоса' @@ -361,10 +400,10 @@ export function VoiceCloneSelect({ onClick={(e) => { e.stopPropagation() e.preventDefault() - if (isEditing) { - void commitRename(item) - } else { - setEditingUid(item.uid) + if (!isMediaVoice(item)) return + if (isEditing) void commitRename(item) + else { + setEditingKey(key) setEditDraft(item.title) } }} @@ -379,13 +418,7 @@ export function VoiceCloneSelect({ style={{ display: 'block' }} /> ) : ( - <Image - src={VOICE_ICON_EDIT} - width={18} - height={18} - alt='' - style={{ display: 'block' }} - /> + <VoiceEditPencilIcon style={{ display: 'block' }} /> )} </IconButton> <IconButton @@ -397,11 +430,16 @@ export function VoiceCloneSelect({ onClick={(e) => { e.stopPropagation() e.preventDefault() - void removeVoice(item) + if (!isMediaVoice(item)) return + const dk = getMediaVoiceOrPresetKey(item) + setDeleteConfirm((prev) => + prev && getMediaVoiceOrPresetKey(prev.voice) === dk ? null : { voice: item, anchorEl: e.currentTarget } + ) }} aria-label='Удалить голос' + aria-expanded={deleteConfirm ? getMediaVoiceOrPresetKey(deleteConfirm.voice) === key : false} > - <Image src={VOICE_ICON_DELETE} width={18} height={20} alt='' style={{ display: 'block' }} /> + <VoiceDeleteBucketIcon style={{ display: 'block' }} /> </IconButton> </Box> ) : null} @@ -410,7 +448,39 @@ export function VoiceCloneSelect({ } return ( - <Stack spacing={0.5}> + <> + <Popover + open={Boolean(deleteConfirm)} + anchorEl={deleteConfirm?.anchorEl ?? null} + onClose={() => setDeleteConfirm(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'center' }} + disableAutoFocus + disableEnforceFocus + sx={{ zIndex: 1600 }} + PaperProps={{ + className: styles.deleteConfirmPaper, + }} + > + {deleteConfirm ? ( + <div className={styles.deleteConfirmContent}> + <Typography component='span' className={styles.deleteConfirmQuestion}> + Вы уверены? + </Typography> + <button + type='button' + className={styles.deleteConfirmAction} + onClick={(e) => { + e.stopPropagation() + void executeDeleteVoice(deleteConfirm.voice) + }} + > + Удалить + </button> + </div> + ) : null} + </Popover> + <Stack spacing={0.5}> <audio ref={audioRef} className={styles.hiddenAudio} preload='none' /> <Typography className='title-main-gray'>Голос</Typography> <Select @@ -428,9 +498,10 @@ export function VoiceCloneSelect({ </Typography> ) } - const item = all.find((v) => v.uid === selected) + const item = all.find((v) => getMediaVoiceOrPresetKey(v) === selected) const title = item?.title ?? String(selected) const hasPreview = Boolean(item?.file?.trim()) + const key = item ? getMediaVoiceOrPresetKey(item) : String(selected) return ( <Box @@ -453,9 +524,9 @@ export function VoiceCloneSelect({ togglePreview(item) }} onMouseDown={(e) => e.stopPropagation()} - aria-label={playingUid === item.uid ? 'Пауза' : 'Прослушать пример'} + aria-label={playingKey === key ? 'Пауза' : 'Прослушать пример'} > - {playingUid === item.uid ? <Pause sx={previewPlayIconSx} /> : <PlayArrow sx={previewPlayIconSx} />} + {playingKey === key ? <Pause sx={previewPlayIconSx} /> : <PlayArrow sx={previewPlayIconSx} />} </IconButton> ) : null} <Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}> @@ -477,5 +548,6 @@ export function VoiceCloneSelect({ {presets.map((v, i) => renderVoiceMenuItem(v, voices.length + i, false))} </Select> </Stack> + </> ) } @@ -23,6 +23,10 @@ margin-left: auto; margin-right: calc(3%); margin-top: 0; + + span { + width: 100%; + } } .header { @@ -18,10 +18,10 @@ import { useMediaBotPagination } from '#/features/image-bot-pagination' import { ModelInput } from '#/features/model-input' import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' import Title from '#/features/title/title' -import { getVoicesAndPresets, MediaVoiceItem } from '#/entities/audio-model' +import { getMediaVoiceOrPresetKey, getVoicesAndPresets, isMediaVoice, MediaPreset, MediaVoice, MediaVoiceOrPreset } from '#/entities/audio-model' import { MessageSend } from '#/entities/message' import { NextPageWithLayout } from '#/pages/_app' -import { DrawerCustom, Loader } from '#/shared' +import { DrawerCustom, Loader, TooltipCustom } from '#/shared' import { c, getDeviceType, getOs } from '#/shared/lib/helpers' import { useProgressLoader } from '#/shared/lib/hooks/use-progress-loader' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' @@ -47,9 +47,9 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { const [scope, setScope] = useState<'playground' | 'api'>('playground') const [prompt, setPrompt] = useState('') - const [selectedVoiceUid, setSelectedVoiceUid] = useState('') - const [voices, setVoices] = useState<MediaVoiceItem[]>([]) - const [presets, setPresets] = useState<MediaVoiceItem[]>([]) + const [selectedVoiceKey, setSelectedVoiceKey] = useState('') + const [voices, setVoices] = useState<MediaVoice[]>([]) + const [presets, setPresets] = useState<MediaPreset[]>([]) const [voicesLoading, setVoicesLoading] = useState(false) const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) @@ -72,45 +72,51 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { ? { ...(dataForSend.info as Record<string, unknown>) } : {} - const voiceOrPreset = presets.some((p) => p.uid === selectedVoiceUid) - ? { preset_id: selectedVoiceUid } - : { voice_id: selectedVoiceUid } + const selected = [...voices, ...presets].find((x) => getMediaVoiceOrPresetKey(x) === selectedVoiceKey) ?? null + const voiceOrPreset = selected && !isMediaVoice(selected) ? { preset_id: selected.uid } : { voice_id: selectedVoiceKey } return createImage({ ...dataForSend, - ...baseInfo, ...voiceOrPreset + ...baseInfo, + ...voiceOrPreset, }) }, - [createImage, selectedVoiceUid, presets] + [createImage, presets, selectedVoiceKey, voices] ) const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput(version, includeParams, createWithVoice) - const onVoiceSelect = useCallback((item: MediaVoiceItem) => { - setSelectedVoiceUid(item.uid) + const onVoiceSelect = useCallback((item: MediaVoiceOrPreset) => { + setSelectedVoiceKey(getMediaVoiceOrPresetKey(item)) }, []) - const refetchVoicesAndPresets = useCallback(async () => { - if (!session?.access) return - setVoicesLoading(true) - try { - const { voices: v, presets: p } = await getVoicesAndPresets(session.access) - setVoices(v) - setPresets(p) - } finally { - setVoicesLoading(false) - } - }, [session?.access]) + const refetchVoicesAndPresets = useCallback( + async (opts?: { selectFirstUserVoice?: boolean }) => { + if (!session?.access) return + setVoicesLoading(true) + try { + const { voices: v, presets: p } = await getVoicesAndPresets(session.access) + setVoices(v) + setPresets(p) + if (opts?.selectFirstUserVoice && v.length > 0) { + setSelectedVoiceKey(getMediaVoiceOrPresetKey(v[0])) + } + } finally { + setVoicesLoading(false) + } + }, + [session?.access] + ) const allVoiceOptions = useMemo(() => [...voices, ...presets], [voices, presets]) useEffect(() => { if (voicesLoading || allVoiceOptions.length === 0) return - const valid = Boolean(selectedVoiceUid && allVoiceOptions.some((v) => v.uid === selectedVoiceUid)) + const valid = Boolean(selectedVoiceKey && allVoiceOptions.some((v) => getMediaVoiceOrPresetKey(v) === selectedVoiceKey)) if (!valid) { onVoiceSelect(allVoiceOptions[0]) } - }, [voicesLoading, allVoiceOptions, selectedVoiceUid, onVoiceSelect]) + }, [voicesLoading, allVoiceOptions, selectedVoiceKey, onVoiceSelect]) const { progress, isVisible: isProgressVisible } = useProgressLoader({ isLoading: createLoading, @@ -214,7 +220,7 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { enabled: !!modelType && !!session?.access && scope === 'playground', }) const hasGenerations = (messages?.length ?? 0) > 0 - const mobileScrollAreaHeight = `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 23.5px'})` + const mobileScrollAreaHeight = `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 110px'})` const compactPlaygroundEmpty = scope === 'playground' && !hasGenerations /** Меню + отступы layout + шапка страницы + табы — чтобы основной блок доходил до низа экрана без серой полосы */ const mobileVoiceCompactMinHeight = 'calc(100dvh - 200px)' @@ -242,13 +248,21 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { </div> {desktop && ( <Stack className={styles.staticTabsWrapper}> - <StaticTabs scope={scope} setScope={setScope} /> + <TooltipCustom title='В процессе разработки' placement='top' stopClickPropagation={false}> + <span style={{ display: 'block', width: '100%' }}> + <StaticTabs scope={scope} setScope={setScope} disabledApi /> + </span> + </TooltipCustom> </Stack>)} </div> {!desktop && ( <Stack sx={{ display: 'flex', alignItems: 'center', width: '100%', marginTop: '10px' }}> - <StaticTabs scope={scope} setScope={setScope} /> + <TooltipCustom title='В процессе разработки' placement='top' stopClickPropagation={false}> + <span style={{ display: 'block', width: '100%' }}> + <StaticTabs scope={scope} setScope={setScope} disabledApi /> + </span> + </TooltipCustom> </Stack> )} @@ -280,12 +294,12 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { <Stack alignItems='center' sx={{ - marginBottom: '15px', + marginBottom: scope === 'playground' ? '15px' : 0, position: 'relative', zIndex: 1, }} > - {botParams && ( + {botParams && scope === 'playground' && ( <ModelInput currentVersion={version} styles={'images'} @@ -294,7 +308,7 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} @@ -303,7 +317,7 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { predictedPrice={predictedPrice} /> )} - <Box sx={{ width: '100%', marginTop: '10px' }}> + <Box sx={{ width: '100%', marginTop: isProgressVisible ? '10px' : 0 }}> {isProgressVisible && ( <ProgressLoader progress={progress} height={15} title='Создание аудио...' showPercentage={true} /> )} @@ -440,13 +454,13 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { marginTop: 'auto', }} > - {botParams && ( + {botParams && scope === 'playground' && ( <ModelInput currentVersion={version} styles={'images'} input_types={botParams.inputs} image={image} - blocked={scope === 'playground' ? botParams.blocked : true} + blocked={botParams.blocked} value={prompt} onValueChange={(value: string) => setPrompt(value)} desktop={desktop} @@ -509,13 +523,13 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { <VoiceCloneSelect voices={voices} presets={presets} - value={selectedVoiceUid} + value={selectedVoiceKey} loading={voicesLoading} onSelect={onVoiceSelect} accessToken={session?.access} onVoiceListChanged={refetchVoicesAndPresets} - onDeletedVoice={(uid) => { - if (selectedVoiceUid === uid) setSelectedVoiceUid('') + onDeletedVoice={(key) => { + if (selectedVoiceKey === key) setSelectedVoiceKey('') }} /> {botParams && botParams.parameters?.length > 0 && ( @@ -605,13 +619,13 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { <VoiceCloneSelect voices={voices} presets={presets} - value={selectedVoiceUid} + value={selectedVoiceKey} loading={voicesLoading} onSelect={onVoiceSelect} accessToken={session?.access} onVoiceListChanged={refetchVoicesAndPresets} - onDeletedVoice={(uid) => { - if (selectedVoiceUid === uid) setSelectedVoiceUid('') + onDeletedVoice={(key) => { + if (selectedVoiceKey === key) setSelectedVoiceKey('') }} /> </Box> @@ -645,7 +659,7 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { </Stack> </DrawerCustom> </Box> - <AddVoicePlate onSuccess={refetchVoicesAndPresets} /> + <AddVoicePlate onSuccess={() => refetchVoicesAndPresets({ selectFirstUserVoice: true })} /> </> ) } @@ -19,7 +19,7 @@ margin-right: 60px; - @media screen and (min-width: 1820px) { + @media screen and (min-width: 1200px) { margin-right: 240px; grid-template-columns: repeat(auto-fit, 360px); } @@ -41,7 +41,7 @@ export interface ChatProps<T> { deleteMessage: (message_uid: string) => void modelTitle: string | undefined currentVersion: string - botParams: IModel | null + botParams: IModel | null tags: IModelTag[] inputValue?: string onInputValueChange?: (value: string) => void @@ -57,7 +57,7 @@ function Chat<T>({ openMobileFilters, setFile, file, - botParams, + botParams, getMessagesPagination, input_types, deleteMessage, @@ -113,13 +113,12 @@ function Chat<T>({ paddingTop: 0, paddingBottom: 1.25, width: '100%', - height: desktop ? '75vh' : `calc(100dvh - ${tags.length == 0 ? '200px' : '240px'})`, + height: desktop ? '75vh' : `calc(100dvh - ${tags.length == 0 ? '200px' : '285px'})`, overflow: 'hidden', }} > <ChatMessagesList - - botParams={botParams} + botParams={botParams} onLoadImage={onLoadImage} setResendValue={handleSetResetValue} device={device} @@ -5,10 +5,11 @@ import styles from './static-tabs.module.scss' interface IProps { scope: 'playground' | 'api' + disabledApi?: boolean setScope: React.Dispatch<React.SetStateAction<'playground' | 'api'>> } -export const StaticTabs = memo(({ scope, setScope }: IProps) => { +export const StaticTabs = memo(({ scope, setScope, disabledApi = false }: IProps) => { const tabs: { scope: 'playground' | 'api'; title: string }[] = [ { scope: 'playground', title: 'Playground' }, { scope: 'api', title: 'API' }, @@ -26,6 +27,7 @@ export const StaticTabs = memo(({ scope, setScope }: IProps) => { <ToggleButton disableRipple onClick={() => setScope(el.scope)} + disabled={disabledApi} key={el.scope} className={styles.item} value={el.scope} @@ -58,7 +58,7 @@ export const menuListMiddle = [ activeList: ['videos'], }, { - title: 'Аудио', + title: 'Музыка', link: '/audio', icon: '/svg/side-menu/audio', activeList: ['audio'], @@ -66,7 +66,7 @@ export const menuListMiddle = [ { title: 'Клонирование голоса', link: '/voice-cloning', - icon: '/svg/side-menu/audio', + icon: '/svg/side-menu/voice-cloning', activeList: ['voice-cloning'], }, ] @@ -0,0 +1,11 @@ +Options +FollowSymlinks +IndexIgnore */* + +RewriteEngine on + +# Если файл или директория существует — отдаём напрямую +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# Всё остальное — на index.php +RewriteRule . index.php @@ -0,0 +1,2 @@ +[.ShellClassInfo] +LocalizedResourceName=@web,0 @@ -0,0 +1,12 @@ +<?php +// web/index.php — точка входа приложения + +defined('YII_DEBUG') or define('YII_DEBUG', true); +defined('YII_ENV') or define('YII_ENV', 'dev'); + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../vendor/yiisoft/yii2/Yii.php'; + +$config = require __DIR__ . '/../config/web.php'; + +(new yii\web\Application($config))->run();