import { Bot, ChevronDown, History, Plus, Send, Settings, StopCircle, User } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" import { TooltipProvider } from "@/components/ui/tooltip" import { shortcutsRegistry } from "@/features/keyboard-shortcuts" import { useMediaImport } from "@/features/media/hooks/use-media-import" import { useModal } from "@/features/modals" import { useApiKeys } from "@/features/user-settings/hooks/use-api-keys" import { cn } from "@/lib/utils" import { useChat } from ".." import { useResourcesAIIntegration } from "../hooks/use-resources-ai-integration" import { useSafeTimeline } from "../hooks/use-safe-timeline" import { chatStorageService } from "../services/chat-storage-service" import { CLAUDE_MODELS, ClaudeService } from "../services/claude-service" import { DEEPSEEK_MODELS, DeepSeekService } from "../services/deepseek-service" import { OLLAMA_MODELS, OllamaService } from "../services/ollama-service" import { AI_MODELS, OpenAiService } from "../services/open-ai-service" import { Agent, ChatMessage } from "../types/chat" import { compressContext, isContextOverLimit } from "../utils/context-manager" import { createTimelineContextPrompt } from "../utils/timeline-context" import { ChatList } from "./chat-list" const AVAILABLE_AGENTS = [ // Claude модели { id: CLAUDE_MODELS.CLAUDE_4_SONNET, name: "Claude 4 Sonnet", useTools: true, provider: "claude", }, { id: CLAUDE_MODELS.CLAUDE_4_OPUS, name: "Claude 4 Opus", useTools: true, provider: "claude", }, // OpenAI модели { id: AI_MODELS.GPT_4, name: "GPT-4", useTools: false, provider: "openai", }, { id: AI_MODELS.GPT_4O, name: "GPT-4o", useTools: false, provider: "openai", }, { id: AI_MODELS.GPT_3_5, name: "GPT-3.5 Turbo", useTools: false, provider: "openai", }, { id: AI_MODELS.O3, name: "o3", useTools: false, provider: "openai", }, // DeepSeek модели { id: DEEPSEEK_MODELS.DEEPSEEK_R1, name: "DeepSeek R1", useTools: false, provider: "deepseek", }, { id: DEEPSEEK_MODELS.DEEPSEEK_CHAT, name: "DeepSeek Chat", useTools: false, provider: "deepseek", }, { id: DEEPSEEK_MODELS.DEEPSEEK_CODER, name: "DeepSeek Coder", useTools: false, provider: "deepseek", }, // Ollama модели (базовые) { id: OLLAMA_MODELS.LLAMA2, name: "Llama 2 (Local)", useTools: false, provider: "ollama", }, { id: OLLAMA_MODELS.MISTRAL, name: "Mistral (Local)", useTools: false, provider: "ollama", }, { id: OLLAMA_MODELS.CODELLAMA, name: "Code Llama (Local)", useTools: false, provider: "ollama", }, ] // Chat modes type ChatMode = "chat" | "agent" const CHAT_MODES: Array<{ id: ChatMode name: string description: string canEdit: boolean }> = [ { id: "chat", name: "Chat", description: "Normal chat", canEdit: false, }, { id: "agent", name: "Agent", description: "Edits files and uses tools", canEdit: true, }, ] export function AiChat() { const { t } = useTranslation() const { chatMessages, sendChatMessage, receiveChatMessage, selectedAgentId, selectAgent, isProcessing, setProcessing, currentSessionId, sessions, isCreatingNewChat, createNewChat, switchSession, deleteSession, updateSessions, clearMessages, } = useChat() const { getApiKeyInfo } = useApiKeys() const { openModal } = useModal() const { importFile } = useMediaImport() // Получаем контекст Timeline (если доступен) const timelineContext = useSafeTimeline() // Интеграция с ResourcesProvider для AI инструментов const { isIntegrated, resourceStats } = useResourcesAIIntegration() const [message, setMessage] = useState("") const [chatMode, setChatMode] = useState("agent") const [showHistory, setShowHistory] = useState(false) const [streamingContent, setStreamingContent] = useState("") const [isStreaming, setIsStreaming] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) const abortControllerRef = useRef(null) // Load chat history on mount useEffect(() => { void updateSessions() }, []) // Прокрутка к последнему сообщению const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) }, []) // Функция для автоматического изменения высоты textarea const autoResizeTextarea = useCallback(() => { if (inputRef.current) { // Сбрасываем высоту до минимальной inputRef.current.style.height = "auto" // Устанавливаем высоту по содержимому (scrollHeight) const newHeight = Math.min(Math.max(40, inputRef.current.scrollHeight), 120) // Минимум 40px, максимум 120px inputRef.current.style.height = `${newHeight}px` } }, [inputRef]) // Прокрутка при добавлении новых сообщений useEffect(() => { scrollToBottom() }, [chatMessages, scrollToBottom]) // Инициализация автоматического изменения высоты textarea useEffect(() => { // Вызываем функцию при монтировании компонента autoResizeTextarea() }, [autoResizeTextarea]) // Обработчик отправки сообщения const handleSendMessage = useCallback(() => { if (!message.trim() || isProcessing || isStreaming) return // Определяем провайдера по модели const getProviderByModel = (model: string) => { if (model.startsWith("claude")) return "claude" if (model.startsWith("gpt") || model.startsWith("o3")) return "openai" if (model.startsWith("deepseek")) return "deepseek" return "ollama" // По умолчанию для локальных моделей } const provider = getProviderByModel(selectedAgentId || "") // Проверяем API ключ только для облачных провайдеров if (provider !== "ollama") { const apiKeyInfo = getApiKeyInfo(provider) if (!apiKeyInfo || !apiKeyInfo.has_value || apiKeyInfo.is_valid !== true) { // Если API ключ не установлен или невалидный, показываем диалог настроек openModal("user-settings") return } } // Создаем сообщение пользователя const userMessage: ChatMessage = { id: `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, content: message, role: "user", timestamp: new Date(), } // Отправляем сообщение пользователя void sendChatMessage(message) setMessage("") setProcessing(true) // Сохраняем сообщение пользователя в историю if (currentSessionId) { // Проверяем, существует ли сессия в storage (для новых чатов может не существовать) const performSave = async () => { try { const session = await chatStorageService.getSession(currentSessionId) if (!session) { // Если сессия не существует, создаем ее await chatStorageService.createSession("") // Заголовок будет создан автоматически при добавлении первого сообщения } // Добавляем сообщение await chatStorageService.addMessage(currentSessionId, userMessage) } catch (error) { console.error("Failed to save message:", error) } } void performSave() } // Сбрасываем высоту textarea после очистки setTimeout(autoResizeTextarea, 0) // Фокус на поле ввода inputRef.current?.focus() // Выполняем реальный API запрос с поддержкой потоковых ответов const performApiRequest = async () => { // Создаем контроллер для отмены запроса abortControllerRef.current = new AbortController() try { const currentModel = selectedAgentId || CLAUDE_MODELS.CLAUDE_4_SONNET const provider = getProviderByModel(currentModel) // Подготавливаем все сообщения const allMessages = [ ...chatMessages.map((msg) => ({ role: msg.role as "user" | "assistant", content: msg.content, })), { role: "user" as const, content: message, }, ] // Создаем системный промпт с контекстом Timeline let systemPrompt = createTimelineContextPrompt( timelineContext?.project || null, timelineContext?.project?.sections?.[0] || null, // Активная секция (пока берем первую) (timelineContext?.uiState?.selectedClipIds ?.map((id: string) => { // Находим выбранные клипы в проекте for (const section of timelineContext.project?.sections || []) { for (const track of section.tracks) { const clip = track.clips.find((c: any) => c.id === id) if (clip) return clip } } return null }) .filter(Boolean) as any[]) || [], ) // Добавляем информацию о доступных ресурсах if (resourceStats && isIntegrated) { systemPrompt += "\n\nДоступные ресурсы в проекте:" if (resourceStats.totalMedia > 0) { systemPrompt += `\n- Медиафайлы: ${resourceStats.totalMedia} шт.` } if (resourceStats.totalMusic > 0) { systemPrompt += `\n- Музыкальные треки: ${resourceStats.totalMusic} шт.` } if (resourceStats.totalEffects > 0) { systemPrompt += `\n- Эффекты: ${resourceStats.totalEffects} шт.` } if (resourceStats.totalFilters > 0) { systemPrompt += `\n- Фильтры: ${resourceStats.totalFilters} шт.` } if (resourceStats.totalDuration > 0) { systemPrompt += `\n- Общая длительность медиа: ${Math.floor(resourceStats.totalDuration / 60)} мин ${Math.floor(resourceStats.totalDuration % 60)} сек` } if (resourceStats.totalSize > 0) { const sizeInMB = Math.round(resourceStats.totalSize / 1024 / 1024) systemPrompt += `\n- Общий размер: ${sizeInMB} МБ` } } // Управление размером контекста let messages: { role: "user" | "assistant"; content: string }[] = allMessages if (isContextOverLimit(allMessages, currentModel, systemPrompt)) { console.log("Контекст превышает лимиты модели, сжимаем...") const compressedMessages = compressContext(allMessages, currentModel, systemPrompt) // Фильтруем только user и assistant сообщения для API messages = compressedMessages.filter((msg) => msg.role === "user" || msg.role === "assistant") as { role: "user" | "assistant" content: string }[] } // Начинаем потоковый ответ setIsStreaming(true) setStreamingContent("") // Общий обработчик для завершения потокового ответа const handleStreamComplete = async (fullContent: string) => { setIsStreaming(false) setStreamingContent("") const agentMessage: ChatMessage = { id: `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, content: fullContent, role: "assistant", timestamp: new Date(), agent: selectedAgentId as Agent | undefined, } receiveChatMessage(agentMessage.content) // Сохраняем сообщение в историю if (currentSessionId) { await chatStorageService.addMessage(currentSessionId, agentMessage) } } // Общий обработчик ошибок const handleStreamError = (error: Error) => { console.error("Error in streaming:", error) setIsStreaming(false) setStreamingContent("") throw error } // Выбираем сервис по провайдеру switch (provider) { case "claude": { const claudeService = ClaudeService.getInstance() await claudeService.sendStreamingRequest(currentModel, messages, { max_tokens: 2000, system: systemPrompt, signal: abortControllerRef.current.signal, onContent: (content) => setStreamingContent((prev) => prev + content), onComplete: handleStreamComplete, onError: handleStreamError, }) break } case "openai": { const openAiService = OpenAiService.getInstance() // Для OpenAI добавляем системное сообщение в начало const messagesWithSystem = [{ role: "system" as const, content: systemPrompt }, ...messages] await openAiService.sendStreamingRequest(currentModel, messagesWithSystem, { max_tokens: 2000, signal: abortControllerRef.current.signal, onContent: (content) => setStreamingContent((prev) => prev + content), onComplete: handleStreamComplete, onError: handleStreamError, }) break } case "deepseek": { const deepSeekService = DeepSeekService.getInstance() // Для DeepSeek добавляем системное сообщение в начало const messagesWithSystem = [{ role: "system" as const, content: systemPrompt }, ...messages] await deepSeekService.sendStreamingRequest(currentModel, messagesWithSystem, { max_tokens: 2000, signal: abortControllerRef.current.signal, onContent: (content) => setStreamingContent((prev) => prev + content), onComplete: handleStreamComplete, onError: handleStreamError, }) break } case "ollama": { const ollamaService = OllamaService.getInstance() // Для Ollama добавляем системное сообщение в начало const messagesWithSystem = [{ role: "system" as const, content: systemPrompt }, ...messages] await ollamaService.sendStreamingRequest(currentModel, messagesWithSystem, { temperature: 0.7, signal: abortControllerRef.current.signal, onContent: (content) => setStreamingContent((prev) => prev + content), onComplete: handleStreamComplete, onError: handleStreamError, }) break } default: throw new Error(`Неподдерживаемый провайдер: ${provider}`) } } catch (error) { console.error("Error sending message to AI:", error) setIsStreaming(false) setStreamingContent("") // Если это не ошибка отмены запроса, показываем сообщение об ошибке if ((error as Error).name !== "AbortError") { const errorContent = t( "timeline.chat.error", "Произошла ошибка при отправке сообщения. Пожалуйста, проверьте настройки API ключа.", ) receiveChatMessage(errorContent) } } finally { setProcessing(false) abortControllerRef.current = null } } void performApiRequest() }, [ message, sendChatMessage, receiveChatMessage, selectedAgentId, isProcessing, isStreaming, autoResizeTextarea, getApiKeyInfo, openModal, setProcessing, chatMessages, currentSessionId, t, timelineContext, resourceStats, isIntegrated, ]) // Обработчик остановки обработки const handleStopProcessing = useCallback(() => { // Прерываем текущий запрос, если он активен if (abortControllerRef.current) { abortControllerRef.current.abort() abortControllerRef.current = null } setProcessing(false) setIsStreaming(false) setStreamingContent("") }, [setProcessing]) // Регистрируем shortcut для отправки сообщения useEffect(() => { const handleSendShortcut = () => { // Проверяем, что фокус на textarea чата const activeElement = document.activeElement const chatInputs = document.querySelectorAll( '[data-testid="chat-input"], [data-testid="chat-input-with-messages"]', ) const isInChatInput = Array.from(chatInputs).some((input) => input === activeElement) if (isInChatInput && !isProcessing && !isStreaming) { handleSendMessage() } } shortcutsRegistry.updateAction("send-chat-message", handleSendShortcut) return () => { shortcutsRegistry.updateAction("send-chat-message", undefined) } }, [handleSendMessage, isProcessing, isStreaming]) // Обработчик нажатия Enter (для Shift+Enter) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // Shift+Enter добавляет новую строку if (e.key === "Enter" && e.shiftKey) { // Позволяем стандартное поведение для новой строки return } // Enter без Shift обрабатывается через shortcuts registry if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() // Обработка через shortcuts registry } }, []) // Форматирование времени const formatTime = (timestamp: Date) => { return timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }) } return (
{/* Header */}

{t("timeline.chat.title", "Chat")}

{/* Resource stats */} {isIntegrated && resourceStats && (
{resourceStats.totalMedia > 0 && (
{t("timeline.chat.media", "Медиа")}: {resourceStats.totalMedia} )} {resourceStats.totalEffects > 0 && (
{t("timeline.chat.effects", "Эффекты")}: {resourceStats.totalEffects} )} {resourceStats.totalFilters > 0 && (
{t("timeline.chat.filters", "Фильтры")}: {resourceStats.totalFilters} )} {resourceStats.totalDuration > 0 && (
{Math.floor(resourceStats.totalDuration / 60)} {t("timeline.chat.minutes", "мин")} )}
{isIntegrated && (
{t("timeline.chat.ai_connected", "AI подключен")}
)}
)}
{/* Main content area */}
{/* Input area - positioned at top when no messages */} {chatMessages.length === 0 && (
{/* Подсказка для пустого проекта */} {resourceStats && resourceStats.totalMedia === 0 && (

💡 {t("timeline.chat.empty_project_hint", "Проект пока пустой. Начните с:")}

)}