ZovOS_AI/internal/ui/static/app.js
Dan4ick 460d20539f feat: initial project structure - Phase 1 MVP
- 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
2026-03-20 00:38:22 +03:00

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();
})();