@@ -0,0 +1,10 @@ + + + + + + + + + + @@ -0,0 +1,10 @@ + + + + + + + + + + Binary files /dev/null and b/public/voice-clone/add-voice.png differ Binary files /dev/null and b/public/voice-clone/message-input-mobile.png differ Binary files /dev/null and b/public/voice-clone/message-preview-mobile.png differ Binary files /dev/null and b/public/voice-clone/my-generations.png differ Binary files /dev/null and b/public/voice-clone/parameters-panel.png differ Binary files /dev/null and b/public/voice-clone/step-1.png differ Binary files /dev/null and b/public/voice-clone/step-2.png differ Binary files /dev/null and b/public/voice-clone/voice-selector-mobile.png differ @@ -0,0 +1,33 @@ + + + + + + + + + + + + + @@ -0,0 +1,10 @@ + + + + @@ -1,11 +1,12 @@ -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { IShortModel } from '#/entities/model-entity' +import { MessageSend, sendMediaMessage } from '#/entities/message' import { getApiUrl } from '#/shared/lib/constants' -export async function getAudio(token?: string): Promise { +async function fetchMlModelsByCategory(category: string, token?: string): Promise { try { - const { data } = await axios.get(getApiUrl() + '/ml_models/?category=audio', { + const { data } = await axios.get(getApiUrl() + `/ml_models/?category=${encodeURIComponent(category)}`, { headers: { Authorization: `Bearer ${token}`, }, @@ -15,3 +16,115 @@ export async function getAudio(token?: string): Promise { return [] } } + +export async function getAudio(token?: string): Promise { + return fetchMlModelsByCategory('audio', token) +} + +export async function getVoiceCloneModels(token?: string): Promise { + return fetchMlModelsByCategory('voice', token) +} + +export type MediaVoice = { + /** backend: voice identifier */ + id: string | number + title: string + file: string +} + +export type MediaPreset = { + /** backend: preset identifier */ + uid: string + title: string + file: string +} + +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', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getMediaPresets(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/presets/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +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> { + const formData = new FormData() + formData.append('file', file) + if (title != null && title !== '') { + formData.append('title', title) + } + + return axios.post(getApiUrl() + '/api/media/voices/', formData, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** DELETE /api/media/voices/{voice_id}/ */ +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: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** PATCH /api/media/voices/{voice_id}/ — сменить title */ +export async function patchMediaVoiceTitle(voiceId: string | number, title: string, token?: string): Promise> { + return axios.patch( + getApiUrl() + `/api/media/voices/${encodeURIComponent(String(voiceId))}/`, + { title }, + { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ) +} + +/** POST /media/voice/{model} — генерация (тот же FormData/JSON, что у остальных media POST) */ +export function postVoiceCloneGeneration(model: string | null, dataForSend: MessageSend | FormData, token?: string) { + return sendMediaMessage(model, 'voice', dataForSend, token) +} @@ -4,9 +4,12 @@ import { Message, MessageSend } from '../types' import { getApiUrl } from '#/shared/lib/constants' - - -export async function sendMediaMessage(model: string | null, modelType: 'video' | 'image' | 'audio', dataForSend: MessageSend | FormData, token?: string) { +export async function sendMediaMessage( + model: string | null, + modelType: 'video' | 'image' | 'audio' | 'voice', + dataForSend: MessageSend | FormData, + token?: string +) { const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' return await axios.post(getApiUrl() + `/media/${modelType}/${model}`, dataForSend, { @@ -19,7 +22,7 @@ export async function sendMediaMessage(model: string | null, modelType: 'vide }) } -export async function getImagesBySlug(slug: string, token: string, type:'image' | 'video' | 'audio', offset?: number, limit = 10) { +export async function getImagesBySlug(slug: string, token: string, type: 'image' | 'video' | 'audio' | 'voice', offset?: number, limit = 10) { return await axios.get(getApiUrl() + `/media/${type}/${slug}?limit=${limit}&offset=${offset}`, { validateStatus: (status) => status < 500, headers: { @@ -13,12 +13,23 @@ import styles from './card.module.scss' export interface AudioModelCardProps extends IShortModel { accessed_models: string[] | null + /** Базовый сегмент URL списка моделей (например audio, voice-cloning) */ + modelsBasePath?: string } -export function AudioModelCard({ description, image, title, slug, accessed_models, blocked, tags }: AudioModelCardProps) { +export function AudioModelCard({ + description, + image, + title, + slug, + accessed_models, + blocked, + tags, + modelsBasePath = 'audio', +}: AudioModelCardProps) { const link = useMemo(() => { - return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `audio/${slug}` - }, [accessed_models]) + return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `${modelsBasePath}/${slug}` + }, [accessed_models, slug, modelsBasePath]) return ( @@ -10,7 +10,7 @@ import { formDataHelper } from '#/widgets/messages' export function useCreateMediaMessage( showError: (message: string) => void, type: string, - modelType: 'video' | 'image' | 'audio', + modelType: 'video' | 'image' | 'audio' | 'voice', device: Device, setMessages: Dispatch>, mobileScrollContainer: RefObject @@ -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') { +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,37 @@ 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 + setLoading(false) } - - offset.current += answer.length } const callback = async function (entries: IntersectionObserverEntry[]) { @@ -108,8 +143,6 @@ export function useMediaBotPagination(deviceType: Device, type:'image' | 'video' behavior: 'smooth', }) } - - setLoading(false) }, 500) } @@ -2,3 +2,6 @@ export const PLATE_CHANGE_PASSWORD = 'plate-change-password' export const RESEND_INVATION_PASSWORD = 'resend-invation-password' export const ERROR_REPORT = 'error-report' export const BUSINESS_ERROR_REPORT = 'business-error-report' +export const LOW_BALANCE_OFFER = 'low-balance-offer' +export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' +export const PLATE_ADD_VOICE = 'plate-add-voice' @@ -3,7 +3,7 @@ width: 100%; max-width: calc(100dvw); height: 100dvh; - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.7); top: 0; left: 0; transition: all 300ms ease-in-out; @@ -66,7 +66,7 @@ &__content { background-color: var(--new-ui-main-color); - border-radius: 20px; + border-radius: 16px; position: relative; overflow: hidden; height: fit-content; @@ -20,6 +20,8 @@ export interface PlatesTemplateProps { animationClass?: string animationBehaviorClass?: string headerClassName?: string + /** Перекрывает фон контента плашки (например `#151518`) */ + contentBackgroundColor?: string variant?: Variant } @@ -35,6 +37,7 @@ export default function PlatesTemplate({ animationBehaviorClass = styles['template__animation-behavior'], closeModal = () => {}, headerClassName, + contentBackgroundColor, variant = 'primary', }: PlatesTemplateProps) { const { setModal, getModal } = usePlatesStore() @@ -64,6 +67,7 @@ export default function PlatesTemplate({ modal.state ? '' : animationBehaviorClass, styles[`template__content_${variant}`] )} + style={contentBackgroundColor ? { backgroundColor: contentBackgroundColor } : undefined} onClick={(e) => e.stopPropagation()} >