- Project infrastructure: go.mod, Makefile, .gitignore, README - Configuration system with YAML parsing and env overrides - Structured logging with slog - AI provider interface with Ollama, OpenAI, Anthropic implementations - Provider manager with runtime switching - Agent orchestrator with ReAct loop (reason-act-observe) - Tool registry with JSON Schema descriptions - Terminal tool: shell command execution with safety controls - Filesystem tools: read, write, list with path access control - Conversation memory with session management - Web UI server with WebSocket streaming - Modern dark theme UI with glassmorphism, animations - Frontend: WebSocket client, markdown rendering, tool call display
284 lines
8.9 KiB
JavaScript
284 lines
8.9 KiB
JavaScript
// ZovOS AI — Frontend Application
|
|
(function() {
|
|
'use strict';
|
|
|
|
// --- State ---
|
|
let ws = null;
|
|
let sessionId = 'default';
|
|
let isStreaming = false;
|
|
let currentAssistantMsg = null;
|
|
|
|
// --- DOM Elements ---
|
|
const chatContainer = document.getElementById('chatContainer');
|
|
const messagesEl = document.getElementById('messages');
|
|
const welcomeScreen = document.getElementById('welcomeScreen');
|
|
const messageInput = document.getElementById('messageInput');
|
|
const btnSend = document.getElementById('btnSend');
|
|
const btnNewChat = document.getElementById('btnNewChat');
|
|
const providerSelect = document.getElementById('providerSelect');
|
|
const statusIndicator = document.getElementById('statusIndicator');
|
|
const statusText = statusIndicator.querySelector('span');
|
|
|
|
// --- WebSocket ---
|
|
function connectWebSocket() {
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const url = `${protocol}//${location.host}/ws`;
|
|
|
|
ws = new WebSocket(url);
|
|
|
|
ws.onopen = () => {
|
|
setStatus('ready', 'Подключён');
|
|
console.log('[WS] Connected');
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
handleWSMessage(msg);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setStatus('error', 'Отключён');
|
|
console.log('[WS] Disconnected, reconnecting in 3s...');
|
|
setTimeout(connectWebSocket, 3000);
|
|
};
|
|
|
|
ws.onerror = (err) => {
|
|
console.error('[WS] Error:', err);
|
|
setStatus('error', 'Ошибка соединения');
|
|
};
|
|
}
|
|
|
|
function handleWSMessage(msg) {
|
|
switch (msg.type) {
|
|
case 'stream':
|
|
hideWelcome();
|
|
if (!currentAssistantMsg) {
|
|
currentAssistantMsg = addMessage('assistant', '');
|
|
}
|
|
appendToMessage(currentAssistantMsg, msg.content);
|
|
scrollToBottom();
|
|
break;
|
|
|
|
case 'tool_call':
|
|
hideWelcome();
|
|
if (!currentAssistantMsg) {
|
|
currentAssistantMsg = addMessage('assistant', '');
|
|
}
|
|
addToolCall(currentAssistantMsg, msg.tool_name, msg.tool_args, msg.tool_id);
|
|
scrollToBottom();
|
|
break;
|
|
|
|
case 'tool_result':
|
|
if (currentAssistantMsg) {
|
|
addToolResult(currentAssistantMsg, msg.tool_name, msg.content, msg.tool_id);
|
|
scrollToBottom();
|
|
}
|
|
break;
|
|
|
|
case 'done':
|
|
isStreaming = false;
|
|
if (currentAssistantMsg) {
|
|
renderMarkdown(currentAssistantMsg);
|
|
}
|
|
currentAssistantMsg = null;
|
|
setStatus('ready', 'Готов');
|
|
btnSend.disabled = false;
|
|
messageInput.focus();
|
|
break;
|
|
|
|
case 'error':
|
|
isStreaming = false;
|
|
currentAssistantMsg = null;
|
|
addMessage('assistant', `⚠️ Ошибка: ${msg.content}`);
|
|
setStatus('error', 'Ошибка');
|
|
btnSend.disabled = false;
|
|
setTimeout(() => setStatus('ready', 'Готов'), 3000);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// --- Messages ---
|
|
function addMessage(role, content) {
|
|
hideWelcome();
|
|
|
|
const msgEl = document.createElement('div');
|
|
msgEl.className = `message ${role}`;
|
|
|
|
const avatar = document.createElement('div');
|
|
avatar.className = 'message-avatar';
|
|
avatar.textContent = role === 'user' ? '👤' : '🤖';
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'message-body';
|
|
|
|
const contentEl = document.createElement('div');
|
|
contentEl.className = 'message-content';
|
|
contentEl.textContent = content;
|
|
|
|
body.appendChild(contentEl);
|
|
msgEl.appendChild(avatar);
|
|
msgEl.appendChild(body);
|
|
messagesEl.appendChild(msgEl);
|
|
|
|
scrollToBottom();
|
|
return contentEl;
|
|
}
|
|
|
|
function appendToMessage(contentEl, text) {
|
|
contentEl.textContent += text;
|
|
}
|
|
|
|
function renderMarkdown(contentEl) {
|
|
const raw = contentEl.textContent;
|
|
if (typeof marked !== 'undefined') {
|
|
contentEl.innerHTML = marked.parse(raw);
|
|
}
|
|
}
|
|
|
|
function addToolCall(contentEl, name, args, id) {
|
|
const parent = contentEl.closest('.message-body');
|
|
const el = document.createElement('div');
|
|
el.className = 'tool-call';
|
|
el.id = `tool-${id}`;
|
|
|
|
let argsDisplay = args;
|
|
try {
|
|
argsDisplay = JSON.stringify(JSON.parse(args), null, 2);
|
|
} catch(e) {}
|
|
|
|
el.innerHTML = `
|
|
<div class="tool-call-header">
|
|
<span>⚡</span>
|
|
<span>${escapeHtml(name)}</span>
|
|
</div>
|
|
<div class="tool-call-args">${escapeHtml(argsDisplay)}</div>
|
|
`;
|
|
parent.appendChild(el);
|
|
}
|
|
|
|
function addToolResult(contentEl, name, result, id) {
|
|
const parent = contentEl.closest('.message-body');
|
|
const el = document.createElement('div');
|
|
el.className = 'tool-result';
|
|
|
|
const truncated = result.length > 500 ? result.substring(0, 500) + '\n... (truncated)' : result;
|
|
el.textContent = truncated;
|
|
parent.appendChild(el);
|
|
}
|
|
|
|
// --- Actions ---
|
|
function sendMessage() {
|
|
const content = messageInput.value.trim();
|
|
if (!content || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
|
|
addMessage('user', content);
|
|
messageInput.value = '';
|
|
autoResize();
|
|
|
|
isStreaming = true;
|
|
currentAssistantMsg = null;
|
|
btnSend.disabled = true;
|
|
setStatus('busy', 'Думаю...');
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'message',
|
|
content: content,
|
|
session_id: sessionId,
|
|
}));
|
|
}
|
|
|
|
function newChat() {
|
|
sessionId = 'chat-' + Date.now();
|
|
messagesEl.innerHTML = '';
|
|
welcomeScreen.style.display = 'flex';
|
|
currentAssistantMsg = null;
|
|
isStreaming = false;
|
|
btnSend.disabled = false;
|
|
|
|
// Clear on server
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
fetch('/api/session/clear', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({session_id: sessionId}),
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Provider ---
|
|
async function loadProviders() {
|
|
try {
|
|
const resp = await fetch('/api/providers');
|
|
const data = await resp.json();
|
|
providerSelect.innerHTML = '';
|
|
const icons = {ollama: '🦙', openai: '🧠', anthropic: '🟣', google: '🔵'};
|
|
data.providers.forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p;
|
|
opt.textContent = `${icons[p] || '🤖'} ${p.charAt(0).toUpperCase() + p.slice(1)}`;
|
|
if (p === data.active) opt.selected = true;
|
|
providerSelect.appendChild(opt);
|
|
});
|
|
} catch(e) {
|
|
console.error('Failed to load providers:', e);
|
|
}
|
|
}
|
|
|
|
async function switchProvider(name) {
|
|
try {
|
|
await fetch('/api/provider', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({provider: name}),
|
|
});
|
|
} catch(e) {
|
|
console.error('Failed to switch provider:', e);
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
function hideWelcome() {
|
|
if (welcomeScreen) welcomeScreen.style.display = 'none';
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
requestAnimationFrame(() => {
|
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
});
|
|
}
|
|
|
|
function setStatus(type, text) {
|
|
statusIndicator.className = 'status-indicator ' + type;
|
|
statusText.textContent = text;
|
|
}
|
|
|
|
function autoResize() {
|
|
messageInput.style.height = 'auto';
|
|
messageInput.style.height = Math.min(messageInput.scrollHeight, 150) + 'px';
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// --- Events ---
|
|
btnSend.addEventListener('click', sendMessage);
|
|
btnNewChat.addEventListener('click', newChat);
|
|
providerSelect.addEventListener('change', (e) => switchProvider(e.target.value));
|
|
|
|
messageInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
messageInput.addEventListener('input', autoResize);
|
|
|
|
// --- Init ---
|
|
connectWebSocket();
|
|
loadProviders();
|
|
messageInput.focus();
|
|
})();
|