From d35aff90a39a83ad06e8515f5c5d5ebf04d5dc05 Mon Sep 17 00:00:00 2001 From: dimadenisjuk Date: Sun, 17 May 2026 19:22:33 +0300 Subject: [PATCH] Simple chatbot without commands or context --- frontend/src/components/ChatBot.tsx | 349 ++++++++++++++++++++++++---- 1 file changed, 301 insertions(+), 48 deletions(-) diff --git a/frontend/src/components/ChatBot.tsx b/frontend/src/components/ChatBot.tsx index 8049646..ad21a79 100644 --- a/frontend/src/components/ChatBot.tsx +++ b/frontend/src/components/ChatBot.tsx @@ -1,5 +1,5 @@ -import { FormEvent, useState } from 'react'; -import { X, Send, Bot } from 'lucide-react'; +import { FormEvent, useState, useEffect, useRef } from 'react'; +import { X, Send, Bot, Settings, Loader2, Trash2, Save } from 'lucide-react'; interface Msg { who: 'bot' | 'me'; @@ -7,16 +7,94 @@ interface Msg { ts: number; } -const HINT = `Это заглушка чат-бота. Здесь будет интеграция с Telegram/AI. -Можно спрашивать про устройства, настройки, бэкапы.`; +interface OpenAIConfig { + host: string; + endpointPath: string; + apiKey: string; + model: string; + systemPrompt: string; +} -function botReply(q: string): string { - const s = q.toLowerCase(); - if (/устройств|devices/.test(s)) return 'Список устройств доступен в разделе "Devices".'; - if (/бэкап|backup/.test(s)) return 'Бэкапы создаются на странице устройства, кнопкой "Backup".'; - if (/прошив|firmware/.test(s)) return 'Репозиторий прошивок — в левом меню "Прошивки".'; - if (/привет|hi|hello/.test(s)) return 'Привет! Чем помочь?'; - return 'Ок, принял. (бот пока в режиме заглушки)'; +const DEFAULT_CONFIG: OpenAIConfig = { + host: 'http://llm2.lab.local:9911/v1', + endpointPath: '/chat/completions', + apiKey: '', + model: 'qwen3-coder-next-q8-2gpu-nooffload', + systemPrompt: 'You are a helpful assistant that answers questions about device management, backups, firmware updates, and network configurations.', +}; + +const STORAGE_KEY = 'openai-chat-config'; + +// Helper to convert internal messages to OpenAI format +function toOpenAIMessages(msgs: Msg[], systemPrompt: string) { + const result: { role: 'system' | 'user' | 'assistant'; content: string }[] = []; + if (systemPrompt.trim()) { + result.push({ role: 'system', content: systemPrompt }); + } + for (const m of msgs) { + if (m.who === 'me') { + result.push({ role: 'user', content: m.text }); + } else { + result.push({ role: 'assistant', content: m.text }); + } + } + return result; +} + +// Call OpenAI-compatible API +async function sendToAI( + messages: Msg[], + config: OpenAIConfig, + systemPrompt: string, + setLoading: (loading: boolean) => void, + setError: (error: string | null) => void +): Promise { + if (!config.apiKey.trim()) { + setError('API key is required. Please configure settings.'); + return null; + } + if (!config.host.trim()) { + setError('Host URL is required.'); + return null; + } + + setLoading(true); + setError(null); + + const endpoint = `${config.host.replace(/\/$/, '')}${config.endpointPath}`; + const openAiMsgs = toOpenAIMessages(messages, systemPrompt); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + messages: openAiMsgs, + stream: false, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error (${response.status}): ${errorText.slice(0, 200)}`); + } + + const data = await response.json(); + // Handle both OpenAI and compatible responses (like Ollama, LocalAI) + const reply = data.choices?.[0]?.message?.content || data.message?.content || data.response; + if (!reply) throw new Error('Unexpected API response format: missing message content'); + return reply.trim(); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Unknown error'; + setError(errorMsg); + return null; + } finally { + setLoading(false); + } } interface ChatBotProps { @@ -26,68 +104,243 @@ interface ChatBotProps { } export default function ChatBot({ open = true, onClose, embedded = false }: ChatBotProps) { + // Load config from localStorage + const [config, setConfig] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + return { ...DEFAULT_CONFIG, ...JSON.parse(saved) }; + } catch { + return DEFAULT_CONFIG; + } + } + return DEFAULT_CONFIG; + }); + + const [showConfig, setShowConfig] = useState(false); const [msgs, setMsgs] = useState([ - { who: 'bot', text: HINT, ts: Date.now() }, + { who: 'bot', text: '👋 Configure your AI assistant using the settings icon (⚙️). Enter your OpenAI-compatible host, API key, endpoint, and model. Then start chatting!', ts: Date.now() }, ]); const [input, setInput] = useState(''); - - const send = (e: FormEvent) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const messagesEndRef = useRef(null); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [msgs]); + + // Save config changes + const updateConfig = (updates: Partial) => { + const newConfig = { ...config, ...updates }; + setConfig(newConfig); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newConfig)); + }; + + const clearChat = () => { + setMsgs([{ who: 'bot', text: 'Chat cleared. Start a new conversation!', ts: Date.now() }]); + setError(null); + }; + + const send = async (e: FormEvent) => { e.preventDefault(); const text = input.trim(); - if (!text) return; + if (!text || loading) return; + + // Don't allow sending if API key missing and not configured + if (!config.apiKey.trim()) { + setError('Please configure API key in settings first'); + return; + } + const now = Date.now(); - setMsgs((m) => [...m, { who: 'me', text, ts: now }]); + const userMsg: Msg = { who: 'me', text, ts: now }; + setMsgs((prev) => [...prev, userMsg]); setInput(''); - setTimeout(() => { - setMsgs((m) => [...m, { who: 'bot', text: botReply(text), ts: Date.now() }]); - }, 350); + setError(null); + + // Get reply from AI + const conversationSoFar = [...msgs, userMsg]; + const reply = await sendToAI( + conversationSoFar, + config, + config.systemPrompt, + setLoading, + setError + ); + + if (reply) { + setMsgs((prev) => [...prev, { who: 'bot', text: reply, ts: Date.now() }]); + } else if (!error) { + // Only show fallback if no specific error was set + setMsgs((prev) => [ + ...prev, + { who: 'bot', text: 'Sorry, I encountered an error. Please check your API settings.', ts: Date.now() }, + ]); + } }; - + if (!open) return null; + const wrapperCls = embedded ? 'card p-0 flex flex-col h-[60vh] min-h-[360px]' - : 'fixed bottom-5 left-60 z-40 w-80 h-96 card p-0 flex flex-col shadow-2xl'; + : 'fixed bottom-5 left-60 z-40 w-96 h-[560px] card p-0 flex flex-col shadow-2xl'; + return (
-
+ {/* Header */} +
-
Помощник
- beta +
AI Assistant
+ OpenAI-compatible + + + + + {!embedded && onClose && ( )}
-
- {msgs.map((m, i) => ( -
- {m.text} -
- ))} + + {/* Config panel */} + {showConfig && ( +
+
+
+ + updateConfig({ host: e.target.value })} + placeholder="https://api.openai.com/v1" + /> +
+
+ + updateConfig({ endpointPath: e.target.value })} + placeholder="/chat/completions" + /> +
-
+
+ setInput(e.target.value)} + type="password" + className="input text-xs w-full" + value={config.apiKey} + onChange={(e) => updateConfig({ apiKey: e.target.value })} + placeholder="sk-..." /> - - +
+
+
+ + updateConfig({ model: e.target.value })} + placeholder="gpt-3.5-turbo" + /> +
+
+ +
+
+
+ +