2026-02-03 23:16:16 +05:00
import React , { useState , useEffect , useRef } from 'react' ;
import { CurrentView } from '../types' ;
import { NAVIGABLE_VIEWS } from '../constants' ;
import { ArrowUpIcon } from './icons' ;
2026-02-11 15:46:19 +05:00
import { STRAPI_URL } from '../strapiService' ;
2026-02-03 23:16:16 +05:00
const PLACEHOLDERS = [
"Создайте мне сайт для моего бизнеса" ,
"Какие у вас есть вакансии?" ,
"Расскажите о ваших услугах для бизнеса" ,
"Мне нужно мобильное приложение" ,
"Что такое ваш ИИ-акселератор?" ,
"Я хочу научиться ИИ" ,
"Покажите ваши последние исследования"
] ;
interface ChatInputProps {
setCurrentView : ( view : CurrentView ) = > void ;
isSticky? : boolean ;
autoFocusOnMount? : boolean ;
}
const ChatInput : React.FC < ChatInputProps > = ( { setCurrentView , isSticky = false , autoFocusOnMount = false } ) = > {
const [ prompt , setPrompt ] = useState ( '' ) ;
const [ isLoading , setIsLoading ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ currentPlaceholderIndex , setCurrentPlaceholderIndex ] = useState ( 0 ) ;
const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
useEffect ( ( ) = > {
const interval = setInterval ( ( ) = > {
setCurrentPlaceholderIndex ( ( prevIndex ) = > ( prevIndex + 1 ) % PLACEHOLDERS . length ) ;
} , 5000 ) ; // Change placeholder every 5 seconds
return ( ) = > clearInterval ( interval ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
if ( textareaRef . current ) {
textareaRef . current . style . height = 'auto' ;
textareaRef . current . style . height = ` ${ textareaRef . current . scrollHeight } px ` ;
}
} , [ prompt ] ) ;
useEffect ( ( ) = > {
if ( autoFocusOnMount && textareaRef . current ) {
// Small timeout to ensure the element is fully visible and transitions are complete before focusing
const timer = setTimeout ( ( ) = > {
textareaRef . current ? . focus ( ) ;
} , 100 ) ;
return ( ) = > clearTimeout ( timer ) ;
}
} , [ autoFocusOnMount ] ) ;
const handleSubmit = async ( e : React.FormEvent ) = > {
e . preventDefault ( ) ;
if ( ! prompt . trim ( ) || isLoading ) return ;
setIsLoading ( true ) ;
setError ( null ) ;
const validViewsString = NAVIGABLE_VIEWS . join ( ', ' ) ;
const systemInstruction = ` You are an expert navigation assistant for a website. Your task is to analyze the user's query and determine which section of the website they want to visit. Respond ONLY with a JSON object containing a single key "view". The possible values for "view" are: ${ validViewsString } .
Here are some examples of user queries and your expected JSON response :
- "I want to see your work" - > { "view" : "storiesAll" }
- "Do you have any jobs?" - > { "view" : "careers" }
- "What services do you offer for businesses?" - > { "view" : "businessServices" }
- "I need a website" or "I need a program" - > { "view" : "businessServices" }
- "Tell me about your company" - > { "view" : "about" }
- "How can I contact you?" - > { "view" : "main" }
- "What AI research do you do?" - > { "view" : "researchAll" }
- "I want to learn AI" - > { "view" : "educationStudents" }
- "I have an idea" or "I have a project" - > { "view" : "acceleratorAbout" }
- "Do you have an accelerator?" - > { "view" : "acceleratorAbout" }
If the user ' s query is about having an idea or a project they want to develop , direct them to the accelerator . If they explicitly want to order a service like a website or an app , direct them to business services .
If the user 's query is unclear or doesn' t map to a specific view , respond with { "view" : "main" } .
Your entire response must be only the JSON object , with no other text , explanation , or markdown formatting . ` ;
try {
2026-02-11 15:46:19 +05:00
// Запрос через прокси Strapi (/api/ollama/chat) — один origin с фронтом, нет CORS и работает из любой сети
const response = await fetch ( ` ${ STRAPI_URL } /api/ollama/chat ` , {
2026-02-03 23:16:16 +05:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( {
2026-02-11 15:46:19 +05:00
model : 'gemma3n:e4b' ,
2026-02-03 23:16:16 +05:00
messages : [
{ role : 'system' , content : systemInstruction } ,
{ role : 'user' , content : prompt }
] ,
2026-02-11 15:46:19 +05:00
stream : false ,
2026-02-03 23:16:16 +05:00
} ) ,
} ) ;
if ( ! response . ok ) {
2026-02-11 15:46:19 +05:00
throw new Error ( ` Ошибка сети: ${ response . status } ${ response . statusText } . Убедитесь, что бэкенд и Ollama доступны. ` ) ;
2026-02-03 23:16:16 +05:00
}
const data = await response . json ( ) ;
if ( data . error ) {
2026-02-11 15:46:19 +05:00
throw new Error ( ` Сервер ИИ вернул ошибку: ${ typeof data . error === 'string' ? data.error : data.error.message || 'неизвестная ошибка' } ` ) ;
2026-02-03 23:16:16 +05:00
}
2026-02-11 15:46:19 +05:00
// Ответ Ollama в /api/chat (без стриминга):
// { message: { role: 'assistant', content: '...' }, ... }
const responseJsonString = data . message ? . content ;
2026-02-03 23:16:16 +05:00
if ( ! responseJsonString ) {
2026-02-11 15:46:19 +05:00
throw new Error ( "Сервер ИИ вернул пустой ответ." ) ;
2026-02-03 23:16:16 +05:00
}
// More robust JSON extraction
let parsedData = null ;
const jsonMatch = responseJsonString . match ( /\{[\s\S]*\}/ ) ;
if ( jsonMatch ) {
try {
parsedData = JSON . parse ( jsonMatch [ 0 ] ) ;
} catch ( e ) {
// JSON might be invalid, fall through to error
console . error ( "Failed to parse JSON from response:" , e ) ;
}
}
if ( parsedData && parsedData . view && NAVIGABLE_VIEWS . includes ( parsedData . view as any ) ) {
setCurrentView ( parsedData . view ) ;
} else {
setError ( "К сожалению, я не смог понять ваш запрос. Попробуйте переформулировать." ) ;
}
} catch ( err : any ) {
2026-02-11 15:46:19 +05:00
console . error ( "Ошибка при обращении к серверу ИИ (Ollama):" , err ) ;
setError ( err . message || "Произошла ошибка. Убедитесь, что бэкенд и сервер Ollama доступны. Пожалуйста, попробуйте еще раз." ) ;
2026-02-03 23:16:16 +05:00
} finally {
setIsLoading ( false ) ;
}
} ;
const wrapperClasses = isSticky ? "" : "relative z-10 mx-auto w-full max-w-3xl" ;
const baseLabelClasses = "relative flex w-full cursor-text flex-col overflow-hidden rounded-3xl py-3 pl-4 pr-14 transition-all duration-300" ;
const nonStickyLabelClasses = "border border-slate-200 bg-white shadow-sm focus-within:shadow-md" ;
const stickyLabelClasses = "border border-slate-300 bg-white/80 shadow-lg backdrop-blur-xl focus-within:shadow-xl" ;
const labelClasses = ` ${ baseLabelClasses } ${ isSticky ? stickyLabelClasses : nonStickyLabelClasses } ` ;
return (
< div className = { wrapperClasses } >
< form onSubmit = { handleSubmit } className = "relative" >
< label className = { labelClasses } >
< div className = { ` text-slate-400 min-h-[24px] pointer-events-none absolute left-0 top-0 w-full select-none px-4 pt-[18px] text-base transition-opacity duration-200 ${ prompt ? 'opacity-0' : 'opacity-100' } ` } aria-hidden = { ! ! prompt } >
< div className = "transition-transform ease-in-out duration-300" style = { { transform : ` translateY(- ${ currentPlaceholderIndex * 1.5 } rem) ` } } >
{ PLACEHOLDERS . map ( ( text , index ) = > (
< div key = { index } className = { ` overflow-hidden text-ellipsis whitespace-nowrap transition-opacity duration-300 ${ currentPlaceholderIndex === index ? 'opacity-100' : 'opacity-0' } ` } >
{ text }
< / div >
) ) }
< / div >
< / div >
< textarea
ref = { textareaRef }
value = { prompt }
onChange = { ( e ) = > setPrompt ( e . target . value ) }
onKeyDown = { ( e ) = > { if ( e . key === 'Enter' && ! e . shiftKey ) handleSubmit ( e ) ; } }
className = "w-full resize-none bg-transparent text-base focus:outline-none text-slate-800"
rows = { 1 }
style = { { minHeight : '24px' } }
aria - label = "Задайте ваш вопрос"
/ >
< / label >
< div className = "absolute bottom-2 right-2 mt-auto flex justify-end" >
< button
className = "bg-slate-800 text-white disabled:bg-slate-200 disabled:text-slate-400 relative h-10 w-10 rounded-full p-0 transition-colors hover:bg-black disabled:hover:bg-slate-200 flex items-center justify-center"
type = "submit"
disabled = { ! prompt . trim ( ) || isLoading }
aria - label = "Отправить запрос"
>
{ isLoading ? (
< svg className = "animate-spin h-5 w-5 text-white" xmlns = "http://www.w3.org/2000/svg" fill = "none" viewBox = "0 0 24 24" >
< circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" > < / circle >
< path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > < / path >
< / svg >
) : (
< ArrowUpIcon className = "w-5 h-5" / >
) }
< / button >
< / div >
< / form >
{ error && < p className = "mt-2 text-center text-sm text-red-600" > { error } < / p > }
< / div >
) ;
} ;
export default ChatInput ;