diff --git a/frontend/src/components/ChatBot.tsx b/frontend/src/components/ChatBot.tsx index ad21a79..fb27e13 100644 --- a/frontend/src/components/ChatBot.tsx +++ b/frontend/src/components/ChatBot.tsx @@ -1,5 +1,7 @@ import { FormEvent, useState, useEffect, useRef } from 'react'; import { X, Send, Bot, Settings, Loader2, Trash2, Save } from 'lucide-react'; +import { api } from '@/api/client'; +import type { Device, InterfaceInfo, FirmwareChannelsOut } from '@/api/client'; interface Msg { who: 'bot' | 'me'; @@ -20,51 +22,294 @@ const DEFAULT_CONFIG: OpenAIConfig = { 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.', + systemPrompt: `You are a helpful assistant that answers questions about device management, backups, firmware updates, and network configurations. + +You have access to a set of tools (functions) that allow you to retrieve real-time data from MikroTik devices. Always use these tools when the user asks for device lists, statuses, interfaces, firmware channels, or wants to execute CLI commands. Do not guess or fabricate data. + +Available tools: +- get_devices_list: returns all devices with names, IPs, statuses. +- get_device_status(identifier): detailed info about a specific device (name, IP, RouterOS version, uptime, etc.). +- get_device_interfaces(identifier): list of interfaces with statuses, comments, MACs. +- get_firmware_channels: current RouterOS channel versions and check timestamps. +- trigger_firmware_check: manually request a firmware update check. +- execute_device_command(device_identifier, command): run any CLI command on a device and return the output. + +When the user asks something like "show devices", "list devices", "status of router1", "interfaces of hAP ac lite", "firmware channels", "check for updates", or "execute /system/resource/print on device router1" – you MUST call the corresponding tool. If the user is ambiguous, ask for clarification (e.g., which device?). + +Important for commands: Use RouterOS syntax with slashes, e.g., "/system/resource/print details" or "/interface/print", just like a path to executable program in Linux. If you write with spaces like "/system resource print", it will be error! Arguments (like "details") should be separated by a space after the command. Example: "/system/resource/print details" + +After receiving tool results, summarize the information clearly for the user.`, }; 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 }); +// ======================== API-ФУНКЦИИ (ИНСТРУМЕНТЫ) ======================== + +async function getDevicesList(): Promise { + try { + const response = await api.get('/devices'); + const devices = response.data; + if (!devices.length) return '📡 No devices found.'; + const lines = devices.map(d => { + const name = d.hostname || d.board_name || d.name || d.id; + const ip = d.address || d.management_ip || 'IP unknown'; + const statusIcon = d.status === 'up' ? '✅' : d.status === 'down' ? '❌' : '⚠️'; + return `${statusIcon} **${name}** (${d.status}) — ${ip}`; + }); + return `📋 **Device list (${devices.length})**\n\n${lines.join('\n')}`; + } catch (err) { + console.error('getDevicesList failed', err); + return '❌ Failed to retrieve device list. Please check server connection.'; } - 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( +async function getDeviceStatus(identifier: string): Promise { + try { + const devicesResp = await api.get('/devices'); + const device = devicesResp.data.find(d => + d.id === identifier || + d.hostname?.toLowerCase() === identifier.toLowerCase() || + d.board_name?.toLowerCase() === identifier.toLowerCase() || + d.name?.toLowerCase() === identifier.toLowerCase() + ); + if (!device) return `❌ Device "${identifier}" not found. Use "list devices" to see available ones.`; + const lines = [ + `🖥️ **${device.hostname || device.board_name || device.name || device.id}**`, + `├─ ID: ${device.id}`, + `├─ Status: ${device.status === 'up' ? '✅ up' : device.status === 'down' ? '❌ down' : '⚠️ unknown'}`, + `├─ IP address: ${device.address || device.management_ip || '—'}`, + `├─ Model: ${device.board_name || '—'}`, + `├─ RouterOS version: ${device.ros_version || '—'}`, + `├─ Uptime: ${device.uptime || '—'}`, + ]; + if (device.internet_ok !== undefined) lines.push(`├─ Internet access: ${device.internet_ok ? '✅ yes' : '❌ no'}`); + if (device.abnormal_reboot) lines.push(`└─ ⚠️ Abnormal reboot: ${device.abnormal_reboot}`); + else lines.push(`└─ No abnormal reboots`); + return lines.join('\n'); + } catch (err) { + console.error('getDeviceStatus failed', err); + return '❌ Failed to retrieve device status.'; + } +} + +async function getDeviceInterfaces(identifier: string): Promise { + try { + const devicesResp = await api.get('/devices'); + const device = devicesResp.data.find(d => + d.id === identifier || + d.hostname?.toLowerCase() === identifier.toLowerCase() || + d.board_name?.toLowerCase() === identifier.toLowerCase() || + d.name?.toLowerCase() === identifier.toLowerCase() + ); + if (!device) return `❌ Device "${identifier}" not found.`; + const ifaceResp = await api.get(`/devices/${device.id}/interfaces`); + const interfaces = ifaceResp.data; + if (!interfaces.length) return `🔌 Device **${device.hostname || device.id}** has no interfaces.`; + const lines = interfaces.map(iface => { + const status = iface.running ? '🟢 up' : iface.disabled ? '⚪ disabled' : '🔴 down'; + let line = `- **${iface.name}** (${iface.type || 'ether'}) — ${status}`; + if (iface.comment) line += ` · ${iface.comment}`; + if (iface.mac_address) line += ` · MAC: ${iface.mac_address}`; + return line; + }); + return `🔌 **Interfaces of ${device.hostname || device.id}**\n\n${lines.join('\n')}`; + } catch (err) { + console.error('getDeviceInterfaces failed', err); + return '❌ Failed to retrieve interfaces.'; + } +} + +async function getFirmwareChannelsInfo(): Promise { + try { + const resp = await api.get('/firmware/channels'); + const data = resp.data; + const order = data.available_channels; + const lines = order.map(ch => { + const info = data.channels[ch]; + if (!info) return `- **${ch}**: no data`; + const ok = info.last_check_ok !== false && info.version; + return `- **${ch}**: ${info.version || '—'} ${ok ? '✅' : '⚠️'} (checked at ${new Date(info.last_check).toLocaleString()})`; + }); + return `📦 **RouterOS channels**\n\n${lines.join('\n')}`; + } catch (err) { + console.error('getFirmwareChannelsInfo failed', err); + return '❌ Failed to retrieve firmware channels.'; + } +} + +async function triggerFirmwareCheck(): Promise { + try { + await api.post('/firmware/check'); + return '🔄 Firmware update check started. New versions will appear in the channels list in a few seconds.'; + } catch (err: any) { + const msg = err?.response?.data?.message || err.message; + return `❌ Failed to trigger firmware check: ${msg}`; + } +} + +async function executeDeviceCommand(deviceIdentifier: string, command: string): Promise { + try { + const devicesResp = await api.get('/devices'); + const device = devicesResp.data.find(d => + d.id === deviceIdentifier || + d.hostname?.toLowerCase() === deviceIdentifier.toLowerCase() || + d.board_name?.toLowerCase() === deviceIdentifier.toLowerCase() || + d.name?.toLowerCase() === deviceIdentifier.toLowerCase() + ); + if (!device) return `❌ Device "${deviceIdentifier}" not found.`; + + // Преобразуем id в число (если он приходит строкой, но API может ждать number) + const deviceId = typeof device.id === 'string' ? parseInt(device.id, 10) : device.id; + if (isNaN(deviceId)) return `❌ Invalid device ID: ${device.id}`; + + // Новый эндпоинт и тело запроса + const resp = await api.post<{ output?: string; result?: string; stdout?: string }>( + '/cli/run', // или '/api/v1/cli/run' – уточните по своему api.client + { + device_ids: [deviceId], + command: command, + confirm: false, + } + ); + + let output = resp.data.output || resp.data.results || resp.data.result || resp.data.stdout; + if (!output) return `✅ Command executed, no output returned.`; + output = JSON.stringify(output); // А надобно будет потом нормально распарсить по-хорошему + const truncated = output.length > 1800 ? output.slice(0, 1800) + '\n… (output truncated)' : output; + return `🖥️ **Result of command** on device ${device.id}:\n\`\`\`\n${truncated}\n\`\`\``; + } catch (err: any) { + const msg = err?.response?.data?.message || err?.response?.data?.error || err.message; + return `❌ Command execution failed: ${msg || 'unknown error'}`; + } +} + +// ======================== OPENAI FUNCTION CALLING (TOOLS) ======================== + +const tools = [ + { + type: 'function', + function: { + name: 'get_devices_list', + description: 'Get the list of all MikroTik devices with their statuses and IP addresses.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'get_device_status', + description: 'Get detailed status of a specific device (uptime, RouterOS version, internet connectivity, etc.).', + parameters: { + type: 'object', + properties: { + identifier: { type: 'string', description: 'Device ID, hostname, board name, or name (case-insensitive partial match).' }, + }, + required: ['identifier'], + }, + }, + }, + { + type: 'function', + function: { + name: 'get_device_interfaces', + description: 'List all network interfaces of a specific device with their statuses (up/down/disabled), comments, and MAC addresses.', + parameters: { + type: 'object', + properties: { + identifier: { type: 'string', description: 'Device ID, hostname, board name, or name.' }, + }, + required: ['identifier'], + }, + }, + }, + { + type: 'function', + function: { + name: 'get_firmware_channels', + description: 'Get current RouterOS firmware channels (stable, testing, development) with versions and last check timestamps.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'trigger_firmware_check', + description: 'Manually trigger a firmware update check. Useful when the user asks to check for new RouterOS updates.', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'execute_device_command', + description: 'Execute an arbitrary CLI command on a device (e.g., "/system/resource/print", "/interface/print").', + parameters: { + type: 'object', + properties: { + device_identifier: { type: 'string', description: 'Device ID, hostname, board name, or name.' }, + command: { type: 'string', description: 'RouterOS command to execute.' }, + }, + required: ['device_identifier', 'command'], + }, + }, + }, +]; + +async function callTool(name: string, args: any): Promise { + switch (name) { + case 'get_devices_list': + return await getDevicesList(); + case 'get_device_status': + return await getDeviceStatus(args.identifier); + case 'get_device_interfaces': + return await getDeviceInterfaces(args.identifier); + case 'get_firmware_channels': + return await getFirmwareChannelsInfo(); + case 'trigger_firmware_check': + return await triggerFirmwareCheck(); + case 'execute_device_command': + return await executeDeviceCommand(args.device_identifier, args.command); + default: + return `Unknown tool: ${name}`; + } +} + +/** + * Send messages to LLM, handle tool_calls recursively, return final assistant message. + * Modifies the conversation history (adds assistant message + tool responses). + */ +async function sendWithTools( messages: Msg[], config: OpenAIConfig, systemPrompt: string, setLoading: (loading: boolean) => void, - setError: (error: string | null) => void -): Promise { + setError: (error: string | null) => void, + updateMessages: (newMsgs: Msg[]) => void +): Promise { if (!config.apiKey.trim()) { setError('API key is required. Please configure settings.'); - return null; + return; } if (!config.host.trim()) { setError('Host URL is required.'); - return null; + return; } - + setLoading(true); setError(null); - + + // Convert internal messages to OpenAI format, prepend system + const openAiMsgs: { role: string; content: string; tool_calls?: any; name?: string }[] = []; + if (systemPrompt.trim()) { + openAiMsgs.push({ role: 'system', content: systemPrompt }); + } + for (const m of messages) { + openAiMsgs.push({ role: m.who === 'me' ? 'user' : 'assistant', content: m.text }); + } + const endpoint = `${config.host.replace(/\/$/, '')}${config.endpointPath}`; - const openAiMsgs = toOpenAIMessages(messages, systemPrompt); - - try { + + const makeRequest = async (msgs: any[]): Promise => { const response = await fetch(endpoint, { method: 'POST', headers: { @@ -73,30 +318,74 @@ async function sendToAI( }, body: JSON.stringify({ model: config.model, - messages: openAiMsgs, + messages: msgs, + tools: tools, + tool_choice: 'auto', 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(); + return await response.json(); + }; + + try { + let currentMessages = [...openAiMsgs]; + let finalAssistantContent: string | null = null; + + while (true) { + const data = await makeRequest(currentMessages); + const assistantMessage = data.choices?.[0]?.message; + if (!assistantMessage) throw new Error('No message in response'); + + // If no tool calls, we're done + if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) { + finalAssistantContent = assistantMessage.content || ''; + break; + } + + // Append assistant message with tool_calls to conversation + currentMessages.push(assistantMessage); + + // Execute each tool call and append tool response messages + for (const toolCall of assistantMessage.tool_calls) { + const func = toolCall.function; + let args: any = {}; + try { + args = JSON.parse(func.arguments); + } catch (e) { + args = {}; + } + const result = await callTool(func.name, args); + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: result, + }); + } + // Continue loop – LLM will see tool outputs and decide final answer or more calls + } + + if (finalAssistantContent !== null) { + // Add the final assistant message to UI + updateMessages([...messages, { who: 'bot', text: finalAssistantContent, ts: Date.now() }]); + } else { + throw new Error('No final response from model'); + } } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Unknown error'; setError(errorMsg); - return null; + // Optionally show error as a bot message + updateMessages([...messages, { who: 'bot', text: `⚠️ Error: ${errorMsg}`, ts: Date.now() }]); } finally { setLoading(false); } } +// ======================== КОМПОНЕНТ CHATBOT ======================== + interface ChatBotProps { open?: boolean; onClose?: () => void; @@ -104,7 +393,6 @@ 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) { @@ -116,85 +404,59 @@ export default function ChatBot({ open = true, onClose, embedded = false }: Chat } return DEFAULT_CONFIG; }); - + const [showConfig, setShowConfig] = useState(false); const [msgs, setMsgs] = useState([ - { 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() }, + { who: 'bot', text: '👋 I can now use real API tools! Ask me about devices, interfaces, firmware, or execute commands. I will automatically fetch live data.\n\nTry:\n- "list devices"\n- "status of hAP ac lite"\n- "interfaces of router1"\n- "firmware channels"\n- "check for updates"\n- "execute /system/resource/print on device router1"', ts: Date.now() }, ]); const [input, setInput] = useState(''); 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 || 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 userMsg: Msg = { who: 'me', text, ts: now }; - setMsgs((prev) => [...prev, userMsg]); + + const userMsg: Msg = { who: 'me', text, ts: Date.now() }; + const newMsgs = [...msgs, userMsg]; + setMsgs(newMsgs); setInput(''); 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() }, - ]); - } + + // Call LLM with tools + await sendWithTools(newMsgs, config, config.systemPrompt, setLoading, setError, setMsgs); }; - + 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-96 h-[560px] card p-0 flex flex-col shadow-2xl'; - + return (
- {/* Header */}
AI Assistant
- OpenAI-compatible - + Tools · Function Calling + - + - + {!embedded && onClose && (
- - {/* Config panel */} + {showConfig && (
@@ -272,10 +533,7 @@ export default function ChatBot({ open = true, onClose, embedded = false }: Chat