@@ -27,6 +27,7 @@ export interface IModel { description: string slug: string image: string + streaming: boolean settings: { is_active: boolean } parameters: IModelParams[] versions: IModelVersions[] @@ -0,0 +1 @@ +export * from '#/widgets/chat-bot/features/image-modal' @@ -0,0 +1 @@ +export * from '#/widgets/chat-bot/features/model-input' @@ -1,4 +1,4 @@ -import { ChatBotPage } from '#/views/chat-bot' +import { ChatBotPage } from '#/widgets/chat-bot' import { getDefaultLayout } from '#/widgets/layouts' ChatBotPage.getLayout = getDefaultLayout() @@ -1,4 +1,4 @@ -import { ChatBotsPage } from "#/views/chat-bot"; +import { ChatBotsPage } from '#/widgets/chat-bot' import { getDefaultLayout } from "#/widgets/layouts"; ChatBotsPage.getLayout = getDefaultLayout({ titlePage: 'Чат-боты' }); @@ -1 +1 @@ -export * from './ui' \ No newline at end of file +export * from '#/widgets/chat-bot/ui/page' @@ -0,0 +1,101 @@ +import axios, { AxiosError, AxiosResponse } from 'axios' + +import { Message, MessageSend } from '#/entities/message' +import { getApiUrl } from '#/shared/lib/constants' +import { IMessageRequest } from '#/shared/lib/types/types-gpt' + +export async function parseStreamErrorResponse(response: Response): Promise { + try { + const body = (await response.json()) as { detail?: string } + return body.detail ?? 'Непредвиденная ошибка, попробуйте еще раз' + } catch { + return 'Непредвиденная ошибка, попробуйте еще раз' + } +} + +export const chatMessagesApi = { + getMessages: async (chatUid: string, offset: number, token?: string) => { + try { + const { data } = await axios.get(getApiUrl() + `/chats/${chatUid}/messages/?limit=10&offset=${offset}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return data + } catch (err) { + return { + error: true as const, + message: 'Произошла ошибка при выполнении запроса', + details: err as AxiosError, + } + } + }, + + deleteMessage: (chatUid: string, messageUid: string, token?: string) => + axios.delete(getApiUrl() + `/chats/${chatUid}/messages/${messageUid}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }), + + sendMessage: async (chatUid: string, dataForSend: MessageSend | FormData, token?: string) => { + const headerDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' + + return axios.post>( + getApiUrl() + `/chats/${chatUid}/messages/`, + dataForSend, + { + withCredentials: true, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': headerDataType, + }, + } + ) + }, + + sendMessageStream: (chatUid: string, dataForSend: MessageSend | FormData, token: string, signal?: AbortSignal) => { + const url = `${getApiUrl() + '/api'}/chats/${chatUid}/messages/stream` + const streamHeaders = { + Authorization: `Bearer ${token}`, + Accept: 'text/event-stream', + } + + if (dataForSend instanceof FormData) { + return fetch(url, { + method: 'POST', + headers: streamHeaders, + body: dataForSend, + signal, + credentials: 'include', + }) + } + + const { content, info } = dataForSend + + return fetch(url, { + method: 'POST', + headers: { + ...streamHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content, info }), + signal, + credentials: 'include', + }) + }, + + reconnectMessageStream: (chatUid: string, messageUid: string, offset: number, token: string, signal?: AbortSignal) => { + const url = `${getApiUrl() + '/api'}/chats/${chatUid}/messages/${messageUid}/stream?offset=${offset}` + + return fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'text/event-stream', + }, + signal, + credentials: 'include', + }) + }, +} @@ -0,0 +1 @@ +export * from './chat-messages-api' @@ -0,0 +1,2 @@ +export * from './use-images-library' +export * from './use-image-icons' \ No newline at end of file @@ -0,0 +1,4 @@ +export interface ImageWithState { + image: string + state: boolean +} \ No newline at end of file @@ -0,0 +1,38 @@ +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { useState } from 'react' + +export function useImageIcons() { + const [iconsMenu, setIconsMenu] = useState('') + const { showMessage } = useShowDataStore() + + const toggleMenu = (uid: string) => { + if (iconsMenu === uid) { + setIconsMenu('') + } else { + setIconsMenu(uid) + } + } + + const downloadFile = (url: string | null, content: string | undefined) => { + if (url) { + fetch(url) + .then((response) => response.blob()) + .then((blob) => { + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + const image_name = content?.replaceAll(' ', '_').substring(0, 25) + link.setAttribute('download', `${image_name}`) + document.body.appendChild(link) + link.click() + }) + .catch((error) => { + showMessage('Что-то пошло не так') + }) + } else { + showMessage('Изображение не найдено') + } + } + + return { iconsMenu, toggleMenu, downloadFile } +} @@ -0,0 +1,29 @@ +import { Dispatch, SetStateAction, useEffect, useMemo } from 'react' +import { Message } from '#/entities/message' + +export const useImagesLibrary = ( + setModal: Dispatch>, + current: string | null, + images: Message[] +) => { + const esc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + setModal(false) + } + } + + const currentIndex = useMemo( + () => images.findIndex((item) => item.file === current), + [current, images] + ) + + useEffect(() => { + window.addEventListener('keydown', esc) + return () => window.removeEventListener('keydown', esc) + }, []) + + return { + currentIndex, + } +} @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import { Swiper as SwiperCore } from 'swiper' + +export const useLibrarySwiper = (onSlideFalse: ((...args: any) => any) | undefined, reverse: boolean) => { + const [swiper, setSwiper] = useState(null) + + + const keydown = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + slideNext() + } + if (e.key === 'ArrowLeft') { + slidePrev() + } + } + + const slidePrev = function () { + const result = swiper?.slidePrev() + + if (!result && onSlideFalse && !reverse) { + onSlideFalse() + } + } + + const slideNext = function () { + const result = swiper?.slideNext() + + if (!result && onSlideFalse && reverse) { + onSlideFalse() + } + } + + useEffect(() => { + document.addEventListener('keydown', keydown, true) + return () => { + document.removeEventListener('keydown', keydown, true) + } + }, [swiper]) + + return { + swiper, + setSwiper, + slidePrev, + slideNext, + } +} @@ -0,0 +1,21 @@ +.overlay { + position: fixed; + z-index: 1201; + inset: 0; + width: 100%; + height: 100%; + display: none; +} + +.overlayVisible { + display: block; +} + +.backdrop { + position: absolute; + z-index: 101; + width: 100%; + height: 100%; + background-color: #000000; + opacity: 0.9; +} @@ -0,0 +1,193 @@ +import { Dispatch, SetStateAction, useEffect, useRef } from 'react' + +import overlayStyles from './full-screen-modal-overlay.module.scss' +import styles from './modal-styles.module.scss' +import { ArrowDropDown } from '@mui/icons-material' +import { c } from '#/shared' +import { useImagesLibrary } from '../model' + +import { Swiper, SwiperSlide } from 'swiper/react' +import 'swiper/css' +import { useLibrarySwiper } from '../model/use-swiper' +import { useImageIcons } from '../model/use-image-icons' +import { ModalImage } from './modal-image' +import { Message } from '#/entities/message' + +interface IProps { + modal: boolean + setModal: Dispatch> + current: string | null + setCurrent?: (value: string | null) => void + onSlideFalse?: (...args: any) => any + reverse?: boolean + images: Message[] +} + +export default function FullScreenModal({ + modal, + setModal, + current, + images, + onSlideFalse, + reverse = false, +}: IProps) { + const { currentIndex } = useImagesLibrary( + setModal, + current, + images + ) + + const prevImagesLengthRef = useRef(0) + + const { swiper, setSwiper, slideNext, slidePrev } = useLibrarySwiper(onSlideFalse, reverse) + + const { downloadFile } = useImageIcons() + + + useEffect(() => { + if (!modal) { + prevImagesLengthRef.current = 0 + return + } + if (!swiper) return + + const len = images.length + const prevLen = prevImagesLengthRef.current + + if (prevLen === 0) { + prevImagesLengthRef.current = len + return + } + + if (len === prevLen) return + + if (len < prevLen) { + prevImagesLengthRef.current = len + return + } + + const delta = len - prevLen + + const id = window.setTimeout(() => { + if (reverse) { + swiper.update() + } else { + const nextIndex = swiper.activeIndex + delta + const clamped = Math.max(0, Math.min(nextIndex, len - 1)) + swiper.slideTo(clamped, 0) + } + prevImagesLengthRef.current = len + }, 0) + + return () => clearTimeout(id) + }, [modal, images.length, reverse, swiper]) + + return ( +
setModal(false)} + > +
+
+
+ { + e.stopPropagation() + if (!swiper) return + const { file, content } = images[swiper.activeIndex] + downloadFile(file as string, content) + }} + width='24' + height='24' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + + +
+
+ { + e.stopPropagation() + if (!swiper) return + const { file } = images[swiper.activeIndex] + window.open(file as string, '_blank') + }} + width='24' + height='24' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + + +
+
setModal(false)}> + + + +
+
+
+ + {modal && ( + setSwiper(swiper)} + > + {images.map((image, index) => ( + + + + ))} + + )} + +
+
+ ) +} @@ -0,0 +1,2 @@ +export { default as ImageModal } from './full-screen-modal' +export * from './modal-image' @@ -0,0 +1,23 @@ +.loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.image_style { + width: auto; + max-width: 70vw; + height: 100%; + position: relative; + user-select: none; + display: flex; + justify-content: center; + align-items: center; + img { + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + } +} @@ -0,0 +1,41 @@ +import { Message } from '#/entities/message' +import { Loader } from '#/shared' +import Image from 'next/image' +import React from 'react' + +import styles from './modal-image.module.scss' + +interface ModalImageProps extends Message {} + +export const ModalImage = ({ file, ...image }: ModalImageProps) => { + const [state, updateImageState] = React.useState(false) + + return ( + <> + {!state && } +
+ {!file.includes('.svg') ? ( + e.stopPropagation()} + onLoadingComplete={() => { + updateImageState(true) + }} + loading='lazy' + src={file} + width={'1500'} + height={'1500'} + alt='К сожалению, изображение не загрузилось' + /> + ) : ( + e.stopPropagation()} + onLoad={() => updateImageState(true)} + alt='К сожалению, изображение не загрузилось' + className={styles.image_style} + /> + )} +
+ + ) +} @@ -0,0 +1,109 @@ +.overlayRoot { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1201; +} + +.overlayRoot_open { + display: block; +} + +.overlayRoot_closed { + display: none; +} + +.overlayBackdrop { + position: absolute; + z-index: 101; + width: 100%; + height: 100%; + background-color: #000000; + opacity: 0.9; +} + +.close_block { +} +.download { +} +.actions { + position: absolute; + z-index: 105; + cursor: pointer; + right: 25px; + top: 25px; + display: flex; + align-items: center; + gap: 15px; +} + +.arrow { + position: absolute; + z-index: 1205; + cursor: pointer; + top: 50%; + + width: 50px; + height: 50px; + + background: transparent; + border: none; + outline: none; + + svg { + fill: white; + width: 50px; + height: 50px; + } + + &_left { + transform: translateY(-50%) rotate(90deg); + left: -40px; + @media screen and (max-width: 768px) { + left: -3px; + } + } + + &_right { + transform: translateY(-50%) rotate(-90deg); + right: -50px; + + @media screen and (max-width: 768px) { + right: -10px; + } + } +} + +.image_block { + position: absolute; + padding: 0 20px; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 105; + margin: auto; + width: fit-content; + height: fit-content; +} + + + +.slide { + width: 80vw !important; + display: flex !important; + align-items: center; + justify-content: center; + height: 100% !important; + max-height: unset; +} + +.swiper { + position: relative; + width: 100%; + max-width: 80vw; + max-height: 1000px; + height: 80vh; +} @@ -0,0 +1 @@ +export * from './ui' @@ -0,0 +1 @@ +export * from './model-input' \ No newline at end of file @@ -0,0 +1,88 @@ +.wrapper { + display: flex; + align-items: center; + padding: 19px 14px; + width: 100%; + border-radius: 15px; + gap: 10px; + border: 2px solid rgb(66, 66, 72) !important; +} + +.wrapperImages { + background-color: #151518; +} + +.wrapperTransparent { + background-color: transparent; +} + +.settingsIcon { + min-width: 21px; + min-height: 21px; +} + +.area { + width: 100%; + resize: none; + border-radius: 0px; + overflow-y: auto; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + padding: 0px; + font-size: 16px; + max-height: 82px; + + &::-webkit-scrollbar { + display: none; /* Chrome, Brave, Edge */ + } + + &::placeholder { + font-size: 16px; + } + + border: none; + outline: none; + &:focus { + outline: none; + } +} + +.endAdornment { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.divider { + height: 20px; + width: 1px; + margin: 0px 8px; + background-color: #40404e; +} + +.predictPrice { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 14px; + border-radius: 16px; + margin-right: 8px; + background-color: #7F7DF31A; + gap: 4px; + + @media screen and (max-width: 400px) { + padding: 0; + gap: 2px; + background-color: transparent; + } +} + +.generationIcon { + margin-top: 3px; +} + +.settingsIcon { + flex-shrink: 0; + cursor: pointer; +} @@ -0,0 +1,330 @@ +import React, { FC, useCallback, useEffect, useRef } from 'react' +import { TextFieldProps } from '@mui/material/TextField/TextField' + +import { LoadImage } from '../../../ui/input-components/load_image' +import { SendBtn } from '../../../ui/input-components/send_button' +import { IModelInputs } from '#/shared/api/models/models' +import { c } from '#/shared/lib/helpers' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { ChatDisclaimer } from '#/shared/ui/chat-disclaimer/chat-disclaimer' + +import { PredictPrice } from './predict-price' + +import classes from './model-input.module.scss' + +function buildTypeVersionsMap(inputs: IModelInputs[]): Record { + const byType = new Map() + for (const item of inputs) { + const list = byType.get(item.type) ?? [] + list.push(item.versions) + byType.set(item.type, list) + } + const result: Record = {} + for (const [type, versionsList] of byType) { + if (versionsList.some((v) => v.length === 0)) { + result[type] = [] + } else { + result[type] = [...new Set(versionsList.flat())] + } + } + return result +} + +function inputAppliesToVersion(input: IModelInputs, currentVersion: string): boolean { + return input.versions.length === 0 || input.versions.includes(currentVersion) +} + +function buildRequiredForVersion(inputs: IModelInputs[], currentVersion: string): (string | null)[] { + const requiredTypes = new Set() + for (const el of inputs) { + if (!inputAppliesToVersion(el, currentVersion)) continue + if (el.required) requiredTypes.add(el.type) + } + return [...requiredTypes] +} + +interface Input { + loading: boolean + wonderMe?: () => void + desktop: boolean + image?: File | null + count?: number + quality?: string + unpinImage?: () => void + imageLoad?: (event: React.ChangeEvent | null, file?: File) => void + sendMessage: (message: string, required: (string | null)[]) => boolean + styles: 'images' | 'chats' | 'audio' + input_types?: IModelInputs[] + blocked?: boolean + viewMobileSettings: () => void + currentVersion: string + resendValue?: string + value?: string + onValueChange?: (value: string) => void + predictedPrice?: string | null +} + +export const ModelInput: FC = ({ + image, + loading, + unpinImage, + sendMessage, + desktop, + imageLoad, + styles, + input_types, + viewMobileSettings, + currentVersion, + resendValue, + blocked, + value: externalValue, + onValueChange: externalOnChange, + predictedPrice, +}: Input) => { + const [disabled, setDisabled] = React.useState(true) + const [required, setRequired] = React.useState<(string | null)[]>([]) + const [types, setTypes] = React.useState([]) + const [typeVersions, setTypeVersions] = React.useState({}) + const [internalValue, setInternalValue] = React.useState('') + const { showMessage } = useShowDataStore() + const hasAttachInput = + typeVersions && + Object.entries(typeVersions).some(([type, versions]) => { + if (type === 'text') return false + return Array.isArray(versions) && (versions.length === 0 || versions.includes(currentVersion)) + }) + + const value = externalValue !== undefined ? externalValue : internalValue + const setValue = externalOnChange ? (val: string) => externalOnChange(val) : setInternalValue + + const draftStorageKey = React.useMemo(() => { + return `model-input:draft:${styles}:${currentVersion}` + }, [styles, currentVersion]) + + const draftHydratedRef = useRef(false) + const setValueRef = useRef(setValue) + setValueRef.current = setValue + + const clearDraft = useCallback(() => { + try { + window.localStorage.removeItem(draftStorageKey) + } catch { + } + setValue('') + }, [draftStorageKey, setValue]) + + const valueRef = useRef(value) + const requiredRef = useRef(required) + valueRef.current = value + requiredRef.current = required + + const wrappedSendMessage = useCallback( + (msg: string, req: (string | null)[]) => { + const result = sendMessage(msg, req) + if (result) { + window.dispatchEvent(new CustomEvent('user-sent-message')) + } + return result + }, + [sendMessage] + ) + + useEffect(() => { + if (input_types) { + setRequired(buildRequiredForVersion(input_types, currentVersion)) + setTypes(input_types.filter((el) => inputAppliesToVersion(el, currentVersion)).map((el) => el.type)) + setTypeVersions(buildTypeVersionsMap(input_types)) + } + }, [input_types, currentVersion]) + + const handleFileLoad = useCallback( + (event: React.ChangeEvent | null) => { + const file = event?.target?.files?.[0] + if (!file) return + + const hasVideo = types.includes('video') + if (hasVideo) { + const name = (file.name || '').toLowerCase().split('?')[0] + const isMp4 = name.endsWith('.mp4') || file.type === 'video/mp4' + + if ((types.length === 1 && types[0] === 'video' && !isMp4) || (file.type.startsWith('video/') && !isMp4)) { + showMessage('Можно загрузить только видео в формате mp4') + return + } + + if (isMp4) { + const maxBytes = 50 * 1024 * 1024 + if (file.size > maxBytes) { + showMessage('Видео должно быть не больше 50 МБ') + return + } + } + } + + imageLoad?.(event, file) + }, + [imageLoad, showMessage, types] + ) + + useEffect(() => { + if (input_types && typeVersions && !typeVersions['text']) { + setDisabled(true) + } else if ( + typeVersions['text'] && + (typeVersions['text'].length === 0 || typeVersions['text'].includes(currentVersion)) + ) { + setDisabled(false) + } + }, [typeVersions, currentVersion, input_types]) + + useEffect(() => { + if (resendValue) { + setValue(resendValue) + } + }, [resendValue, setValue]) + + useEffect(() => { + draftHydratedRef.current = false + }, [draftStorageKey]) + + useEffect(() => { + try { + if (!draftHydratedRef.current) { + draftHydratedRef.current = true + if (resendValue) { + return + } + const saved = window.localStorage.getItem(draftStorageKey) + if (saved !== null && saved !== '' && value === '') { + setValueRef.current(saved) + return + } + } + window.localStorage.setItem(draftStorageKey, value) + } catch { + } + }, [draftStorageKey, resendValue, value]) + + useEffect(() => { + const handleTourSuggestedQuery = (e: CustomEvent<{ query: string }>) => { + if (e.detail?.query) { + setValue(e.detail.query) + } + } + window.addEventListener('tour-suggested-query', handleTourSuggestedQuery as EventListener) + return () => { + window.removeEventListener('tour-suggested-query', handleTourSuggestedQuery as EventListener) + } + }, [setValue]) + + useEffect(() => { + const handleTourSendMessage = () => { + const { current: msg } = valueRef + const { current: req } = requiredRef + if (msg && sendMessage(msg, req)) { + clearDraft() + if (unpinImage) unpinImage() + window.dispatchEvent(new CustomEvent('user-sent-message')) + } + } + window.addEventListener('tour-send-message', handleTourSendMessage) + return () => window.removeEventListener('tour-send-message', handleTourSendMessage) + }, [clearDraft, sendMessage, unpinImage]) + + return ( + <> +
+ {!desktop && ( + + + + )} + + +
+ + {hasAttachInput && ( + <> + {!blocked && ( + + )} +
+ + )} + {!disabled && !blocked && ( + string)) => { + const newVal = typeof val === 'function' ? val(value) : val + externalOnChange(newVal) + } : + (val: string | ((prev: string) => string)) => { + const next = typeof val === 'function' ? val(value) : val + if (next === '') { + clearDraft() + } else { + setInternalValue(next) + } + } + } + required={required} + /> + )} +
+
+ + {styles === 'chats' && ( + + )} + + ) +} @@ -0,0 +1,41 @@ +.predictPrice { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 14px; + border-radius: 16px; + margin-right: 8px; + gap: 4px; + color: #7f7df3; + background-color: #7f7df31a; + + :global(svg path) { + fill: currentColor; + } + + &--low { + color: #10b981; + background-color: rgba(16, 185, 129, 0.12); + } + + &--mid { + color: #f59e0b; + background-color: rgba(245, 158, 11, 0.12); + } + + &--high { + color: #f15179; + background-color: rgba(241, 81, 121, 0.12); + } +} + +.generationIcon { + margin-top: 3px; + color: inherit; +} + +.value { + font-size: 14px; + font-weight: 600; + line-height: 1; +} @@ -0,0 +1,36 @@ +import { TooltipCustom } from '#/shared' +import { SvgIcon } from '#/shared/ui/svg' + +import classes from './predict-price.module.scss' + +const HIGH_COST_TOOLTIP = + 'Генерация может выйти очень дорогой, т.к вы ввели очень большой запрос или в чате накопились большие сообщения. Если хотите снизить стоимость генерации, создайте новый чат или уменьшите количество запросов' + +function tierModifierClass(token: number): string { + if (token < 75) return classes['predictPrice--low'] + if (token < 150) return classes['predictPrice--mid'] + return classes['predictPrice--high'] +} + +export function PredictPrice({ token, isChatBot = false }: { token: number; isChatBot?: boolean }) { + const modifier = isChatBot ? tierModifierClass(token) : '' + const isHighCost = isChatBot && token >= 150 + + + if (token && token>0) { + return ( + +
+ + {token} +
+
+ ) + } + + return <> +} @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -0,0 +1,3 @@ +.inlineCode { + border-radius: 15px; +} @@ -0,0 +1,49 @@ +import React from 'react' +import ReactMarkdown from 'react-markdown' +import dynamic from 'next/dynamic' +import remarkGfm from 'remark-gfm' + +import styles from './markdown.module.scss' + +interface IProps { + id?: string + content: string +} + +export const Markdown = ({ content, id = 'markdown'}: IProps) => { + const LazyCode = dynamic(() => import('#/widgets/chat-gpt-field/ui/code')) + + return ( +
+ + + {children} + + + ) : ( + <> + + {children} + + + ) + }, + }} + > + {content} + +
+ ) +} @@ -0,0 +1,59 @@ +const STORAGE_PREFIX = 'chat-bot:stream:' + +export interface ChatStreamSession { + inputMessageUuid: string + lastOffset: number + modelContent: string +} + +function getStorageKey(chatUid: string) { + return `${STORAGE_PREFIX}${chatUid}` +} + +export function readChatStreamSession(chatUid: string): ChatStreamSession | null { + if (typeof window === 'undefined') { + return null + } + + try { + const raw = window.sessionStorage.getItem(getStorageKey(chatUid)) + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as ChatStreamSession + if (!parsed.inputMessageUuid || typeof parsed.lastOffset !== 'number') { + return null + } + + return { + inputMessageUuid: parsed.inputMessageUuid, + lastOffset: parsed.lastOffset, + modelContent: parsed.modelContent ?? '', + } + } catch { + return null + } +} + +export function writeChatStreamSession(chatUid: string, session: ChatStreamSession) { + if (typeof window === 'undefined') { + return + } + + try { + window.sessionStorage.setItem(getStorageKey(chatUid), JSON.stringify(session)) + } catch { + } +} + +export function clearChatStreamSession(chatUid: string) { + if (typeof window === 'undefined') { + return + } + + try { + window.sessionStorage.removeItem(getStorageKey(chatUid)) + } catch { + } +} @@ -0,0 +1,85 @@ +export type ChatStreamEventName = 'start' | 'token' | 'done' | 'error' + +export interface ParsedSseEvent { + id?: number + event?: ChatStreamEventName + data?: unknown +} + +function parseSseBlock(block: string): ParsedSseEvent | null { + const trimmed = block.trim() + if (!trimmed || trimmed.startsWith(':')) { + return null + } + + const event: ParsedSseEvent = {} + + for (const line of trimmed.split('\n')) { + if (line.startsWith('id:')) { + const id = Number.parseInt(line.slice(3).trim(), 10) + if (!Number.isNaN(id)) { + event.id = id + } + continue + } + + if (line.startsWith('event:')) { + event.event = line.slice(6).trim() as ChatStreamEventName + continue + } + + if (line.startsWith('data:')) { + const raw = line.slice(5).trim() + try { + event.data = JSON.parse(raw) + } catch { + event.data = raw + } + } + } + + if (!event.event && event.data === undefined && event.id === undefined) { + return null + } + + return event +} + +export async function* readSseEvents(body: ReadableStream): AsyncGenerator { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n') + + let boundary = buffer.indexOf('\n\n') + while (boundary !== -1) { + const block = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + + const parsed = parseSseBlock(block) + if (parsed) { + yield parsed + } + + boundary = buffer.indexOf('\n\n') + } + } + + if (buffer.trim()) { + const parsed = parseSseBlock(buffer) + if (parsed) { + yield parsed + } + } + } finally { + reader.releaseLock() + } +} @@ -0,0 +1 @@ +export { useChatModel } from './use-chat-model' @@ -0,0 +1,731 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useSession } from 'next-auth/react' + +import { useAppDispatch } from '#/app/store/store' +import { getUserBalance } from '#/entities/balance' +import { Message, MessageSend } from '#/entities/message' +import { moveChatToTop } from '#/features/chats' +import { Variant } from '#/shared/lib/hooks/use-show-data' + +import { chatMessagesApi, parseStreamErrorResponse } from '../api/chat-messages-api' +import { clearChatStreamSession, readChatStreamSession, writeChatStreamSession } from '../lib/chat-stream-session' +import { readSseEvents } from '../lib/parse-sse' + +const TEMP_USER_MESSAGE_UID = 'new-send' +const STREAMING_PENDING_UID = 'pending' +const MAX_RECONNECT_ATTEMPTS = 8 + +function getWaitingForModelContent(modelType: string) { + return `Ваш вопрос получен. Ожидание ответа от ${modelType}...` +} + +function getStreamingModelUid(inputMessageUuid: string) { + return `streaming:${inputMessageUuid}` +} + +function removeStreamingModelMessage(messages: Message[] | null, inputMessageUuid?: string | null) { + const uidsToRemove = new Set([getStreamingModelUid(STREAMING_PENDING_UID)]) + + if (inputMessageUuid) { + uidsToRemove.add(getStreamingModelUid(inputMessageUuid)) + } + + return messages?.filter((message) => !uidsToRemove.has(message.uid)) ?? null +} + +function detectInProgressStream(messages: Message[]): { inputMessageUuid: string; lastOffset: number } | null { + if (messages.length === 0) { + return null + } + + const lastMessage = messages[messages.length - 1] + if (!lastMessage.from_model) { + return { inputMessageUuid: lastMessage.uid, lastOffset: 0 } + } + + return null +} + +function findModelMessageUid(messages: Message[], inputMessageUuid: string) { + const userIndex = messages.findIndex((message) => message.uid === inputMessageUuid) + if (userIndex === -1) { + return getStreamingModelUid(inputMessageUuid) + } + + const modelMessage = messages[userIndex + 1] + if (modelMessage?.from_model) { + return modelMessage.uid + } + + return getStreamingModelUid(inputMessageUuid) +} + +function ensureModelMessageForStream( + messages: Message[], + inputMessageUuid: string, + modelType: string, + savedContent?: string +): Message[] { + const userIndex = messages.findIndex((message) => message.uid === inputMessageUuid) + if (userIndex === -1) { + return messages + } + + const modelMessage = messages[userIndex + 1] + const waitingContent = savedContent || getWaitingForModelContent(modelType) + + if (modelMessage?.from_model) { + if (waitingContent && modelMessage.content !== waitingContent) { + const shouldReplace = + !modelMessage.content || modelMessage.content === getWaitingForModelContent(modelType) || savedContent + if (shouldReplace && savedContent) { + return messages.map((message, index) => + index === userIndex + 1 ? { ...message, content: waitingContent } : message + ) + } + } + return messages + } + + const userMessage = messages[userIndex] + return [ + ...messages, + { + content: waitingContent, + created_at: userMessage.created_at, + elapsed_time: '', + file: null, + from_model: true, + info: null, + is_favourite: false, + is_sent: true, + uid: getStreamingModelUid(inputMessageUuid), + model: '', + }, + ] +} + +function createOptimisticUserMessage(dataForSend: MessageSend): Message { + const date = new Date() + + return { + content: dataForSend.content.trim(), + info: dataForSend.info as Message['info'], + is_sent: true, + model: '', + file: dataForSend.file + ? ((URL.createObjectURL(dataForSend.file) + '?type=.' + dataForSend.file.name.split('.')[1]) as string) + : null, + from_model: false, + uid: TEMP_USER_MESSAGE_UID, + elapsed_time: '', + is_favourite: false, + created_at: date.toISOString(), + } +} + +function createStreamingModelMessage(inputMessageUuid: string, createdAt: string, modelType: string): Message { + return { + content: getWaitingForModelContent(modelType), + created_at: createdAt, + elapsed_time: '', + file: null, + from_model: true, + info: null, + is_favourite: false, + is_sent: true, + uid: getStreamingModelUid(inputMessageUuid), + model: '', + } +} + +function buildStreamRequestBody(dataForSend: MessageSend) { + if (dataForSend.file) { + const formData = new FormData() + formData.append('file', dataForSend.file) + formData.append('content', dataForSend.content) + formData.append('info', JSON.stringify(dataForSend.info)) + return formData + } + + return dataForSend +} + +type StreamRuntime = { + lastOffset: number + modelContent: string + hasStreamEvents: boolean + streamFailed: boolean +} + +async function consumeSseStream( + body: ReadableStream, + handlers: { + onStart?: (messageUuid: string) => void + onToken: (content: string) => void + onDone: (content: string) => void + onError: (detail: string) => void + onEventId?: (id: number) => void + } +) { + for await (const sseEvent of readSseEvents(body)) { + if (sseEvent.id !== undefined) { + handlers.onEventId?.(sseEvent.id) + } + + switch (sseEvent.event) { + case 'start': { + const data = sseEvent.data as { message_uuid?: string } + if (data?.message_uuid) { + handlers.onStart?.(data.message_uuid) + } + break + } + case 'token': { + const data = sseEvent.data as { content?: string } + if (typeof data?.content === 'string' && data.content.length > 0) { + handlers.onToken(data.content) + } + break + } + case 'done': { + const data = sseEvent.data as { content?: string } + handlers.onDone(data?.content ?? '') + break + } + case 'error': { + const data = sseEvent.data as { detail?: string } + handlers.onError(data?.detail ?? 'Ошибка генерации') + break + } + } + } +} + +export function useChatModel( + currentChat: string | null, + showMessage: (message: string, variant?: Variant) => void, + modelType: string, + streaming: boolean +) { + const { data } = useSession() + const dispatch = useAppDispatch() + const [messages, setMessages] = useState(null) + const [loading, setLoading] = useState(false) + const [offset, setOffset] = useState(0) + + const abortRef = useRef(null) + const isSendingRef = useRef(false) + const inputMessageUuidRef = useRef(null) + const streamCreatedAtRef = useRef('') + const streamRuntimeRef = useRef({ + lastOffset: 0, + modelContent: '', + hasStreamEvents: false, + streamFailed: false, + }) + + const persistStreamSession = useCallback( + (chatUid: string, inputMessageUuid: string) => { + const runtime = streamRuntimeRef.current + writeChatStreamSession(chatUid, { + inputMessageUuid, + lastOffset: runtime.lastOffset, + modelContent: runtime.modelContent, + }) + }, + [] + ) + + const resetStreamRuntime = useCallback((modelContent = '') => { + streamRuntimeRef.current = { + lastOffset: 0, + modelContent, + hasStreamEvents: false, + streamFailed: false, + } + }, []) + + const markOptimisticUserMessageFailed = useCallback((inputUuid?: string | null) => { + setMessages((prev) => + prev?.map((message) => { + if (message.uid === TEMP_USER_MESSAGE_UID || (inputUuid && message.uid === inputUuid)) { + return { ...message, is_sent: false } + } + return message + }) ?? null + ) + }, []) + + const processMessageStream = useCallback( + async (response: Response, options: { isReconnect?: boolean } = {}) => { + if (!currentChat || !response.body) { + return { shouldReconnect: false, streamFailed: true, missingStart: true } + } + + const runtime = streamRuntimeRef.current + runtime.streamFailed = false + + await consumeSseStream(response.body, { + onStart: (messageUuid) => { + runtime.hasStreamEvents = true + inputMessageUuidRef.current = messageUuid + + if (!runtime.modelContent) { + runtime.modelContent = getWaitingForModelContent(modelType) + } + + persistStreamSession(currentChat, messageUuid) + + if (options.isReconnect) { + return + } + + const pendingUid = getStreamingModelUid(STREAMING_PENDING_UID) + const modelUid = getStreamingModelUid(messageUuid) + + setMessages((prev) => { + if (!prev) { + return prev + } + + return prev.map((message) => { + if (message.uid === TEMP_USER_MESSAGE_UID) { + return { ...message, uid: messageUuid } + } + if (message.uid === pendingUid) { + return { ...message, uid: modelUid, content: runtime.modelContent } + } + return message + }) + }) + }, + onEventId: (id) => { + runtime.lastOffset = id + const inputUuid = inputMessageUuidRef.current + if (inputUuid) { + persistStreamSession(currentChat, inputUuid) + } + }, + onToken: (content) => { + runtime.hasStreamEvents = true + const inputUuid = inputMessageUuidRef.current + if (!inputUuid) { + return + } + + const isFirstToken = + !runtime.modelContent || runtime.modelContent === getWaitingForModelContent(modelType) + runtime.modelContent = isFirstToken ? content : runtime.modelContent + content + persistStreamSession(currentChat, inputUuid) + + setMessages((prev) => { + if (!prev) { + return prev + } + + const resolvedModelUid = findModelMessageUid(prev, inputUuid) + return prev.map((message) => + message.uid === resolvedModelUid ? { ...message, content: runtime.modelContent } : message + ) + }) + }, + onDone: (content) => { + const inputUuid = inputMessageUuidRef.current + if (!inputUuid) { + return + } + + clearChatStreamSession(currentChat) + runtime.modelContent = content + runtime.hasStreamEvents = false + + setMessages((prev) => { + if (!prev) { + return prev + } + + const modelUid = findModelMessageUid(prev, inputUuid) + return prev.map((message) => (message.uid === modelUid ? { ...message, content } : message)) + }) + }, + onError: () => { + runtime.streamFailed = true + const inputUuid = inputMessageUuidRef.current + if (inputUuid) { + persistStreamSession(currentChat, inputUuid) + } + }, + }) + + return { + shouldReconnect: runtime.streamFailed && runtime.hasStreamEvents, + streamFailed: runtime.streamFailed, + missingStart: !runtime.hasStreamEvents && !options.isReconnect && !inputMessageUuidRef.current, + } + }, + [currentChat, modelType, persistStreamSession] + ) + + const reconnectToStream = useCallback( + async ( + chatUid: string, + inputMessageUuid: string, + streamOffset: number, + loadedMessages?: Message[], + savedContent?: string, + options: { silent?: boolean; attempt?: number } = {} + ): Promise => { + if (!data?.access) { + return false + } + + const attempt = options.attempt ?? 0 + if (attempt >= MAX_RECONNECT_ATTEMPTS) { + if (!options.silent) { + showMessage('Не удалось восстановить соединение со стримом') + } + return false + } + + abortRef.current?.abort() + const abortController = new AbortController() + abortRef.current = abortController + isSendingRef.current = true + inputMessageUuidRef.current = inputMessageUuid + + resetStreamRuntime(savedContent || '') + streamRuntimeRef.current.lastOffset = streamOffset + + if (loadedMessages) { + setMessages(ensureModelMessageForStream(loadedMessages, inputMessageUuid, modelType, savedContent)) + } + + setLoading(true) + + try { + const response = await chatMessagesApi.reconnectMessageStream( + chatUid, + inputMessageUuid, + streamOffset, + data.access, + abortController.signal + ) + + if (response.status === 404) { + clearChatStreamSession(chatUid) + return false + } + + if (!response.ok) { + persistStreamSession(chatUid, inputMessageUuid) + if (!options.silent) { + showMessage(await parseStreamErrorResponse(response)) + } + return false + } + + if (!response.body) { + persistStreamSession(chatUid, inputMessageUuid) + return false + } + + const result = await processMessageStream(response, { isReconnect: true }) + + if (result.shouldReconnect) { + persistStreamSession(chatUid, inputMessageUuid) + return reconnectToStream( + chatUid, + inputMessageUuid, + streamRuntimeRef.current.lastOffset, + undefined, + streamRuntimeRef.current.modelContent, + { silent: true, attempt: attempt + 1 } + ) + } + + if (result.streamFailed && !options.silent) { + showMessage('Ошибка генерации') + } + + return !result.streamFailed + } catch { + if (!abortController.signal.aborted) { + persistStreamSession(chatUid, inputMessageUuid) + } + return false + } finally { + isSendingRef.current = false + setLoading(false) + dispatch(getUserBalance(data.access)) + if (abortRef.current === abortController) { + abortRef.current = null + } + } + }, + [data?.access, dispatch, modelType, persistStreamSession, processMessageStream, resetStreamRuntime, showMessage] + ) + + const tryReconnectOnLoad = useCallback( + async (chatUid: string, loadedMessages: Message[]) => { + const session = readChatStreamSession(chatUid) + const reconnectTarget = session ?? detectInProgressStream(loadedMessages) + + if (!reconnectTarget) { + return + } + + await reconnectToStream( + chatUid, + reconnectTarget.inputMessageUuid, + reconnectTarget.lastOffset, + loadedMessages, + session?.modelContent + ) + }, + [reconnectToStream] + ) + + useEffect(() => { + return () => { + abortRef.current?.abort() + } + }, []) + + useEffect(() => { + abortRef.current?.abort() + abortRef.current = null + isSendingRef.current = false + inputMessageUuidRef.current = null + resetStreamRuntime() + + if (!currentChat) { + setMessages(null) + setOffset(0) + return + } + + setMessages([]) + setOffset(0) + + let cancelled = false + + ;(async () => { + setLoading(true) + const answer = await chatMessagesApi.getMessages(currentChat, 0, data?.access) + if (cancelled) { + return + } + setLoading(false) + + if (!Array.isArray(answer)) { + showMessage('Ошибка загрузки чата') + return + } + + const loadedMessages = answer.reverse() + setMessages(loadedMessages) + setOffset(answer.length) + + if (streaming && data?.access) { + await tryReconnectOnLoad(currentChat, loadedMessages) + } + })() + + return () => { + cancelled = true + } + }, [currentChat, data?.access, resetStreamRuntime, showMessage, streaming, tryReconnectOnLoad]) + + const getMessagesPagination = useCallback(async () => { + if (!currentChat || isSendingRef.current) { + return + } + + setLoading(true) + const answer = await chatMessagesApi.getMessages(currentChat, offset, data?.access) + setLoading(false) + + if (Array.isArray(answer)) { + const newMessages = answer.reverse() + setMessages((prev) => (prev != null ? [...newMessages, ...prev] : newMessages)) + setOffset((prev) => prev + answer.length) + return + } + + showMessage('Ошибка загрузки сообщений') + }, [currentChat, data?.access, offset, showMessage]) + + const sendMessage = useCallback( + async (dataForSend: MessageSend) => { + if (isSendingRef.current) { + return + } + + if (!currentChat) { + showMessage('Выберите или создайте чат') + return + } + + if (!data?.access) { + showMessage('Требуется авторизация') + return + } + + isSendingRef.current = true + setLoading(true) + dispatch(moveChatToTop(currentChat)) + + const userMessage = createOptimisticUserMessage(dataForSend) + streamCreatedAtRef.current = userMessage.created_at + const requestBody = buildStreamRequestBody(dataForSend) + + if (!streaming) { + const modelMessageAboutStartGeneration: Message = { + ...userMessage, + content: getWaitingForModelContent(modelType), + from_model: true, + file: null, + created_at: userMessage.created_at, + } + + setMessages((prev) => [...(prev ?? []), userMessage, modelMessageAboutStartGeneration]) + + try { + const { data: result, status } = await chatMessagesApi.sendMessage(currentChat, requestBody, data.access) + + setMessages((prev) => prev!.slice(0, -2)) + + if (status >= 400) { + userMessage.is_sent = false + setMessages((prev) => [...(prev ?? []), userMessage]) + const error = result as { detail: string } + if (error.detail) { + showMessage(error.detail) + } else { + showMessage('Непредвиденная ошибка, попробуйте еще раз') + } + } else { + setMessages((prev) => [...(prev ?? []), ...(result as Message[])]) + } + } catch { + setMessages((prev) => { + const withoutPlaceholder = prev?.slice(0, -2) ?? [] + return [...withoutPlaceholder, { ...userMessage, is_sent: false }] + }) + showMessage('Непредвиденная ошибка, попробуйте еще раз') + } finally { + isSendingRef.current = false + setLoading(false) + dispatch(getUserBalance(data.access)) + } + + return + } + + abortRef.current?.abort() + const abortController = new AbortController() + abortRef.current = abortController + inputMessageUuidRef.current = null + resetStreamRuntime(getWaitingForModelContent(modelType)) + + setMessages((prev) => [ + ...(prev ?? []), + userMessage, + createStreamingModelMessage(STREAMING_PENDING_UID, streamCreatedAtRef.current, modelType), + ]) + + try { + const response = await chatMessagesApi.sendMessageStream(currentChat, requestBody, data.access, abortController.signal) + + if (!response.ok) { + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + showMessage(await parseStreamErrorResponse(response)) + return + } + + if (!response.body) { + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + showMessage('Пустой ответ сервера') + return + } + + const result = await processMessageStream(response) + + if (result.shouldReconnect && inputMessageUuidRef.current) { + await reconnectToStream( + currentChat, + inputMessageUuidRef.current, + streamRuntimeRef.current.lastOffset, + undefined, + streamRuntimeRef.current.modelContent, + { silent: true } + ) + return + } + + if (result.missingStart) { + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + showMessage('Непредвиденная ошибка, попробуйте еще раз') + } + } catch { + if (abortController.signal.aborted) { + return + } + + const inputUuid = inputMessageUuidRef.current + const runtime = streamRuntimeRef.current + + if (inputUuid && runtime.hasStreamEvents) { + persistStreamSession(currentChat, inputUuid) + await reconnectToStream(currentChat, inputUuid, runtime.lastOffset, undefined, runtime.modelContent, { + silent: true, + }) + return + } + + markOptimisticUserMessageFailed(inputUuid) + setMessages((prev) => removeStreamingModelMessage(prev, inputUuid)) + showMessage('Непредвиденная ошибка, попробуйте еще раз') + } finally { + isSendingRef.current = false + setLoading(false) + dispatch(getUserBalance(data.access)) + if (abortRef.current === abortController) { + abortRef.current = null + } + } + }, + [ + currentChat, + data?.access, + dispatch, + markOptimisticUserMessageFailed, + modelType, + persistStreamSession, + processMessageStream, + reconnectToStream, + resetStreamRuntime, + showMessage, + streaming, + ] + ) + + const deleteMessage = useCallback( + (messageUid: string) => { + if (!currentChat) { + return + } + + chatMessagesApi.deleteMessage(currentChat, messageUid, data?.access).then(() => { + setMessages((prev) => prev?.filter((message) => message.uid !== messageUid) ?? null) + }) + }, + [currentChat, data?.access] + ) + + return { messages, sendMessage, loading, getMessagesPagination, deleteMessage } +} @@ -0,0 +1,138 @@ +.pointer { + cursor: pointer; +} + +.container { + position: relative; + width: 46px; + margin: -12px 0; + height: 46px; + flex-shrink: 0; + background-color: #40404e; + border-radius: 4px; +} + +.previewImage { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 4px; +} + +.previewIcon { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + background-color: #8280FF; +} + +.previewClickable { + cursor: pointer; +} + +.tooltipIconPlaceholder { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background-color: #2a2a2e; + border-radius: 12px; + padding-bottom: 24px; + min-width: 128px; + min-height: 144px; +} + +.tooltipContent { + position: relative; + display: inline-block; + background-color: #222222; + border-radius: 12px; + display: flex; + min-height: 144px; + min-width: 128px; + + &.tooltipContentSvg { + padding: 40px; + } + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient(to top, #151518, transparent); + pointer-events: none; + border-radius: 0 0 10px 10px; + z-index: 0; + } +} + +.tooltipImage { + display: block; + min-height: 144px; + min-width: 128px; + max-width: 300px; + max-height: 300px; + object-fit: contain; + border-radius: 12px; +} + +.tooltipText { + position: absolute; + top: 0; + left: 0; + padding: 8px 8px; + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 2px; + font-size: 13px; + z-index: 1; + + .tooltipTextItem { + display: flex; + justify-content: space-between; + } +} + +.fileName { + min-width: 0; + overflow: hidden; +} + +.dimensionsText { + background-color: #151518; + border-radius: 12px; + padding: 4px 8px; +} + +.closeButton { + position: absolute; + top: -6px; + right: -4px; + width: 12px; + height: 12px; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.8); + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 1); + } +} + +.hiddenInput { + display: none; +} @@ -0,0 +1,255 @@ +import React, { useRef, useState } from 'react' +import { Box, Tooltip, Typography } from '@mui/material' +import Image from 'next/image' + +import { getFileTypeIcon, isImageFile } from '#/shared/lib/helpers' + +import styles from './load_image.module.scss' + +const isVideoFile = (file: File) => { + const name = (file.name || '').toLowerCase().split('?')[0] + return file.type === 'video/mp4' || name.endsWith('.mp4') +} + +interface IProps { + loading: boolean + unpinImage?: () => void + image: File | null | undefined + imageLoad?: (event: React.ChangeEvent | null, file?: File) => void + types: string[] + fileNameMaxLength?: number + desktop?: boolean +} + +const acceptTypes: any = { + image: 'image/png,image/jpeg', + zip: 'application/zip', + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + doc: 'application/msword', + audio: 'audio/*', + video: 'video/mp4,.mp4', + txt: '.txt', + text: '', +} + +const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + + +const formatFileName = (name: string, maxLength: number = 30) => { + const lastDot = name.lastIndexOf('.') + const baseName = lastDot > 0 ? name.slice(0, lastDot) : name + const extWithMeta = lastDot > 0 ? name.slice(lastDot + 1) : '' + const ext = extWithMeta.split('?')[0] + if (baseName.length <= maxLength) return name + return `${baseName.slice(0, maxLength)}...${ext}` +} + +export const LoadImage = ({ loading, unpinImage, image, imageLoad, types, fileNameMaxLength, desktop }: IProps) => { + const ref = useRef(null) + const [inputTypes, setInputTypes] = useState('') + const [previewUrl, setPreviewUrl] = useState(null) + const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null) + const videoThumbTimeRef = useRef(0.05) + + React.useEffect(() => { + setInputTypes(types.map((el) => acceptTypes[el]).toString()) + }, [types]) + + React.useEffect(() => { + if (!image) { + setPreviewUrl(null) + setDimensions(null) + videoThumbTimeRef.current = 0.05 + return + } + const isImage = isImageFile(image) + const isVideo = isVideoFile(image) + + if (!isImage && !isVideo) { + setPreviewUrl(null) + setDimensions(null) + videoThumbTimeRef.current = 0.05 + return + } + + const url = URL.createObjectURL(image) + setPreviewUrl(url) + + if (isImage) { + const img = new window.Image() + img.onload = () => { + setDimensions({ width: img.naturalWidth, height: img.naturalHeight }) + } + img.src = url + } else { + setDimensions(null) + videoThumbTimeRef.current = 0.05 + } + + return () => URL.revokeObjectURL(url) + }, [image]) + + const seekVideoToFirstFrame = (el: HTMLVideoElement | null) => { + if (!el) return + const onLoadedMetadata = () => { + try { + const duration = Number.isFinite(el.duration) ? el.duration : 0 + const target = duration > 0 ? Math.min(0.1, duration / 2) : 0.05 + videoThumbTimeRef.current = target + el.currentTime = target + el.pause() + } catch {} + } + el.addEventListener('loadedmetadata', onLoadedMetadata, { once: true }) + } + + const startTooltipVideo = async (el: HTMLVideoElement | null) => { + if (!el) return + try { + // начинаем чуть дальше нулевого кадра (иначе часто чёрный) + const duration = Number.isFinite(el.duration) ? el.duration : 0 + const target = duration > 0 ? Math.min(0.1, duration / 2) : 0.05 + el.currentTime = target + } catch {} + try { + el.muted = true + await el.play() + } catch { + // autoplay может быть заблокирован политикой браузера + } + } + + if (!imageLoad || loading) { + return null + } + + if (image) { + const isImage = isImageFile(image) + const isVideo = isVideoFile(image) + const fileIcon = getFileTypeIcon(image) + const isSvg = image.name.toLowerCase().split('?')[0].endsWith('.svg') + + const tooltipContent = ( + + {isImage && previewUrl ? ( + Превью + ) : isVideo && previewUrl && desktop ? ( + + ) + + return ( + + + {isImage && previewUrl ? ( + { e.stopPropagation(); unpinImage?.() } : undefined} + /> + ) : isVideo ? ( + + { + e.stopPropagation() + unpinImage?.() + }} + > + Закрыть + + + ) + } + return ( + <> + + (ref.current! as any).click()} + > + + + + ) +} @@ -0,0 +1,47 @@ +import React from 'react' +import CircularProgress from '@mui/material/CircularProgress' +import Image from 'next/image' + +interface IProps { + loading: boolean + unpinImage?: () => void + input: string + sendMessage: (message: string, required: (string | null)[]) => boolean + setInput: React.Dispatch> + required: (string | null)[] +} + +export const SendBtn = ({ loading, unpinImage, input, sendMessage, setInput, required }: IProps) => { + const send_icon = '/svg/chatgpt/send_message.svg' + + if (loading) { + return ( + + ) + } + return ( + { + if (!loading) { + const isSend = sendMessage(input, required) + if (isSend) { + setInput('') + if (unpinImage) unpinImage() + } + } + }} + height={28} + width={28} + className='pointer' + src={send_icon} + alt={'Отправить запрос'} + /> + ) +} @@ -5,7 +5,7 @@ import Image from 'next/image' import { TooltipCustom } from '#/shared' import { formatDate } from '#/shared/lib/helpers' -import { Markdown } from '#/widgets/markdown/markdown' +import { Markdown } from '../../lib/markdown/markdown' import styles from './bot-message.module.scss' import { FullscreenIcon } from './icons/fullscreen-icon' @@ -5,11 +5,11 @@ import { useSession } from 'next-auth/react' import { Message } from '#/entities/message' import { IModel } from '#/entities/model-entity' -import { ImageModal } from '#/features/image-modal' +import { ImageModal } from '../../features/image-modal' import { ClientOnly } from '#/shared' import { ArrowDownScroll } from '#/shared/ui/icon-components/scroll-down-arrow' -import { PreviewView } from '#/widgets/messages' -import { IsNextDay } from '#/widgets/messages/ui/is-next-day' +import { PreviewView } from './preview-view' +import { IsNextDay } from './is-next-day' import { makeThinScrollbar } from '#/shared/lib/constants/styles' interface IMessagesList { @@ -3,7 +3,7 @@ import { Box, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/mater import Dialog from '@mui/material/Dialog' import Image from 'next/image' -import { Markdown } from '#/widgets/markdown/markdown' +import { Markdown } from '../../lib/markdown/markdown' import { ZoomOutIcon as ZoomInIcon } from './icons/zoom-in-icon' import { ZoomOutIcon } from './icons/zoom-out-icon' @@ -0,0 +1,6 @@ +export * from './chat-messages-list' +export * from './is-next-day' +export * from './user-message' +export * from './bot-message' +export * from './preview-view' +export * from './fullscreen-message-modal' @@ -2,9 +2,9 @@ import React, { memo } from 'react' import { Box, Typography } from '@mui/material' import { Message } from '#/shared/lib/types/model' -import { getDateFromString } from '#/widgets/messages/lib/date-from-string' -import { getDayMontsString } from '#/widgets/messages/lib/day-months-string' -import { UserMessage } from '#/widgets/messages' +import { getDateFromString } from '../../lib/date-from-string' +import { getDayMontsString } from '../../lib/day-months-string' +import { UserMessage } from './user-message' interface IProps { message: Message @@ -6,9 +6,9 @@ import Image from 'next/image' import { TooltipCustom } from '#/shared' import { c, formatDate } from '#/shared/lib/helpers' import { Message } from '#/shared/lib/types/model' -import { BotMessage } from '#/widgets/messages' +import { BotMessage } from './bot-message' -import { Markdown } from '../../markdown/markdown' +import { Markdown } from '../../lib/markdown/markdown' import { FullscreenIcon } from './icons/fullscreen-icon' import { FullscreenMessageModal } from './fullscreen-message-modal' @@ -16,13 +16,14 @@ import { usePredictPrice } from '#/features/predict-price/model/use-predict-pric import Title from '#/features/title/title' import { TutorialContext } from '#/features/tutorial-context/tutorial-context' import { NextPageWithLayout } from '#/pages/_app' -import { DrawerCustom, useModel } from '#/shared' +import { DrawerCustom } from '#/shared' import model_api from '#/shared/api/models/api' import { getDeviceType, getOs } from '#/shared/lib/helpers' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { SvgIcon } from '#/shared/ui/svg' -import { AllChatWindow } from '#/widgets/chat-window' -import { ChatsContainer } from '#/widgets/chats/chats.container' +import { useChatModel } from '../../model' +import { AllChatWindow } from '../window' +import { ChatsContainer } from '../chats/chats.container' import { ModelApiView, StaticTabs } from '#/widgets/model-api-view' import styles from './chats-bot.module.scss' @@ -53,7 +54,12 @@ const Page: NextPageWithLayout = () => { const currentChat = useAppSelector(selectCurrentChat) - const { messages, sendMessage, loading, getMessagesPagination, deleteMessage } = useModel(currentChat, showMessage, modelType) + const { messages, sendMessage, loading, getMessagesPagination, deleteMessage } = useChatModel( + currentChat, + showMessage, + modelType, + botParams?.streaming === true + ) const includeParams = useAppSelector((state) => state.params.params) const dispatch = useDispatch() @@ -1,6 +1,5 @@ import React, { useCallback } from 'react' import { Stack } from '@mui/material' -import { ChatMessagesList } from '#/widgets/messages' import BlockedSvg from '#/assets/svg/blocked.svg?react' @@ -11,7 +10,8 @@ import { c } from '#/shared' import { Device, DeviceOs } from '#/shared/lib/types/entities' import { IModel, IModelInputs, IModelTag } from '#/entities/model-entity' import { Message } from '#/entities/message' -import { ModelInput } from '#/features/model-input' +import { ModelInput } from '../../features/model-input' +import { ChatMessagesList } from '../messages' interface Elements { element: React.ReactElement @@ -0,0 +1,4 @@ +export * from './ui/page' +export * from './ui/window' +export { ChatsContainer } from './ui/chats/chats.container' +export { useChatModel } from './model' @@ -1 +1 @@ -export * from './ui' \ No newline at end of file +export * from '#/widgets/chat-bot/ui/window' @@ -1,3 +1 @@ -export * from './date-from-string' -export * from './day-months-string' -export * from './form-data' \ No newline at end of file +export * from './form-data' @@ -1,9 +1,3 @@ export * from './image-messages-list' -export * from './chat-messages-list' export * from './answer-wrap' -export * from './bot-message' -export * from './preview-view' -export * from './user-message' -export * from './is-next-day' export * from './image-icons' -export * from './fullscreen-message-modal'