AI-agent chatbot with commands execution #1
@@ -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<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 {
|
||||
@@ -26,68 +104,243 @@ interface 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[]>([
|
||||
{ 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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const send = (e: FormEvent) => {
|
||||
// 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();
|
||||
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 (
|
||||
<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" />
|
||||
<div className="font-medium text-sm">Помощник</div>
|
||||
<span className="ml-2 text-xs text-mk-mute">beta</span>
|
||||
<div className="font-medium text-sm">AI Assistant</div>
|
||||
<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 && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
|
||||
aria-label="Закрыть"
|
||||
className="p-1 rounded hover:bg-mk-panel2 text-mk-mute hover:text-mk-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* Config panel */}
|
||||
{showConfig && (
|
||||
<div className="p-3 border-b border-mk-border bg-mk-panel/30 space-y-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-mk-mute block mb-1">Host URL</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input text-xs w-full"
|
||||
value={config.host}
|
||||
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>
|
||||
<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
|
||||
className="input"
|
||||
placeholder="Спросите бота…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
value={config.apiKey}
|
||||
onChange={(e) => updateConfig({ apiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<button className="btn-primary" type="submit" aria-label="Отправить">
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user