AI-agent chatbot with commands execution #1
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user