Simple chatbot without commands or context

This commit is contained in:
2026-05-17 19:22:33 +03:00
parent 27eb4fd606
commit d35aff90a3
+301 -48
View File
@@ -1,5 +1,5 @@
import { FormEvent, useState } from 'react'; import { FormEvent, useState, useEffect, useRef } from 'react';
import { X, Send, Bot } from 'lucide-react'; import { X, Send, Bot, Settings, Loader2, Trash2, Save } from 'lucide-react';
interface Msg { interface Msg {
who: 'bot' | 'me'; who: 'bot' | 'me';
@@ -7,16 +7,94 @@ interface Msg {
ts: number; ts: number;
} }
const HINT = `Это заглушка чат-бота. Здесь будет интеграция с Telegram/AI. interface OpenAIConfig {
Можно спрашивать про устройства, настройки, бэкапы.`; host: string;
endpointPath: string;
apiKey: string;
model: string;
systemPrompt: string;
}
function botReply(q: string): string { const DEFAULT_CONFIG: OpenAIConfig = {
const s = q.toLowerCase(); host: 'http://llm2.lab.local:9911/v1',
if (/устройств|devices/.test(s)) return 'Список устройств доступен в разделе "Devices".'; endpointPath: '/chat/completions',
if (/бэкап|backup/.test(s)) return 'Бэкапы создаются на странице устройства, кнопкой "Backup".'; apiKey: '',
if (/прошив|firmware/.test(s)) return 'Репозиторий прошивок — в левом меню "Прошивки".'; model: 'qwen3-coder-next-q8-2gpu-nooffload',
if (/привет|hi|hello/.test(s)) return 'Привет! Чем помочь?'; systemPrompt: 'You are a helpful assistant that answers questions about device management, backups, firmware updates, and network configurations.',
return 'Ок, принял. (бот пока в режиме заглушки)'; };
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<string | null> {
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 { interface ChatBotProps {
@@ -26,68 +104,243 @@ interface ChatBotProps {
} }
export default function ChatBot({ open = true, onClose, embedded = false }: ChatBotProps) { export default function ChatBot({ open = true, onClose, embedded = false }: ChatBotProps) {
// Load config from localStorage
const [config, setConfig] = useState<OpenAIConfig>(() => {
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<Msg[]>([ const [msgs, setMsgs] = useState<Msg[]>([
{ 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 [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const send = (e: FormEvent) => { const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [msgs]);
// Save config changes
const updateConfig = (updates: Partial<OpenAIConfig>) => {
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(); e.preventDefault();
const text = input.trim(); 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(); 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(''); setInput('');
setTimeout(() => { setError(null);
setMsgs((m) => [...m, { who: 'bot', text: botReply(text), ts: Date.now() }]);
}, 350); // 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; if (!open) return null;
const wrapperCls = embedded const wrapperCls = embedded
? 'card p-0 flex flex-col h-[60vh] min-h-[360px]' ? '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 ( return (
<div className={wrapperCls}> <div className={wrapperCls}>
<div className="px-4 py-3 border-b border-mk-border flex items-center gap-2"> {/* Header */}
<div className="px-4 py-3 border-b border-mk-border flex items-center gap-2 shrink-0">
<Bot size={18} className="text-mk-accent2" /> <Bot size={18} className="text-mk-accent2" />
<div className="font-medium text-sm">Помощник</div> <div className="font-medium text-sm">AI Assistant</div>
<span className="ml-2 text-xs text-mk-mute">beta</span> <span className="ml-2 text-xs text-mk-mute">OpenAI-compatible</span>
<button
onClick={() => setShowConfig(!showConfig)}
className={`ml-auto p-1 rounded hover:bg-mk-panel2 transition-colors ${showConfig ? 'bg-mk-panel2 text-mk-accent' : 'text-mk-mute hover:text-mk-text'}`}
aria-label="Settings"
title="API Settings"
>
<Settings size={16} />
</button>
<button
onClick={clearChat}
className="p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text transition-colors"
aria-label="Clear chat"
title="Clear conversation"
>
<Trash2 size={14} />
</button>
{!embedded && onClose && ( {!embedded && onClose && (
<button <button
onClick={onClose} onClick={onClose}
className="ml-auto p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text" className="p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
aria-label="Закрыть" aria-label="Close"
> >
<X size={16} /> <X size={16} />
</button> </button>
)} )}
</div> </div>
<div className="flex-1 overflow-auto p-3 space-y-2 text-sm">
{msgs.map((m, i) => ( {/* Config panel */}
<div {showConfig && (
key={i} <div className="p-3 border-b border-mk-border bg-mk-panel/30 space-y-2 text-sm">
className={`max-w-[85%] px-3 py-2 rounded-lg whitespace-pre-wrap ${ <div className="grid grid-cols-2 gap-2">
m.who === 'me' <div>
? 'ml-auto bg-mk-accent/20 text-mk-text' <label className="text-xs text-mk-mute block mb-1">Host URL</label>
: 'mr-auto bg-mk-panel2 text-mk-text' <input
}`} type="text"
> className="input text-xs w-full"
{m.text} value={config.host}
</div> onChange={(e) => updateConfig({ host: e.target.value })}
))} placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<label className="text-xs text-mk-mute block mb-1">Endpoint path</label>
<input
type="text"
className="input text-xs w-full"
value={config.endpointPath}
onChange={(e) => updateConfig({ endpointPath: e.target.value })}
placeholder="/chat/completions"
/>
</div>
</div> </div>
<form onSubmit={send} className="p-2 border-t border-mk-border flex gap-2"> <div>
<label className="text-xs text-mk-mute block mb-1">API Key</label>
<input <input
className="input" type="password"
placeholder="Спросите бота…" className="input text-xs w-full"
value={input} value={config.apiKey}
onChange={(e) => setInput(e.target.value)} onChange={(e) => updateConfig({ apiKey: e.target.value })}
placeholder="sk-..."
/> />
<button className="btn-primary" type="submit" aria-label="Отправить"> </div>
<Send size={14} /> <div className="grid grid-cols-2 gap-2">
</button> <div>
</form> <label className="text-xs text-mk-mute block mb-1">Model</label>
<input
type="text"
className="input text-xs w-full"
value={config.model}
onChange={(e) => updateConfig({ model: e.target.value })}
placeholder="gpt-3.5-turbo"
/>
</div>
<div className="flex items-end">
<button
onClick={() => {
// Test connection with a simple ping? Not needed, just save.
setShowConfig(false);
}}
className="btn-primary text-xs py-1.5 w-full"
>
<Save size={12} className="inline mr-1" /> Save
</button>
</div>
</div>
<div>
<label className="text-xs text-mk-mute block mb-1">System Prompt</label>
<textarea
className="input text-xs w-full"
rows={2}
value={config.systemPrompt}
onChange={(e) => updateConfig({ systemPrompt: e.target.value })}
placeholder="You are a helpful assistant..."
/>
</div>
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-auto p-3 space-y-2 text-sm">
{msgs.map((m, i) => (
<div
key={i}
className={`max-w-[85%] px-3 py-2 rounded-lg whitespace-pre-wrap ${
m.who === 'me'
? 'ml-auto bg-mk-accent/20 text-mk-text'
: 'mr-auto bg-mk-panel2 text-mk-text'
}`}
>
{m.text}
</div>
))}
{loading && (
<div className="mr-auto bg-mk-panel2 text-mk-text px-3 py-2 rounded-lg inline-flex items-center gap-2">
<Loader2 size={14} className="animate-spin" />
<span>Thinking...</span>
</div>
)}
{error && !loading && (
<div className="mr-auto bg-red-500/20 text-red-300 px-3 py-2 rounded-lg text-xs">
{error}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input form */}
<form onSubmit={send} className="p-2 border-t border-mk-border flex gap-2 shrink-0">
<input
className="input flex-1"
placeholder={config.apiKey ? "Ask me anything..." : "🔑 Please configure API key in settings"}
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading || !config.apiKey}
/>
<button
className="btn-primary"
type="submit"
disabled={loading || !config.apiKey || !input.trim()}
aria-label="Send"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
</button>
</form>
</div> </div>
); );
} }