@@ -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' @@ -91,7 +91,9 @@ const chatsSlice = createSlice({ .addCase(fetchChatsByModel.fulfilled, (state, action) => { state.loading = false state.chats = action.payload.chats - state.currentChat = action.payload.currentChat + const hasCurrentChat = + state.currentChat != null && action.payload.chats.some((chat) => chat.uid === state.currentChat) + state.currentChat = hasCurrentChat ? state.currentChat : action.payload.currentChat }) .addCase(fetchChatsByModel.rejected, (state, action) => { state.loading = false @@ -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,102 @@ +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, offset: number, token: string, signal?: AbortSignal) => { + const baseUrl = `${getApiUrl() + '/api'}/chats/${chatUid}/messages/stream` + const url = offset > 0 ? `${baseUrl}?offset=${offset}` : baseUrl + + 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,26 @@ +import DsMarkdown, { ConfigProvider } from 'ds-markdown' + +import { ruRU } from './ds-markdown-locale' + +interface DsMarkdownContentProps { + content: string + streaming: boolean +} + +const STREAMING_INTERVAL = 20 + +export function DsMarkdownContent({ content, streaming }: DsMarkdownContentProps) { + return ( + + + {content} + + + ) +} @@ -0,0 +1,22 @@ +export const ruRU = { + codeBlock: { + copy: 'Копировать', + copied: 'Скопировано', + download: 'Скачать', + downloaded: 'Скачано', + }, + mermaid: { + diagram: 'Диаграмма', + code: 'Код', + zoomOut: 'Уменьшить', + zoomIn: 'Увеличить', + download: 'Скачать', + fullScreen: 'На весь экран', + exitFullScreen: 'Выйти из полноэкранного режима', + downloadImage: 'Скачать изображение', + downloadedImage: 'Скачано', + copyImage: 'Копировать изображение', + copiedImage: 'Скопировано', + fitInView: 'По размеру страницы', + }, +} as const @@ -0,0 +1,41 @@ +.markdown { + max-width: 100%; + margin-top: 12px; + + :global(.ds-markdown) { + background-color: transparent !important; + color: inherit; + font-size: inherit; + line-height: inherit; + min-height: 0; + padding: 0; + } + + :global(.ds-markdown) p, + :global(.ds-markdown) li, + :global(.ds-markdown) h1, + :global(.ds-markdown) h2, + :global(.ds-markdown) h3, + :global(.ds-markdown) h4, + :global(.ds-markdown) h5, + :global(.ds-markdown) h6, + :global(.ds-markdown) blockquote { + overflow-wrap: anywhere; + word-break: break-word; + } + + :global(.ds-markdown) table { + display: block; + max-width: 100%; + overflow-x: auto; + } + + :global(.md-code-block) { + max-width: 100%; + } + + :global(.md-code-block-banner) :global(.ds-button) { + font-family: 'Inter', sans-serif; + font-weight: 500; + } +} @@ -0,0 +1,25 @@ +import dynamic from 'next/dynamic' + +import styles from './markdown.module.scss' + +const DsMarkdownContent = dynamic(() => import('./ds-markdown-content').then((mod) => mod.DsMarkdownContent), { + ssr: false, +}) + +interface IProps { + id?: string + content: string + streaming?: boolean +} + +export const Markdown = ({ content, id = 'markdown', streaming = false }: IProps) => { + if (!content) { + return null + } + + return ( +
+ +
+ ) +} @@ -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 (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,871 @@ +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 + receivedStart: boolean + streamCompleted: boolean + streamFailed: boolean + streamErrored: boolean +} + +function getSseErrorDetail(data: unknown): string { + if (typeof data === 'string' && data.trim()) { + return data + } + + if (data && typeof data === 'object' && 'detail' in data && typeof (data as { detail?: unknown }).detail === 'string') { + return (data as { detail: string }).detail + } + + return 'Ошибка генерации' +} + +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 } + 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': { + handlers.onError(getSseErrorDetail(sseEvent.data)) + return + } + } + } +} + +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 [paginationLoading, setPaginationLoading] = 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, + receivedStart: false, + streamCompleted: false, + streamFailed: false, + streamErrored: 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, + receivedStart: false, + streamCompleted: false, + streamFailed: false, + streamErrored: 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.receivedStart = true + runtime.hasStreamEvents = true + + if (!messageUuid) { + return + } + + inputMessageUuidRef.current = messageUuid + + if (!runtime.modelContent) { + runtime.modelContent = getWaitingForModelContent(modelType) + } + + persistStreamSession(currentChat, messageUuid) + + const pendingUid = getStreamingModelUid(STREAMING_PENDING_UID) + const modelUid = getStreamingModelUid(messageUuid) + + setMessages((prev) => { + if (!prev) { + return prev + } + + const hasPlaceholder = prev.some( + (message) => message.uid === TEMP_USER_MESSAGE_UID || message.uid === pendingUid + ) + + if (options.isReconnect && !hasPlaceholder) { + return ensureModelMessageForStream(prev, messageUuid, modelType, runtime.modelContent) + } + + 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) => { + runtime.streamCompleted = true + + const inputUuid = inputMessageUuidRef.current + if (!inputUuid) { + return + } + + clearChatStreamSession(currentChat) + runtime.modelContent = content + + setMessages((prev) => { + if (!prev) { + return prev + } + + const modelUid = findModelMessageUid(prev, inputUuid) + return prev.map((message) => (message.uid === modelUid ? { ...message, content } : message)) + }) + }, + onError: (detail) => { + runtime.streamFailed = true + runtime.streamErrored = true + + const inputUuid = inputMessageUuidRef.current + clearChatStreamSession(currentChat) + setMessages((prev) => removeStreamingModelMessage(prev, inputUuid)) + + if (!runtime.receivedStart) { + markOptimisticUserMessageFailed(inputUuid) + } + + inputMessageUuidRef.current = null + showMessage(detail) + }, + }) + + const hasActiveSession = readChatStreamSession(currentChat) !== null + + return { + shouldReconnect: + runtime.streamFailed && !runtime.streamErrored && (runtime.hasStreamEvents || hasActiveSession), + streamFailed: runtime.streamFailed, + streamErrored: runtime.streamErrored, + missingStart: !runtime.receivedStart && !options.isReconnect && !runtime.streamCompleted && !runtime.streamErrored, + receivedStart: runtime.receivedStart, + streamCompleted: runtime.streamCompleted, + } + }, + [currentChat, markOptimisticUserMessageFailed, modelType, persistStreamSession, showMessage] + ) + + 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, + streamOffset, + data.access, + abortController.signal + ) + + if (response.status === 404) { + clearChatStreamSession(chatUid) + inputMessageUuidRef.current = null + setMessages((prev) => removeStreamingModelMessage(prev, inputMessageUuid)) + 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.streamErrored) { + resetStreamRuntime() + return false + } + + 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 && !result.streamErrored && !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 detected = detectInProgressStream(loadedMessages) + + if (!session && !detected) { + return + } + + const inputMessageUuid = + session?.inputMessageUuid && session.inputMessageUuid !== STREAMING_PENDING_UID + ? session.inputMessageUuid + : (detected?.inputMessageUuid ?? session?.inputMessageUuid ?? STREAMING_PENDING_UID) + + await reconnectToStream( + chatUid, + inputMessageUuid, + session?.lastOffset ?? detected?.lastOffset ?? 0, + loadedMessages, + session?.modelContent + ) + }, + [reconnectToStream] + ) + + const streamingRef = useRef(streaming) + streamingRef.current = streaming + const tryReconnectOnLoadRef = useRef(tryReconnectOnLoad) + tryReconnectOnLoadRef.current = tryReconnectOnLoad + const messagesRef = useRef(messages) + messagesRef.current = messages + const reconnectAttemptedForChatRef = useRef(null) + + useEffect(() => { + return () => { + abortRef.current?.abort() + } + }, []) + + useEffect(() => { + abortRef.current?.abort() + abortRef.current = null + isSendingRef.current = false + inputMessageUuidRef.current = null + resetStreamRuntime() + + if (!currentChat) { + setMessages([]) + setOffset(0) + reconnectAttemptedForChatRef.current = null + return + } + + setMessages(null) + setOffset(0) + reconnectAttemptedForChatRef.current = null + + let cancelled = false + + ;(async () => { + setLoading(true) + + try { + const answer = await chatMessagesApi.getMessages(currentChat, 0, data?.access) + if (cancelled) { + return + } + + if (!Array.isArray(answer)) { + showMessage('Ошибка загрузки чата') + setMessages([]) + return + } + + const loadedMessages = answer.reverse() + setMessages(loadedMessages) + setOffset(answer.length) + + if (streamingRef.current && data?.access) { + reconnectAttemptedForChatRef.current = currentChat + await tryReconnectOnLoadRef.current(currentChat, loadedMessages) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + })() + + return () => { + cancelled = true + } + }, [currentChat, data?.access, showMessage]) + + useEffect(() => { + if (!currentChat || !streaming || !data?.access) { + return + } + + const loadedMessages = messagesRef.current + if (loadedMessages === null) { + return + } + + if (reconnectAttemptedForChatRef.current === currentChat) { + return + } + + reconnectAttemptedForChatRef.current = currentChat + void tryReconnectOnLoad(currentChat, loadedMessages) + }, [currentChat, streaming, data?.access, tryReconnectOnLoad]) + + const getMessagesPagination = useCallback(async () => { + if (!currentChat || isSendingRef.current || paginationLoading) { + return + } + + setPaginationLoading(true) + + try { + const answer = await chatMessagesApi.getMessages(currentChat, offset, data?.access) + + if (!Array.isArray(answer) || answer.length === 0) { + return + } + + const newMessages = answer.reverse() + + setMessages((prev) => { + const existingUids = new Set(prev?.map((message) => message.uid) ?? []) + const toPrepend = newMessages.filter((message) => !existingUids.has(message.uid)) + + if (toPrepend.length === 0) { + return prev ?? null + } + + return [...toPrepend, ...(prev ?? [])] + }) + + setOffset((prev) => prev + answer.length) + } catch (error) { + console.error('[useChatModel] getMessagesPagination: request failed', { chatUid: currentChat, error }) + showMessage('Ошибка загрузки сообщений') + } finally { + setPaginationLoading(false) + } + }, [currentChat, data?.access, offset, paginationLoading, 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 { + console.error('[useChatModel] sendMessage: HTTP error without detail', { + chatUid: currentChat, + status, + result, + }) + showMessage('Непредвиденная ошибка, попробуйте еще раз') + } + } else { + setMessages((prev) => [...(prev ?? []), ...(result as Message[])]) + } + } catch (error) { + console.error('[useChatModel] sendMessage: request failed', { chatUid: currentChat, error }) + 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), + ]) + + writeChatStreamSession(currentChat, { + inputMessageUuid: STREAMING_PENDING_UID, + lastOffset: 0, + modelContent: getWaitingForModelContent(modelType), + }) + + try { + const response = await chatMessagesApi.sendMessageStream(currentChat, requestBody, data.access, abortController.signal) + + if (!response.ok) { + clearChatStreamSession(currentChat) + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + showMessage(await parseStreamErrorResponse(response)) + return + } + + if (!response.body) { + clearChatStreamSession(currentChat) + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + showMessage('Пустой ответ сервера') + return + } + + const result = await processMessageStream(response) + + if (result.streamErrored) { + resetStreamRuntime() + return + } + + if (result.shouldReconnect) { + const inputUuid = + inputMessageUuidRef.current ?? readChatStreamSession(currentChat)?.inputMessageUuid ?? STREAMING_PENDING_UID + + await reconnectToStream( + currentChat, + inputUuid, + streamRuntimeRef.current.lastOffset, + undefined, + streamRuntimeRef.current.modelContent, + { silent: true } + ) + return + } + + if (result.missingStart) { + console.error('[useChatModel] sendMessageStream: missing start event', { + chatUid: currentChat, + result, + inputMessageUuid: inputMessageUuidRef.current, + }) + clearChatStreamSession(currentChat) + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + showMessage('Непредвиденная ошибка, попробуйте еще раз') + } + } catch (error) { + if (abortController.signal.aborted) { + return + } + + const session = readChatStreamSession(currentChat) + if (session) { + const inputUuid = inputMessageUuidRef.current ?? session.inputMessageUuid + await reconnectToStream(currentChat, inputUuid, session.lastOffset, undefined, session.modelContent, { + silent: true, + }) + return + } + + markOptimisticUserMessageFailed() + setMessages((prev) => removeStreamingModelMessage(prev)) + console.error('[useChatModel] sendMessageStream: request failed', { + chatUid: currentChat, + error, + inputMessageUuid: inputMessageUuidRef.current, + streamRuntime: streamRuntimeRef.current, + }) + 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, paginationLoading, 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' @@ -77,9 +77,10 @@ export function BotMessage(props: any) { @@ -111,7 +112,7 @@ export function BotMessage(props: any) { })} - + { if (props.message.file) { @@ -120,22 +121,20 @@ export function BotMessage(props: any) { }} className='smallScroll' sx={{ + flex: 1, + minWidth: 0, overflowY: 'auto', + overflowX: 'hidden', position: 'relative', - padding: '15px 23px', + padding: '0px 16px', border: `1px solid #303035`, - color: '#A6A5A5', + color: '#f9fafb', boxShadow: 'none', lineHeight: '22.5px', fontSize: '15px', - marginTop: 0.5, textAlign: 'left', fontFamily: 'Inter,sans-serif', borderRadius: '13px', - '& p': { - color: 'inherit', - fontStyle: 'inherit', - }, ...flexStyle, }} > @@ -151,9 +150,12 @@ export function BotMessage(props: any) { ) : ( '' )} - + - + void onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void loading: boolean + paginationLoading?: boolean } export const ChatMessagesList: React.FC = memo( - ({ messageResponse, onLoadImage, setResendValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading }) => { - const paginationScroll = React.useRef(null) - const [isPaginating, setIsPaginating] = React.useState(false) - const [chatScrollHeight, setChatScrollHeight] = React.useState(0) + ({ messageResponse, onLoadImage, setResendValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => { + const paginationScroll = React.useRef(null) const [scrollBottom, setScrollBottom] = React.useState(0) + const prevMessagesSnapshotRef = useRef<{ + length: number + firstUid?: string + lastUid?: string + lastContentLength: number + } | null>(null) + const scrollAnchorRef = useRef<{ + uid: string + topOffset: number + scrollTop: number + scrollHeight: number + } | null>(null) + const prependRestoreObserverRef = useRef(null) + const prependRestoreTimeoutRef = useRef | null>(null) + const initialScrollObserverRef = useRef(null) + const initialScrollTimeoutRef = useRef | null>(null) + const isRestoringScrollRef = useRef(false) + const scrollBottomRef = useRef(0) + const lastSeenLastUidRef = useRef(null) + + const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => + el.getBoundingClientRect().top - block.getBoundingClientRect().top + + const captureScrollAnchor = (block: HTMLDivElement) => { + const anchorUid = messageResponse?.[0]?.uid + if (!anchorUid) { + return + } + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchorUid)}"]`) as HTMLElement | null + + scrollAnchorRef.current = { + uid: anchorUid, + topOffset: el ? getMessageTopInViewport(block, el) : 0, + scrollTop: block.scrollTop, + scrollHeight: block.scrollHeight, + } + } + + const scrollToBottom = (block: HTMLDivElement, persist = false) => { + const apply = () => { + block.scrollTop = block.scrollHeight + } + + apply() + + if (!persist) { + return + } + + initialScrollObserverRef.current?.disconnect() + if (initialScrollTimeoutRef.current) { + clearTimeout(initialScrollTimeoutRef.current) + } + + const observer = new ResizeObserver(apply) + initialScrollObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(apply) + }) + + initialScrollTimeoutRef.current = setTimeout(() => { + observer.disconnect() + initialScrollObserverRef.current = null + }, 3000) + } + + const restorePrependScroll = (block: HTMLDivElement) => { + const anchor = scrollAnchorRef.current + if (!anchor) { + return + } + + const apply = () => { + block.scrollTop = anchor.scrollTop + (block.scrollHeight - anchor.scrollHeight) + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchor.uid)}"]`) as HTMLElement | null + if (el) { + const currentTop = getMessageTopInViewport(block, el) + block.scrollTop = block.scrollTop + currentTop - anchor.topOffset + } + + return true + } + + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + + isRestoringScrollRef.current = true + apply() + + const observer = new ResizeObserver(() => { + apply() + }) + prependRestoreObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(() => { + apply() + isRestoringScrollRef.current = false + }) + }) + + prependRestoreTimeoutRef.current = setTimeout(() => { + observer.disconnect() + prependRestoreObserverRef.current = null + scrollAnchorRef.current = null + isRestoringScrollRef.current = false + }, 3000) + } const desktop = device === 'desktop' const { status } = useSession() @@ -54,47 +170,111 @@ export const ChatMessagesList: React.FC = memo( .filter((el) => el !== null) as Message[] }, [messageResponse]) - React.useEffect(() => { + useEffect(() => { + return () => { + prependRestoreObserverRef.current?.disconnect() + initialScrollObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + if (initialScrollTimeoutRef.current) { + clearTimeout(initialScrollTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!messageResponse?.length) { + prevMessagesSnapshotRef.current = null + lastSeenLastUidRef.current = null + scrollAnchorRef.current = null + initialScrollObserverRef.current?.disconnect() + if (initialScrollTimeoutRef.current) { + clearTimeout(initialScrollTimeoutRef.current) + } + } + }, [messageResponse]) + + useLayoutEffect(() => { + if (!messageResponse?.length) { + return + } + const block = paginationScroll.current + if (!block) { + return + } - if (messageResponse != undefined && !isPaginating) { - setChatScrollHeight(paginationScroll.current.scrollHeight) + const snapshot = { + length: messageResponse.length, + firstUid: messageResponse[0]?.uid, + lastUid: messageResponse[messageResponse.length - 1]?.uid, + lastContentLength: messageResponse[messageResponse.length - 1]?.content?.length ?? 0, + } + const prev = prevMessagesSnapshotRef.current - const time = setTimeout(() => { - if (block) { - //@ts-ignore - block.scrollTo({ - top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку - }) - } - }, 250) - return () => clearTimeout(time) - } else if (messageResponse != undefined && isPaginating) { - if (block) { - //@ts-ignore - block.scrollTop = block.scrollHeight - chatScrollHeight - setChatScrollHeight(paginationScroll.current.scrollHeight) - } + if (!prev) { + scrollToBottom(block, true) + prevMessagesSnapshotRef.current = snapshot + return + } + + const prepended = + snapshot.length > prev.length && snapshot.lastUid === prev.lastUid + + const appended = + snapshot.lastUid !== prev.lastUid && snapshot.length >= prev.length && !prepended + + const structureSame = + snapshot.length === prev.length && + snapshot.firstUid === prev.firstUid && + snapshot.lastUid === prev.lastUid + + const streamingGrowth = + structureSame && + snapshot.lastContentLength > prev.lastContentLength && + Boolean(snapshot.lastUid?.startsWith('streaming:')) + + if (prepended && scrollAnchorRef.current) { + restorePrependScroll(block) + } else if (appended) { + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } else if (streamingGrowth && scrollBottomRef.current < 200) { + block.scrollTop = block.scrollHeight } - setIsPaginating(false) + + prevMessagesSnapshotRef.current = snapshot }, [messageResponse]) useEffect(() => { - setIsNewMessage(true) + const lastUid = messageResponse?.[messageResponse.length - 1]?.uid + if (!lastUid) { + return + } + + if (lastSeenLastUidRef.current && lastSeenLastUidRef.current !== lastUid) { + setIsNewMessage(true) + } + + lastSeenLastUidRef.current = lastUid }, [messageResponse]) const handleScroll = () => { - setScrollBottom(paginationScroll.current?.scrollHeight - paginationScroll.current?.scrollTop - paginationScroll.current?.clientHeight) - - if (paginationScroll.current && messageResponse?.length !== 0) { - const { scrollTop, scrollHeight, clientHeight } = paginationScroll.current - if (scrollTop === 0) { - if (getMessagesPagination) { - setIsPaginating(true) - getMessagesPagination() - } - } + const block = paginationScroll.current + if (!block) { + return + } + + const distanceFromBottom = block.scrollHeight - block.scrollTop - block.clientHeight + scrollBottomRef.current = distanceFromBottom + setScrollBottom(distanceFromBottom) + + if (messageResponse?.length && block.scrollTop === 0 && getMessagesPagination && !paginationLoading && !isRestoringScrollRef.current) { + captureScrollAnchor(block) + void getMessagesPagination() } } @@ -141,12 +321,14 @@ export const ChatMessagesList: React.FC = memo( zIndex: 10, }} onClick={() => { - //@ts-ignore const block = paginationScroll.current + if (!block) { + return + } block.scrollTo({ top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку + behavior: 'smooth', }) }} > @@ -154,7 +336,7 @@ export const ChatMessagesList: React.FC = memo( )} - {loading && ( + {paginationLoading && ( = memo( <>{desktop && modelType !== 'deepl' && } ) : ( messageResponse?.map((message, idx) => { + const isStreaming = + loading && + !paginationLoading && + message.from_model && + message.uid.startsWith('streaming:') && + idx === messageResponse.length - 1 + return ( = memo( modelTitle={modelTitle} deleteMessage={deleteMessage} device={device} + isStreaming={isStreaming} /> ) }) @@ -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 @@ -19,6 +19,7 @@ interface IProps { device: 'mobile' | 'desktop' setResendValue: (value: string) => void onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void + isStreaming?: boolean } export const IsNextDay = memo( @@ -35,6 +36,7 @@ export const IsNextDay = memo( modelType, device, setResendValue, + isStreaming, }: IProps) => { if (messageResponse && message.created_at) { const date = getDateFromString(message.created_at) @@ -72,6 +74,7 @@ export const IsNextDay = memo( isNewMessage={isNewMessage} modelType={modelType} device={device} + isStreaming={isStreaming} /> ) @@ -108,6 +111,7 @@ export const IsNextDay = memo( isNewMessage={isNewMessage} modelType={modelType} device={device} + isStreaming={isStreaming} /> ) @@ -126,6 +130,7 @@ export const IsNextDay = memo( isNewMessage={isNewMessage} modelType={modelType} device={device} + isStreaming={isStreaming} /> ) } @@ -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' @@ -26,6 +26,7 @@ interface IMessagesList { modelTitle: string | undefined setResendValue: (value: string) => void onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void + isStreaming?: boolean } export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setModal, ...props }: IMessagesList) { @@ -85,7 +86,7 @@ export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setM return ( <> - + {!props.message.from_model ? ( )} @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react' import { useDispatch } from 'react-redux' -import { Box, Collapse, Stack, Typography } from '@mui/material' +import { Box, Collapse, CircularProgress, Stack, Typography } from '@mui/material' import Head from 'next/head' import { useRouter } from 'next/router' import { useSession } from 'next-auth/react' @@ -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,13 +54,24 @@ const Page: NextPageWithLayout = () => { const currentChat = useAppSelector(selectCurrentChat) - const { messages, sendMessage, loading, getMessagesPagination, deleteMessage } = useModel(currentChat, showMessage, modelType) + const { messages, sendMessage, loading, paginationLoading, getMessagesPagination, deleteMessage } = useChatModel( + currentChat, + showMessage, + modelType, + botParams?.streaming === true + ) const includeParams = useAppSelector((state) => state.params.params) const dispatch = useDispatch() const deleteMessageMemo = useCallback(deleteMessage, [currentChat, messages]) + const chatWindowHeight = desktop + ? '75vh' + : `calc(100dvh - ${(botParams?.tags ?? []).length === 0 ? '200px' : '285px'})` + + const isMessagesLoading = messages === null + React.useEffect(() => { if (data?.access) { model_api.getBotParams(router.asPath.split('/')[2], data.access).then((res) => { @@ -257,29 +269,52 @@ const Page: NextPageWithLayout = () => { - + {isMessagesLoading ? ( + + + + ) : ( + + )} { openMobileFilters: () => void setting?: T loading: boolean + paginationLoading?: boolean file?: any setFile: React.Dispatch> isCalculating: boolean @@ -49,6 +50,7 @@ export interface ChatProps { function Chat({ device, loading, + paginationLoading, messages, modelType, sendMessage, @@ -126,6 +128,7 @@ function Chat({ deleteMessage={deleteMessage} modelTitle={modelTitle} loading={loading} + paginationLoading={paginationLoading} />
@@ -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 @@ -.inlineCode { - border-radius: 15px; -} + @@ -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' @@ -0,0 +1,413 @@ +export interface StreamingCodeBlock { + id: string + title: string + code: string + language: 'python' | 'bash' +} + +interface StreamingBlocksParams { + modelsType: string + modelSlug: string + currentVersion: string + formattedModelParams: string + showFileExample: boolean + prefix: string +} + +const SSE_LOOP_HTTPX = ` buffer = "" + for chunk in response.iter_bytes(): + buffer += chunk.decode() + while "\\n\\n" in buffer: + message, buffer = buffer.split("\\n\\n", 1) + event = data = None + for line in message.splitlines(): + if line.startswith("event:"): + event = line[6:].strip() + elif line.startswith("data:"): + data = json.loads(line[5:].strip()) + if event == "token": + print(data["content"], end="", flush=True) + elif event == "error": + print(data["detail"])` + +const SSE_LOOP_REQUESTS = ` buffer = "" + for chunk in response.iter_content(chunk_size=1024): + buffer += chunk.decode() + while "\\n\\n" in buffer: + message, buffer = buffer.split("\\n\\n", 1) + event = data = None + for line in message.splitlines(): + if line.startswith("event:"): + event = line[6:].strip() + elif line.startswith("data:"): + data = json.loads(line[5:].strip()) + if event == "token": + print(data["content"], end="", flush=True) + elif event == "error": + print(data["detail"])` + +function toCompactInfoJson(formattedModelParams: string) { + const body = formattedModelParams + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('') + + return `{${body}}` +} + +function indentCode(code: string, spaces: number) { + const padding = ' '.repeat(spaces) + return code + .split('\n') + .map((line) => (line ? `${padding}${line}` : line)) + .join('\n') +} + +function getReconnectTitle(showFileExample: boolean) { + return showFileExample ? '3. Переподключение к стриму' : '2. Переподключение к стриму' +} + +export function getHttpxStreamingBlocks({ + modelsType, + modelSlug, + formattedModelParams, + showFileExample, + prefix, +}: StreamingBlocksParams): StreamingCodeBlock[] { + const url = `https://api.air.fail/public/${modelsType}/${modelSlug}/stream` + + const streamCode = `import httpx +import json + +url = "${url}" +api_key = "" +idempotency_key = "stream-example-1" # уникальный ID запроса; необходим для переподключения к стриму + +form_data = { + "content": "Привет! Как дела?", + "info": json.dumps({ + ${formattedModelParams} + }), +} +headers = { + "Authorization": api_key, + "Idempotency-Key": idempotency_key, +} + +with httpx.stream("POST", url, json=form_data, headers=headers, timeout=120) as response: +${SSE_LOOP_HTTPX}` + + const streamFileCode = `import httpx +import json + +url = "${url}" +api_key = "" +idempotency_key = "stream-example-1" # уникальный ID запроса; необходим для переподключения к стриму +filename = "example.png" + +form_data = { + "content": "Привет, что на картинке?", + "info": json.dumps({ + ${formattedModelParams} + }), +} +headers = { + "Authorization": api_key, + "Idempotency-Key": idempotency_key, +} + +with open(filename, "rb") as f: + files = {"file": (filename, f)} + with httpx.stream("POST", url, data=form_data, files=files, headers=headers, timeout=120) as response: +${indentCode(SSE_LOOP_HTTPX, 4)}` + + const reconnectCode = `import httpx +import json + +url = "${url}" +api_key = "" +idempotency_key = "stream-example-1" # тот же ключ, что при POST; необходим для переподключения +offset = 10 # номер последнего полученного event; стрим продолжится с этого места + +headers = { + "Authorization": api_key, + "Idempotency-Key": idempotency_key, +} + +with httpx.stream("GET", url, params={"offset": offset}, headers=headers, timeout=120) as response: +${SSE_LOOP_HTTPX}` + + const blocks: StreamingCodeBlock[] = [ + { id: `${prefix}-stream-1`, title: '1. Обычный стриминг', code: streamCode, language: 'python' }, + ] + + if (showFileExample) { + blocks.push({ + id: `${prefix}-stream-2`, + title: '2. Стриминг с прикреплённым файлом', + code: streamFileCode, + language: 'python', + }) + } + + blocks.push({ + id: `${prefix}-stream-3`, + title: getReconnectTitle(showFileExample), + code: reconnectCode, + language: 'python', + }) + + return blocks +} + +export function getRequestsStreamingBlocks({ + modelsType, + modelSlug, + formattedModelParams, + showFileExample, + prefix, +}: StreamingBlocksParams): StreamingCodeBlock[] { + const url = `https://api.air.fail/public/${modelsType}/${modelSlug}/stream` + + const streamCode = `import requests +import json + +url = "${url}" +api_key = "" +idempotency_key = "stream-example-1" # уникальный ID запроса; необходим для переподключения к стриму + +form_data = { + "content": "Привет! Как дела?", + "info": json.dumps({ + ${formattedModelParams} + }), +} +headers = { + "Authorization": api_key, + "Idempotency-Key": idempotency_key, +} + +with requests.post(url, json=form_data, headers=headers, stream=True, timeout=120) as response: +${SSE_LOOP_REQUESTS}` + + const streamFileCode = `import requests +import json + +url = "${url}" +api_key = "" +idempotency_key = "stream-example-1" # уникальный ID запроса; необходим для переподключения к стриму +filename = "example.png" + +form_data = { + "content": "Привет, что на картинке?", + "info": json.dumps({ + ${formattedModelParams} + }), +} +headers = { + "Authorization": api_key, + "Idempotency-Key": idempotency_key, +} + +with open(filename, "rb") as f: + files = {"file": (filename, f)} + with requests.post(url, data=form_data, files=files, headers=headers, stream=True, timeout=120) as response: +${indentCode(SSE_LOOP_REQUESTS, 4)}` + + const reconnectCode = `import requests +import json + +url = "${url}" +api_key = "" +idempotency_key = "stream-example-1" # тот же ключ, что при POST; необходим для переподключения +offset = 10 # номер последнего полученного event; стрим продолжится с этого места + +headers = { + "Authorization": api_key, + "Idempotency-Key": idempotency_key, +} + +with requests.get(url, params={"offset": offset}, headers=headers, stream=True, timeout=120) as response: +${SSE_LOOP_REQUESTS}` + + const blocks: StreamingCodeBlock[] = [ + { id: `${prefix}-stream-1`, title: '1. Обычный стриминг', code: streamCode, language: 'python' }, + ] + + if (showFileExample) { + blocks.push({ + id: `${prefix}-stream-2`, + title: '2. Стриминг с прикреплённым файлом', + code: streamFileCode, + language: 'python', + }) + } + + blocks.push({ + id: `${prefix}-stream-3`, + title: getReconnectTitle(showFileExample), + code: reconnectCode, + language: 'python', + }) + + return blocks +} + +export function getOpenAiStreamingBlocks({ + currentVersion, + formattedModelParams, + showFileExample, + prefix, +}: StreamingBlocksParams): StreamingCodeBlock[] { + const streamCode = `from openai import OpenAI + +client = OpenAI( + base_url="https://api.air.fail/public/openai", + api_key="", + default_headers={ + "Idempotency-Key": "stream-example-1", # уникальный ID запроса; необходим для переподключения к стриму + }, +) + +for event in client.responses.create( + model="${currentVersion}", + instructions="Ты умный ассистент", + input="Напиши короткий тост на день рождения", + stream=True, + metadata={ + ${formattedModelParams} + }, +): + print(event.model_dump_json(warnings=False))` + + const streamFileCode = `import base64 + +from openai import OpenAI + +client = OpenAI( + base_url="https://api.air.fail/public/openai", + api_key="", + default_headers={ + "Idempotency-Key": "stream-example-1", # уникальный ID запроса; необходим для переподключения к стриму + }, +) +filename = "example.png" + +with open(filename, "rb") as f: + image_base64 = base64.b64encode(f.read()).decode("utf-8") + +for event in client.responses.create( + model="${currentVersion}", + input=[ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Опиши, что изображено на картинке."}, + { + "type": "input_image", + "image_url": f"data:image/png;base64,{image_base64}", + }, + ], + } + ], + stream=True, + metadata={ + ${formattedModelParams} + }, +): + print(event.model_dump_json(warnings=False))` + + const reconnectCode = `from openai import OpenAI + +client = OpenAI( + base_url="https://api.air.fail/public/openai", + api_key="", + default_headers={ + "Idempotency-Key": "stream-example-1", # тот же ключ, что при POST; необходим для переподключения + }, +) + +with client.responses.stream( + response_id="stream-example-1", + starting_after=0, # номер последнего полученного event; стрим продолжится с этого места +) as stream: + for event in stream: + print(event.model_dump_json(warnings=False))` + + const blocks: StreamingCodeBlock[] = [ + { id: `${prefix}-stream-1`, title: '1. Обычный стриминг', code: streamCode, language: 'python' }, + ] + + if (showFileExample) { + blocks.push({ + id: `${prefix}-stream-2`, + title: '2. Стриминг с прикреплённым файлом', + code: streamFileCode, + language: 'python', + }) + } + + blocks.push({ + id: `${prefix}-stream-3`, + title: getReconnectTitle(showFileExample), + code: reconnectCode, + language: 'python', + }) + + return blocks +} + +export function getCurlStreamingBlocks({ + modelsType, + modelSlug, + formattedModelParams, + showFileExample, + prefix, +}: StreamingBlocksParams): StreamingCodeBlock[] { + const url = `https://api.air.fail/public/${modelsType}/${modelSlug}/stream` + const infoJson = toCompactInfoJson(formattedModelParams) + + const streamCode = `# Idempotency-Key — уникальный ID запроса; необходим для переподключения +curl -N -X POST "${url}" \\ + -H "Authorization: " \\ + -H "Idempotency-Key: stream-example-1" \\ + -F "content=Привет! Как дела?" \\ + -F 'info=${infoJson}'` + + const streamFileCode = `# Idempotency-Key — уникальный ID запроса; необходим для переподключения +curl -N -X POST "${url}" \\ + -H "Authorization: " \\ + -H "Idempotency-Key: stream-example-1" \\ + -F "content=Привет, что на картинке?" \\ + -F 'info=${infoJson}' \\ + -F "file=@example.png"` + + const reconnectCode = `# Idempotency-Key — тот же ключ, что при POST; необходим для переподключения +# offset — номер последнего полученного event; стрим продолжится с этого места +curl -N -X GET "${url}?offset=10" \\ + -H "Authorization: " \\ + -H "Idempotency-Key: stream-example-1"` + + const blocks: StreamingCodeBlock[] = [ + { id: `${prefix}-stream-1`, title: '1. Обычный стриминг', code: streamCode, language: 'bash' }, + ] + + if (showFileExample) { + blocks.push({ + id: `${prefix}-stream-2`, + title: '2. Стриминг с прикреплённым файлом', + code: streamFileCode, + language: 'bash', + }) + } + + blocks.push({ + id: `${prefix}-stream-3`, + title: getReconnectTitle(showFileExample), + code: reconnectCode, + language: 'bash', + }) + + return blocks +} @@ -6,7 +6,25 @@ import { IComponentProps } from '../types' import { Markdown } from '#/widgets/markdown/markdown' -const CodeBlock = ({ title, code, id }: { title: string; code: string; id: string }) => { +import { + getCurlStreamingBlocks, + getHttpxStreamingBlocks, + getOpenAiStreamingBlocks, + getRequestsStreamingBlocks, + StreamingCodeBlock, +} from './scope-variants-streaming' + +const CodeBlock = ({ + title, + code, + id, + language = 'python', +}: { + title: string + code: string + id: string + language?: 'python' | 'bash' +}) => { const copy = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -86,20 +104,35 @@ const CodeBlock = ({ title, code, id }: { title: string; code: string; id: strin /> - + ) } -export const PythonHTTPX = ({ currentVersion, modelSlug, modelsType, showFileExample, modelParams }: IComponentProps) => { - const formattedModelParams = Object.entries({ +function StreamingExamples({ blocks, sectionId }: { blocks: StreamingCodeBlock[]; sectionId: string }) { + return ( + + + {blocks.map((block) => ( + + ))} + + ) +} + +function formatModelParams(currentVersion: string, modelParams: IComponentProps['modelParams']) { + return Object.entries({ ...(currentVersion && { version: currentVersion }), - ...modelParams + ...modelParams, }) .filter(([_, value]) => value !== undefined) .map(([key, value]) => `"${key}": ${typeof value === 'string' ? `"${value}"` : value}`) .join(',\n ') +} + +export const PythonHTTPX = ({ currentVersion, modelSlug, modelsType, showFileExample, modelParams }: IComponentProps) => { + const formattedModelParams = formatModelParams(currentVersion, modelParams) const code1 = `import httpx import json @@ -143,18 +176,25 @@ print(response.json())` {showFileExample && } + {modelsType === 'text' && ( + + )} ) } export const PythonRequests = ({ currentVersion, modelSlug, modelsType, showFileExample, modelParams }: IComponentProps) => { - const formattedModelParams = Object.entries({ - ...(currentVersion && { version: currentVersion }), - ...modelParams - }) - .filter(([_, value]) => value !== undefined) - .map(([key, value]) => `"${key}": ${typeof value === 'string' ? `"${value}"` : value}`) - .join(',\n ') + const formattedModelParams = formatModelParams(currentVersion, modelParams) const code1 = `import requests import json @@ -196,11 +236,26 @@ print(response.json())` {showFileExample && } + {modelsType === 'text' && ( + + )} ) } export const PythonOpenAISDK = ({ currentVersion, modelSlug, modelsType, showFileExample, modelParams }: IComponentProps) => { + const formattedModelParams = formatModelParams(currentVersion, modelParams) + const code1 = `from openai import OpenAI client = OpenAI( @@ -252,18 +307,25 @@ print(response)` {showFileExample && } + {modelsType === 'text' && ( + + )} ) } export const cURL = ({ currentVersion, modelSlug, modelsType, showFileExample, modelParams }: IComponentProps) => { - const formattedModelParams = Object.entries({ - ...(currentVersion && { version: currentVersion }), - ...modelParams - }) - .filter(([_, value]) => value !== undefined) - .map(([key, value]) => `"${key}": ${typeof value === 'string' ? `"${value}"` : value}`) - .join(',\n ') + const formattedModelParams = formatModelParams(currentVersion, modelParams) const code1 = `curl -X POST "https://api.air.fail/public/${modelsType}/${modelSlug}" -H "Authorization: " @@ -282,8 +344,21 @@ export const cURL = ({ currentVersion, modelSlug, modelsType, showFileExample, m return ( - - {showFileExample && } + + {showFileExample && } + {modelsType === 'text' && ( + + )} ) } \ No newline at end of file @@ -54,6 +54,7 @@ "cookie": "^0.5.0", "cross-env": "^7.0.3", "dayjs": "^1.11.8", + "ds-markdown": "^1.2.0", "eslint-plugin-simple-import-sort": "^10.0.0", "framer-motion": "^12.23.12", "i18next": "^23.4.1", @@ -3991,6 +3991,13 @@ __metadata: languageName: node linkType: hard +"@types/katex@npm:^0.16.0": + version: 0.16.8 + resolution: "@types/katex@npm:0.16.8" + checksum: 10c0/0661609353f4f5e62bd2dc78da99e842761c6474b19f2268b195bbe9dbf20e6f766a31155d79eec2e7c3eff4e7eba4b30f4f519e9c6a11c75bb45e257a2ddb69 + languageName: node + linkType: hard + "@types/lodash.debounce@npm:^4.0.9": version: 4.0.9 resolution: "@types/lodash.debounce@npm:4.0.9" @@ -5260,6 +5267,13 @@ __metadata: languageName: node linkType: hard +"classnames@npm:^2.5.1": + version: 2.5.1 + resolution: "classnames@npm:2.5.1" + checksum: 10c0/afff4f77e62cea2d79c39962980bf316bacb0d7c49e13a21adaadb9221e1c6b9d3cdb829d8bb1b23c406f4e740507f37e1dcf506f7e3b7113d17c5bab787aa69 + languageName: node + linkType: hard + "cli-cursor@npm:^5.0.0": version: 5.0.0 resolution: "cli-cursor@npm:5.0.0" @@ -5378,6 +5392,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^8.3.0": + version: 8.3.0 + resolution: "commander@npm:8.3.0" + checksum: 10c0/8b043bb8322ea1c39664a1598a95e0495bfe4ca2fad0d84a92d7d1d8d213e2a155b441d2470c8e08de7c4a28cf2bc6e169211c49e1b21d9f7edc6ae4d9356060 + languageName: node + linkType: hard + "commondir@npm:^1.0.1": version: 1.0.1 resolution: "commondir@npm:1.0.1" @@ -5924,6 +5945,27 @@ __metadata: languageName: node linkType: hard +"ds-markdown@npm:^1.2.0": + version: 1.2.0 + resolution: "ds-markdown@npm:1.2.0" + dependencies: + classnames: "npm:^2.5.1" + katex: "npm:^0.16.22" + react-markdown: "npm:^10.1.0" + react-markdown-typer: "npm:1.0.5" + react-syntax-highlighter: "npm:^15.6.1" + rehype-katex: "npm:^7.0.1" + remark-gfm: "npm:^4.0.1" + remark-math: "npm:^6.0.0" + unified: "npm:^11.0.5" + unist-util-visit: "npm:^5.0.0" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/100d53f26be8d2260681e167a7616d68e685de0541599fa06ec55c5d6c00be305f5f5cc8d380f867026966d5c10d6a5f7b18ab2e487a06c215af25d6c9bc7bb1 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -7141,6 +7183,68 @@ __metadata: languageName: node linkType: hard +"hast-util-from-dom@npm:^5.0.0": + version: 5.0.1 + resolution: "hast-util-from-dom@npm:5.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hastscript: "npm:^9.0.0" + web-namespaces: "npm:^2.0.0" + checksum: 10c0/9a90381e048107a093a3da758bb17b67aaf5322e222f02497f841c4990abf94aa177d38d5b9bf61ad07b3601d0409f34f5b556d89578cc189230c6b994d2af77 + languageName: node + linkType: hard + +"hast-util-from-html-isomorphic@npm:^2.0.0": + version: 2.0.0 + resolution: "hast-util-from-html-isomorphic@npm:2.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-from-dom: "npm:^5.0.0" + hast-util-from-html: "npm:^2.0.0" + unist-util-remove-position: "npm:^5.0.0" + checksum: 10c0/fc68d9245e794483a802d5c85a9f6c25959e00db78cc796411efc965134f3206f9cc9fa38134572ea781ad74663e801f1f83202007b208e27a770855566a62b6 + languageName: node + linkType: hard + +"hast-util-from-html@npm:^2.0.0": + version: 2.0.3 + resolution: "hast-util-from-html@npm:2.0.3" + dependencies: + "@types/hast": "npm:^3.0.0" + devlop: "npm:^1.1.0" + hast-util-from-parse5: "npm:^8.0.0" + parse5: "npm:^7.0.0" + vfile: "npm:^6.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/993ef707c1a12474c8d4094fc9706a72826c660a7e308ea54c50ad893353d32e139b7cbc67510c2e82feac572b320e3b05aeb13d0f9c6302d61261f337b46764 + languageName: node + linkType: hard + +"hast-util-from-parse5@npm:^8.0.0": + version: 8.0.3 + resolution: "hast-util-from-parse5@npm:8.0.3" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + devlop: "npm:^1.0.0" + hastscript: "npm:^9.0.0" + property-information: "npm:^7.0.0" + vfile: "npm:^6.0.0" + vfile-location: "npm:^5.0.0" + web-namespaces: "npm:^2.0.0" + checksum: 10c0/40ace6c0ad43c26f721c7499fe408e639cde917b2350c9299635e6326559855896dae3c3ebf7440df54766b96c4276a7823e8f376a2b6a28b37b591f03412545 + languageName: node + linkType: hard + +"hast-util-is-element@npm:^3.0.0": + version: 3.0.0 + resolution: "hast-util-is-element@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/f5361e4c9859c587ca8eb0d8343492f3077ccaa0f58a44cd09f35d5038f94d65152288dcd0c19336ef2c9491ec4d4e45fde2176b05293437021570aa0bc3613b + languageName: node + linkType: hard + "hast-util-parse-selector@npm:^2.0.0": version: 2.2.5 resolution: "hast-util-parse-selector@npm:2.2.5" @@ -7148,6 +7252,15 @@ __metadata: languageName: node linkType: hard +"hast-util-parse-selector@npm:^4.0.0": + version: 4.0.0 + resolution: "hast-util-parse-selector@npm:4.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 10c0/5e98168cb44470dc274aabf1a28317e4feb09b1eaf7a48bbaa8c1de1b43a89cd195cb1284e535698e658e3ec26ad91bc5e52c9563c36feb75abbc68aaf68fb9f + languageName: node + linkType: hard + "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.6 resolution: "hast-util-to-jsx-runtime@npm:2.3.6" @@ -7171,6 +7284,18 @@ __metadata: languageName: node linkType: hard +"hast-util-to-text@npm:^4.0.0": + version: 4.0.2 + resolution: "hast-util-to-text@npm:4.0.2" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + unist-util-find-after: "npm:^5.0.0" + checksum: 10c0/93ecc10e68fe5391c6e634140eb330942e71dea2724c8e0c647c73ed74a8ec930a4b77043b5081284808c96f73f2bee64ee416038ece75a63a467e8d14f09946 + languageName: node + linkType: hard + "hast-util-whitespace@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-whitespace@npm:3.0.0" @@ -7193,6 +7318,19 @@ __metadata: languageName: node linkType: hard +"hastscript@npm:^9.0.0": + version: 9.0.1 + resolution: "hastscript@npm:9.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-parse-selector: "npm:^4.0.0" + property-information: "npm:^7.0.0" + space-separated-tokens: "npm:^2.0.0" + checksum: 10c0/18dc8064e5c3a7a2ae862978e626b97a254e1c8a67ee9d0c9f06d373bba155ed805fc5b5ce21b990fb7bc174624889e5e1ce1cade264f1b1d58b48f994bc85ce + languageName: node + linkType: hard + "hermes-estree@npm:0.25.1": version: 0.25.1 resolution: "hermes-estree@npm:0.25.1" @@ -8558,6 +8696,17 @@ __metadata: languageName: node linkType: hard +"katex@npm:^0.16.0, katex@npm:^0.16.22": + version: 0.16.47 + resolution: "katex@npm:0.16.47" + dependencies: + commander: "npm:^8.3.0" + bin: + katex: cli.js + checksum: 10c0/b10f4d0651c60771a48444879e4227255e26e2b2ec061b1ee4b08934863ad2324ba8dbb772455f7768aeb14dfcc13bcd309174a0ddd5ef954a607f644a197710 + languageName: node + linkType: hard + "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -8928,6 +9077,21 @@ __metadata: languageName: node linkType: hard +"mdast-util-math@npm:^3.0.0": + version: 3.0.0 + resolution: "mdast-util-math@npm:3.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + longest-streak: "npm:^3.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.1.0" + unist-util-remove-position: "npm:^5.0.0" + checksum: 10c0/d4e839e38719f26872ed78aac18339805a892f1b56585a9cb8668f34e221b4f0660b9dfe49ec96dbbe79fd1b63b648608a64046d8286bcd2f9d576e80b48a0a1 + languageName: node + linkType: hard + "mdast-util-mdx-expression@npm:^2.0.0": version: 2.0.1 resolution: "mdast-util-mdx-expression@npm:2.0.1" @@ -9003,7 +9167,7 @@ __metadata: languageName: node linkType: hard -"mdast-util-to-markdown@npm:^2.0.0": +"mdast-util-to-markdown@npm:^2.0.0, mdast-util-to-markdown@npm:^2.1.0": version: 2.1.2 resolution: "mdast-util-to-markdown@npm:2.1.2" dependencies: @@ -9174,6 +9338,21 @@ __metadata: languageName: node linkType: hard +"micromark-extension-math@npm:^3.0.0": + version: 3.1.0 + resolution: "micromark-extension-math@npm:3.1.0" + dependencies: + "@types/katex": "npm:^0.16.0" + devlop: "npm:^1.0.0" + katex: "npm:^0.16.0" + micromark-factory-space: "npm:^2.0.0" + micromark-util-character: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + checksum: 10c0/56e6f2185a4613f9d47e7e98cf8605851c990957d9229c942b005e286c8087b61dc9149448d38b2f8be6d42cc6a64aad7e1f2778ddd86fbbb1a2f48a3ca1872f + languageName: node + linkType: hard + "micromark-factory-destination@npm:^2.0.0": version: 2.0.1 resolution: "micromark-factory-destination@npm:2.0.1" @@ -10512,6 +10691,18 @@ __metadata: languageName: node linkType: hard +"react-markdown-typer@npm:1.0.5": + version: 1.0.5 + resolution: "react-markdown-typer@npm:1.0.5" + dependencies: + react-markdown: "npm:^10.1.0" + peerDependencies: + react: ">=18.0.0" + react-dom: ">=18.0.0" + checksum: 10c0/d8577b917ff425fff76fcb0f5928cab26a80ca8caf6384a743d28e706818763a32ad3bd192ff82558696814a459924759e55322f0d5f8951aa3f31f5c90839ec + languageName: node + linkType: hard + "react-markdown@npm:^10.1.0": version: 10.1.0 resolution: "react-markdown@npm:10.1.0" @@ -10553,7 +10744,7 @@ __metadata: languageName: node linkType: hard -"react-syntax-highlighter@npm:^15.5.0": +"react-syntax-highlighter@npm:^15.5.0, react-syntax-highlighter@npm:^15.6.1": version: 15.6.6 resolution: "react-syntax-highlighter@npm:15.6.6" dependencies: @@ -10713,6 +10904,21 @@ __metadata: languageName: node linkType: hard +"rehype-katex@npm:^7.0.1": + version: 7.0.1 + resolution: "rehype-katex@npm:7.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/katex": "npm:^0.16.0" + hast-util-from-html-isomorphic: "npm:^2.0.0" + hast-util-to-text: "npm:^4.0.0" + katex: "npm:^0.16.0" + unist-util-visit-parents: "npm:^6.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/73c770319536128b75055d904d06951789d00a0552c11724c0dac2e244dcb21041630552d118a11cc42233fdcd1bfee525e78a0020fde635bd916cceb281dfb1 + languageName: node + linkType: hard + "remark-gfm@npm:^4.0.1": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" @@ -10727,6 +10933,18 @@ __metadata: languageName: node linkType: hard +"remark-math@npm:^6.0.0": + version: 6.0.0 + resolution: "remark-math@npm:6.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-math: "npm:^3.0.0" + micromark-extension-math: "npm:^3.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/859613c4db194bb6b3c9c063661dc52b8ceda9c5cf3256b42f73d93eb8f38a6d634eb5f976fe094425f6f1035aaf329eb49ada314feb3b2b1073326b6d3aaa02 + languageName: node + linkType: hard + "remark-parse@npm:^11.0.0": version: 11.0.0 resolution: "remark-parse@npm:11.0.0" @@ -12127,6 +12345,7 @@ __metadata: cross-env: "npm:^7.0.3" cross-fetch: "npm:^4.1.0" dayjs: "npm:^1.11.8" + ds-markdown: "npm:^1.2.0" eslint: "npm:^9.39.4" eslint-config-next: "npm:^16.0.0" eslint-config-prettier: "npm:^10.1.0" @@ -12234,7 +12453,7 @@ __metadata: languageName: node linkType: hard -"unified@npm:^11.0.0": +"unified@npm:^11.0.0, unified@npm:^11.0.5": version: 11.0.5 resolution: "unified@npm:11.0.5" dependencies: @@ -12249,6 +12468,16 @@ __metadata: languageName: node linkType: hard +"unist-util-find-after@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-find-after@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/a7cea473c4384df8de867c456b797ff1221b20f822e1af673ff5812ed505358b36f47f3b084ac14c3622cb879ed833b71b288e8aa71025352a2aab4c2925a6eb + languageName: node + linkType: hard + "unist-util-is@npm:^6.0.0": version: 6.0.1 resolution: "unist-util-is@npm:6.0.1" @@ -12267,6 +12496,16 @@ __metadata: languageName: node linkType: hard +"unist-util-remove-position@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-remove-position@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + checksum: 10c0/e8c76da4399446b3da2d1c84a97c607b37d03d1d92561e14838cbe4fdcb485bfc06c06cfadbb808ccb72105a80643976d0660d1fe222ca372203075be9d71105 + languageName: node + linkType: hard + "unist-util-stringify-position@npm:^4.0.0": version: 4.0.0 resolution: "unist-util-stringify-position@npm:4.0.0" @@ -12479,6 +12718,16 @@ __metadata: languageName: node linkType: hard +"vfile-location@npm:^5.0.0": + version: 5.0.3 + resolution: "vfile-location@npm:5.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/1711f67802a5bc175ea69750d59863343ed43d1b1bb25c0a9063e4c70595e673e53e2ed5cdbb6dcdc370059b31605144d95e8c061b9361bcc2b036b8f63a4966 + languageName: node + linkType: hard + "vfile-message@npm:^4.0.0": version: 4.0.3 resolution: "vfile-message@npm:4.0.3" @@ -12524,6 +12773,13 @@ __metadata: languageName: node linkType: hard +"web-namespaces@npm:^2.0.0": + version: 2.0.1 + resolution: "web-namespaces@npm:2.0.1" + checksum: 10c0/df245f466ad83bd5cd80bfffc1674c7f64b7b84d1de0e4d2c0934fb0782e0a599164e7197a4bce310ee3342fd61817b8047ff04f076a1ce12dd470584142a4bd + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"