Files
iiEasy/components/ChatInput.tsx

198 lines
9.1 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { CurrentView } from '../types';
import { NAVIGABLE_VIEWS } from '../constants';
import { ArrowUpIcon } from './icons';
import { STRAPI_URL } from '../strapiService';
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 {
// Запрос через прокси Strapi (/api/ollama/chat) — один origin с фронтом, нет CORS и работает из любой сети
const response = await fetch(`${STRAPI_URL}/api/ollama/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma3n:e4b',
messages: [
{ role: 'system', content: systemInstruction },
{ role: 'user', content: prompt }
],
stream: false,
}),
});
if (!response.ok) {
throw new Error(`Ошибка сети: ${response.status} ${response.statusText}. Убедитесь, что бэкенд и Ollama доступны.`);
}
const data = await response.json();
if (data.error) {
throw new Error(`Сервер ИИ вернул ошибку: ${typeof data.error === 'string' ? data.error : data.error.message || 'неизвестная ошибка'}`);
}
// Ответ Ollama в /api/chat (без стриминга):
// { message: { role: 'assistant', content: '...' }, ... }
const responseJsonString = data.message?.content;
if (!responseJsonString) {
throw new Error("Сервер ИИ вернул пустой ответ.");
}
// 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) {
console.error("Ошибка при обращении к серверу ИИ (Ollama):", err);
setError(err.message || "Произошла ошибка. Убедитесь, что бэкенд и сервер Ollama доступны. Пожалуйста, попробуйте еще раз.");
} 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;