Agent2 / index.html
Nerdur's picture
Upload 2 files
be3209b verified
Raw History Blame Contribute Delete
130 kB
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>AgentScope AI Chat</title>
<meta name="description" content="Kompaktan PWA Chat aplikacija sa podrškom za HuggingFace i OpenAI kompatibilne API-je.">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<!-- Highlight.js za syntax highlighting u code blokovima -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
/* --- CSS VARIJABLE I RESET --- */
:root {
--bg-color: #121212;
--surface-color: #1e1e1e;
--surface-hover: #2c2c2c;
--input-bg: #2a2a2a; /* Dodano: Pozadina za input polja */
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--text-main: #e5e5e5;
--text-secondary: #a3a3a3;
--border-color: #333;
--user-msg-bg: #2563eb;
--ai-msg-bg: #2c2c2c;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* --- LAYOUT --- */
header {
background-color: var(--surface-color);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
z-index: 10;
}
.app-title {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.settings-btn {
background: none;
border: none;
color: var(--text-main);
font-size: 1.2rem;
cursor: pointer;
padding: 0.5rem;
transition: color 0.2s;
}
.settings-btn:hover {
color: var(--primary-color);
}
main {
flex: 1;
overflow-y: auto;
overflow-anchor: none;
-webkit-overflow-scrolling: touch;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* --- CHAT MESSAGES --- */
.message {
max-width: 85%;
padding: 0.8rem 1rem;
border-radius: 12px;
font-size: 0.95rem;
line-height: 1.5;
position: relative;
animation: fadeIn 0.3s ease;
word-wrap: break-word;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
align-self: flex-end;
background-color: var(--user-msg-bg);
color: white;
border-bottom-right-radius: 2px;
}
.message.ai {
align-self: flex-start;
background-color: var(--ai-msg-bg);
color: var(--text-main);
border-bottom-left-radius: 2px;
}
.message.system {
align-self: center;
background: transparent;
color: var(--text-secondary);
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
/* --- INPUT AREA --- */
.input-area {
background-color: var(--surface-color);
padding: 0.8rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.attach-btn {
background-color: var(--input-bg);
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1rem;
transition: color 0.2s, border-color 0.2s;
}
.attach-btn:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
.attachment-chip {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.5rem 0.7rem;
font-size: 0.85rem;
color: var(--text-main);
}
.attachment-chip span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-chip button {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
cursor: pointer;
line-height: 1;
}
.attachment-chip button:hover {
color: #ef4444;
}
.input-wrapper {
flex: 1;
position: relative;
}
textarea {
width: 100%;
background-color: var(--input-bg); /* Koristi novu varijablu */
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-main);
padding: 0.8rem;
font-family: inherit;
resize: none;
max-height: 120px;
min-height: 48px;
outline: none;
font-size: 0.95rem;
transition: border-color 0.2s;
}
textarea:focus {
border-color: var(--primary-color);
}
.send-btn {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.send-btn:hover {
background-color: var(--primary-hover);
}
.send-btn:disabled {
background-color: var(--surface-hover);
color: var(--text-secondary);
cursor: not-allowed;
}
.send-btn.generating {
background-color: #d9534f;
cursor: pointer;
}
.send-btn.generating:hover {
background-color: #c9302c;
}
.spinner {
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top: 3px solid #fff;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* --- SETTINGS MODAL --- */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
backdrop-filter: blur(2px);
}
.modal-overlay.open {
opacity: 1;
pointer-events: auto;
}
.modal {
background-color: var(--surface-color);
width: 90%;
max-width: 400px;
max-height: 85vh;
max-height: 85dvh;
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-color);
transform: scale(0.9);
transition: transform 0.3s;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-body {
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.3rem;
margin-right: -0.3rem;
}
.modal-footer {
flex-shrink: 0;
padding-top: 0.75rem;
}
/* --- MCP SERVERI --- */
.mcp-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.7rem 0;
border-bottom: 1px solid var(--border-color);
}
.mcp-row:last-child { border-bottom: none; }
.mcp-row-toggle {
display: flex;
align-items: center;
gap: 0.7rem;
cursor: pointer;
flex: 1;
min-width: 0;
}
.mcp-row-toggle input[type="checkbox"] { width: auto; flex-shrink: 0; }
.mcp-row-name { font-size: 0.92rem; font-weight: 500; }
.mcp-row-note { font-size: 0.72rem; color: var(--text-secondary); margin-top: 0.15rem; }
.mcp-row-status { font-size: 0.72rem; margin-top: 0.15rem; color: var(--text-secondary); }
.mcp-row-status.connected { color: #4ade80; }
.mcp-row-status.error { color: #f87171; }
.mcp-row-status.connecting { color: #fbbf24; }
.mcp-remove {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.95rem;
flex-shrink: 0;
padding: 0.3rem;
}
.mcp-remove:hover { color: #f87171; }
.modal-overlay.open .modal {
transform: scale(1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-title {
font-size: 1.2rem;
font-weight: 600;
}
.close-modal {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.form-group input, .form-group select {
width: 100%;
padding: 0.8rem;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
color: white;
outline: none;
}
.form-group select:focus {
border-color: var(--primary-color);
}
.save-btn {
width: 100%;
padding: 0.8rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
}
/* --- PWA INSTALL PROMPT --- */
#install-prompt {
display: none;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: var(--surface-color);
padding: 1rem;
border-top: 1px solid var(--border-color);
text-align: center;
z-index: 50;
}
#install-prompt button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: bold;
}
/* --- CODE BLOCKS & PREVIEW --- */
.code-block-wrapper {
background: #0d0d0d;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
margin: 0.5rem 0;
}
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #171717;
border-bottom: 1px solid var(--border-color);
padding: 0.4rem 0.75rem;
}
.code-lang-label {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.code-actions {
display: flex;
gap: 0.35rem;
}
.code-action-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
font-family: inherit;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background-color 0.15s;
white-space: nowrap;
}
.code-action-btn:hover {
color: var(--text-main);
border-color: var(--primary-color);
}
.code-action-btn.preview-btn {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.code-action-btn.preview-btn:hover {
background-color: var(--primary-hover);
}
.code-action-btn.copied {
color: #22c55e;
border-color: #22c55e;
}
.code-block {
padding: 0.75rem;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 0.82rem;
white-space: pre;
display: block;
margin: 0;
background: transparent !important;
}
.code-block code.hljs {
background: transparent !important;
padding: 0 !important;
}
.msg-text {
white-space: pre-wrap;
}
/* Typewriter kursor - treperi na kraju teksta dok se poruka ispisuje */
.typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background-color: var(--text-main);
margin-left: 1px;
vertical-align: text-bottom;
animation: blinkCursor 0.8s steps(1) infinite;
}
@keyframes blinkCursor {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* --- SIDEBAR (istorija razgovora) --- */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 40;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.sidebar-overlay.open {
opacity: 1;
pointer-events: auto;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100%;
height: 100dvh;
width: min(300px, 82vw);
background-color: var(--surface-color);
border-right: 1px solid var(--border-color);
z-index: 50;
display: flex;
flex-direction: column;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: var(--shadow-md);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.sidebar-new-chat {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.8rem;
padding: 0.7rem 0.9rem;
background-color: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-main);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
flex-shrink: 0;
}
.sidebar-new-chat:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.sidebar-section-label {
padding: 0 1rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 0.3rem;
flex-shrink: 0;
}
.sidebar-chat-list {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.sidebar-chat-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.7rem;
border-radius: 8px;
cursor: pointer;
color: var(--text-main);
font-size: 0.85rem;
}
.sidebar-chat-item span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-chat-item:hover {
background-color: var(--surface-hover);
}
.sidebar-chat-item.active {
background-color: var(--surface-hover);
color: var(--primary-color);
}
.sidebar-chat-delete {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
padding: 0.25rem 0.4rem;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
flex-shrink: 0;
}
.sidebar-chat-item:hover .sidebar-chat-delete {
opacity: 1;
}
.sidebar-chat-delete:hover {
color: #ef4444;
}
/* --- EXECUTE_CODE REZULTAT (formatiran prikaz umjesto sirovog JSON-a) --- */
.exec-result {
align-self: stretch;
width: 100%;
max-width: 100%;
border-radius: 8px;
padding: 0.65rem 0.8rem;
margin: 0.15rem 0;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
border: 1px solid var(--border-color);
background: var(--surface-color);
}
.exec-result-header {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 0.78rem;
margin-bottom: 0.4rem;
}
.exec-result.success .exec-result-header { color: #4ade80; }
.exec-result.error .exec-result-header { color: #f87171; }
.exec-result.error {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.35);
}
.exec-result.success {
background: rgba(34, 197, 94, 0.06);
border-color: rgba(34, 197, 94, 0.3);
}
.exec-error-meta {
margin-left: auto;
font-weight: 400;
font-size: 0.7rem;
color: var(--text-secondary);
font-family: 'Courier New', monospace;
}
.exec-result-body {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-main);
}
.exec-result-body.error-text {
color: #fca5a5;
}
.exec-logs {
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px solid var(--border-color);
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
#preview-modal .modal {
max-width: 95%;
width: 95%;
height: 85vh;
display: flex;
flex-direction: column;
}
#preview-frame {
flex: 1;
width: 100%;
border: none;
border-radius: 8px;
background: #fff;
}
</style>
</head>
<body>
<!-- Header -->
<header>
<div style="display:flex; align-items:center; gap:0.4rem;">
<button class="settings-btn" id="open-sidebar" aria-label="Meni" title="Meni">
<i class="fa-solid fa-bars"></i>
</button>
<div class="app-title">
<i class="fa-solid fa-robot" style="color: var(--primary-color);"></i>
AgentScope AI
</div>
</div>
<div style="display:flex; align-items:center; gap:0.2rem;">
<button class="settings-btn" id="open-mcp" aria-label="MCP Serveri" title="MCP Serveri">
<i class="fa-solid fa-plug"></i>
</button>
<button class="settings-btn" id="open-settings" aria-label="Postavke">
<i class="fa-solid fa-gear"></i>
</button>
</div>
</header>
<!-- Sidebar (istorija razgovora) -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="app-title">
<i class="fa-solid fa-robot" style="color: var(--primary-color);"></i>
AgentScope AI
</div>
<button class="settings-btn" id="close-sidebar" aria-label="Zatvori meni">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<button class="sidebar-new-chat" id="sidebar-new-chat" type="button">
<i class="fa-solid fa-plus"></i> Novi razgovor
</button>
<div class="sidebar-section-label">Razgovori</div>
<div class="sidebar-chat-list" id="sidebar-chat-list"></div>
</aside>
<!-- Main Chat Area -->
<main id="chat-container">
<div class="message system">Dobrodošao. Odaberi model i unesi API Ključ u postavkama za početak.</div>
</main>
<!-- Input Area -->
<div class="input-area">
<div id="attachment-chip" class="attachment-chip" style="display:none;"></div>
<div class="input-row">
<input type="file" id="file-input" style="display:none" accept="image/*,.txt,.md,.json,.csv,.js,.jsx,.ts,.py,.html,.css,.xml,.log,.yaml,.yml">
<button id="attach-btn" class="attach-btn" title="Dodaj fajl" type="button">
<i class="fa-solid fa-paperclip"></i>
</button>
<div class="input-wrapper">
<textarea id="user-input" rows="1" placeholder="Napiši poruku..."></textarea>
</div>
<button id="send-btn" class="send-btn" disabled title="Pošalji poruku">
<div class="spinner" id="loading-spinner"></div>
<i class="fa-solid fa-paper-plane" id="send-icon"></i>
<i class="fa-solid fa-stop" id="stop-icon" style="display:none;"></i>
</button>
</div>
</div>
<!-- Skriveni iframe za sandboxed izvršavanje koda kad model pozove "execute_code" alat -->
<iframe id="exec-frame" style="display:none;" sandbox="allow-scripts"></iframe>
<!-- Install Prompt -->
<div id="install-prompt">
<button id="install-btn">Instaliraj Aplikaciju</button>
</div>
<!-- Settings Modal -->
<div class="modal-overlay" id="settings-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Postavke</h3>
<button class="close-modal" id="close-settings">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="provider-select">Provider (API)</label>
<select id="provider-select">
<option value="huggingface">Hugging Face (Inference Providers)</option>
<option value="openai">OpenAI</option>
<option value="mistral">Mistral AI</option>
<option value="samba">SambaNova Cloud</option>
<option value="gemini">Google Gemini</option>
<option value="nvidia">NVIDIA NIM</option>
<option value="pollinations">Pollinations AI</option>
<option value="kimi">Kimi (Moonshot AI)</option>
<option value="qwen">Qwen (Alibaba DashScope)</option>
</select>
</div>
<div class="form-group" id="api-key-group">
<label for="api-key">API Ključ (Bearer Token)</label>
<input type="password" id="api-key" placeholder="Nh... your-key-here">
<small style="color: var(--text-secondary); display: block; margin-top: 0.4rem; font-size: 0.75rem;">
Ključ se čuva samo lokalno u ovom browseru (localStorage) i šalje se direktno API provideru. Ne deli ovaj uređaj/link s drugima dok je ključ upisan.
</small>
<small id="api-key-provider-hint" style="color: var(--text-secondary); display: block; margin-top: 0.4rem; font-size: 0.75rem;"></small>
</div>
<div class="form-group">
<label for="model-select">Model</label>
<div style="display: flex; gap: 0.5rem;">
<select id="model-select" style="flex: 1;">
<!-- Options will be populated dynamically -->
<option value="" disabled selected>Učitavanje...</option>
</select>
<button type="button" id="refresh-models" title="Osvježi listu modela (dinamički fetch)" style="background-color: var(--input-bg); border: 1px solid var(--border-color); color: var(--text-main); border-radius: 6px; width: 44px; cursor: pointer;">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
<small id="model-status" style="color: var(--text-secondary); display: block; margin-top: 0.4rem; font-size: 0.75rem;"></small>
</div>
<div class="form-group">
<label for="system-prompt">System Prompt</label>
<input type="text" id="system-prompt" value="Ti si pomoćni asistent. Kada korisnik traži da napraviš nešto vizuelno ili interaktivno (3D scenu, animaciju, igru, sat i sl.), odmah vrati KOMPLETAN izvršiv kod u jednom ```html code bloku (uključi <script> tagove, po potrebi Three.js sa cdnjs.cloudflare.com CDN-a) — nemoj samo najavljivati šta ćeš uraditi, nego odmah generiši gotov kod." placeholder="System instruction...">
</div>
<div class="form-group">
<label for="max-tokens">Max Tokens (odgovor)</label>
<input type="number" id="max-tokens" min="16" max="128000" step="1" value="4096">
<small style="color: var(--text-secondary); display: block; margin-top: 0.4rem; font-size: 0.75rem;">
Nema više fiksnog limita od 500 - postavi koliko ti provider/model dozvoljava.
</small>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" id="tools-enabled" checked style="width:auto;">
Omogući Tool Calling & Code Execution
</label>
<small style="color: var(--text-secondary); display: block; margin-top: 0.4rem; font-size: 0.75rem;">
Model može pozvati alate: izvršavanje JavaScript koda (sandbox) i kreiranje artifacta (live preview). Ako provider/model ne podržava "tools", aplikacija automatski pokušava ponovo bez njih.
</small>
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
<input type="checkbox" id="streaming-enabled" checked style="width:auto;">
Omogući streaming odgovora (uživo ispisivanje)
</label>
<small style="color: var(--text-secondary); display: block; margin-top: 0.4rem; font-size: 0.75rem;">
Odgovor se ispisuje uživo čim stiže sa API-ja (kao kod ChatGPT-a), umjesto da se čeka cijeli odgovor pa tek onda prikaže. Ako provider/model ne podržava streaming, aplikacija automatski pada nazad na običan (non-streaming) način.
</small>
</div>
</div>
<div class="modal-footer">
<button class="save-btn" id="save-settings">Spremi</button>
<button class="save-btn" id="clear-settings" style="background-color: transparent; border: 1px solid var(--border-color); color: var(--text-secondary); margin-top: 0.5rem;">Obriši sačuvani API ključ</button>
</div>
</div>
</div>
<!-- MCP Servers Modal -->
<div class="modal-overlay" id="mcp-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">MCP Serveri</h3>
<button class="close-modal" id="close-mcp">&times;</button>
</div>
<div class="modal-body">
<div id="mcp-list"></div>
<div class="form-group" style="margin-top:1rem; border-top:1px solid var(--border-color); padding-top:1rem;">
<label>Dodaj custom MCP server</label>
<input type="text" id="mcp-new-name" placeholder="Naziv (npr. Moj server)">
<input type="text" id="mcp-new-url" placeholder="https://.../mcp" style="margin-top:0.5rem;">
<div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
<input type="text" id="mcp-new-header-name" placeholder="Header naziv (opciono, npr. Authorization)" style="flex:1;">
<input type="password" id="mcp-new-header-value" placeholder="Vrijednost / API ključ" style="flex:1;">
</div>
<button type="button" class="save-btn" id="mcp-add-btn" style="margin-top:0.6rem;">+ Dodaj server</button>
</div>
<small style="color: var(--text-secondary); display: block; margin-top: 0.8rem; font-size: 0.75rem;">
Napomena: aplikacija se povezuje na MCP servere direktno iz browsera (nema backend proxy-ja). Neki serveri ne dozvoljavaju cross-origin (CORS) pozive iz browsera pa mogu prijaviti grešku konekcije - to je ograničenje samog servera, ne aplikacije. Ključevi/tokeni se čuvaju samo lokalno (localStorage).
</small>
</div>
<div class="modal-footer">
<button class="save-btn" id="close-mcp-footer">Zatvori</button>
</div>
</div>
</div>
<div class="modal-overlay" id="preview-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Pregled rezultata</h3>
<button class="close-modal" id="close-preview">&times;</button>
</div>
<iframe id="preview-frame" sandbox="allow-scripts"></iframe>
</div>
</div>
<!-- PWA Manifest & SW Injection Script -->
<script>
// --- 1. PWA MANIFEST GENERATION ---
const manifest = {
"name": "AgentScope AI",
"short_name": "AgentScope",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#121212",
"theme_color": "#121212",
"icons": [
{
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 192 192'%3E%3Crect width='192' height='192' fill='%23121212'/%3E%3Ccircle cx='96' cy='96' r='60' fill='%233b82f6'/%3E%3Ccircle cx='75' cy='88' r='10' fill='%23121212'/%3E%3Ccircle cx='117' cy='88' r='10' fill='%23121212'/%3E%3Crect x='66' y='118' width='60' height='10' rx='5' fill='%23121212'/%3E%3C/svg%3E",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect width='512' height='512' fill='%23121212'/%3E%3Ccircle cx='256' cy='256' r='160' fill='%233b82f6'/%3E%3Ccircle cx='200' cy='235' r='26' fill='%23121212'/%3E%3Ccircle cx='312' cy='235' r='26' fill='%23121212'/%3E%3Crect x='176' y='315' width='160' height='26' rx='13' fill='%23121212'/%3E%3C/svg%3E",
"sizes": "512x512",
"type": "image/svg+xml"
}
]
};
// Create a Blob URL for the manifest and inject it dynamically
const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/json'});
const manifestUrl = URL.createObjectURL(manifestBlob);
// Create link tag if it doesn't exist
let manifestLink = document.querySelector('link[rel="manifest"]');
if (!manifestLink) {
manifestLink = document.createElement('link');
manifestLink.rel = 'manifest';
document.head.appendChild(manifestLink);
}
manifestLink.href = manifestUrl;
// --- 2. SERVICE WORKER (Offline Support) ---
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js')
.then(reg => { console.log('SW Registered'); reg.update(); })
.catch(err => console.log('SW Failed:', err));
}
// --- 3. APP LOGIC ---
const UI = {
chatContainer: document.getElementById('chat-container'),
userInput: document.getElementById('user-input'),
sendBtn: document.getElementById('send-btn'),
loadingSpinner: document.getElementById('loading-spinner'),
sendIcon: document.getElementById('send-icon'),
stopIcon: document.getElementById('stop-icon'),
modal: document.getElementById('settings-modal'),
openSettingsBtn: document.getElementById('open-settings'),
closeSettingsBtn: document.getElementById('close-settings'),
saveSettingsBtn: document.getElementById('save-settings'),
clearSettingsBtn: document.getElementById('clear-settings'),
// Inputs
providerSelect: document.getElementById('provider-select'),
apiKeyInput: document.getElementById('api-key'),
modelSelect: document.getElementById('model-select'),
refreshModelsBtn: document.getElementById('refresh-models'),
modelStatus: document.getElementById('model-status'),
systemPromptInput: document.getElementById('system-prompt'),
maxTokensInput: document.getElementById('max-tokens'),
toolsEnabledInput: document.getElementById('tools-enabled'),
streamingEnabledInput: document.getElementById('streaming-enabled'),
apiKeyProviderHint: document.getElementById('api-key-provider-hint'),
installBtn: document.getElementById('install-btn'),
installPrompt: document.getElementById('install-prompt'),
// Preview modal (za prikaz generisanog koda)
previewModal: document.getElementById('preview-modal'),
previewFrame: document.getElementById('preview-frame'),
closePreviewBtn: document.getElementById('close-preview'),
// File upload
fileInput: document.getElementById('file-input'),
attachBtn: document.getElementById('attach-btn'),
attachmentChip: document.getElementById('attachment-chip'),
// Sandbox za izvršavanje koda (execute_code alat)
execFrame: document.getElementById('exec-frame'),
// MCP Serveri modal
mcpBtn: document.getElementById('open-mcp'),
mcpModal: document.getElementById('mcp-modal'),
closeMcpBtn: document.getElementById('close-mcp'),
closeMcpFooterBtn: document.getElementById('close-mcp-footer'),
mcpList: document.getElementById('mcp-list'),
mcpNewName: document.getElementById('mcp-new-name'),
mcpNewUrl: document.getElementById('mcp-new-url'),
mcpNewHeaderName: document.getElementById('mcp-new-header-name'),
mcpNewHeaderValue: document.getElementById('mcp-new-header-value'),
mcpAddBtn: document.getElementById('mcp-add-btn'),
// Sidebar (istorija razgovora)
sidebar: document.getElementById('sidebar'),
sidebarOverlay: document.getElementById('sidebar-overlay'),
openSidebarBtn: document.getElementById('open-sidebar'),
closeSidebarBtn: document.getElementById('close-sidebar'),
sidebarNewChatBtn: document.getElementById('sidebar-new-chat'),
sidebarChatList: document.getElementById('sidebar-chat-list')
};
// --- MCP SERVERI (podrazumijevana lista) ---
// Ovo su stvarni, javno dokumentovani remote MCP serveri (Streamable HTTP + JSON-RPC).
// NAPOMENA: pošto ova app poziva servere direktno iz browsera (bez backend proxy-ja),
// konekcija radi samo ako server eksplicitno dozvoljava cross-origin (CORS) pozive.
// Prva 3 rade bez ključa; posljednja 2 traže vlastiti (besplatan) token/ključ koji se
// upisuje u polje ispod - ključ ostaje samo u localStorage ovog browsera.
const MCP_DEFAULTS = [
{ id: 'deepwiki', name: 'DeepWiki', url: 'https://mcp.deepwiki.com/mcp', headerName: '', headerValue: '', builtin: true, note: 'Pitanja o javnim GitHub repozitorijima - bez ključa' },
{ id: 'context7', name: 'Context7', url: 'https://mcp.context7.com/mcp', headerName: 'CONTEXT7_API_KEY', headerValue: '', builtin: true, note: 'Aktuelna dokumentacija biblioteka/frameworka - radi bez ključa (opcioni ključ za veći limit)' },
{ id: 'usefulai', name: 'Useful AI Tools', url: 'https://api.usefulai.fun/mcp', headerName: '', headerValue: '', builtin: true, note: '340+ pomoćnih alata (konverzije, tekst, kriptografija...) - bez ključa' },
{ id: 'huggingface', name: 'Hugging Face', url: 'https://huggingface.co/mcp', headerName: 'Authorization', headerValue: '', builtin: true, note: 'Pretraga modela/dataseta/paperа - traži besplatan HF token' },
{ id: 'github', name: 'GitHub', url: 'https://api.githubcopilot.com/mcp/', headerName: 'Authorization', headerValue: '', builtin: true, note: 'Repo/issues/PR uvid - traži GitHub Personal Access Token' }
];
const App = {
config: {
provider: 'huggingface',
apiKey: '',
model: '',
maxTokens: 4096,
toolsEnabled: true,
streamingEnabled: true,
systemPrompt: 'Ti si pomoćni asistent. Kada korisnik traži da napraviš nešto vizuelno ili interaktivno (3D scenu, animaciju, igru, sat i sl.), odmah vrati KOMPLETAN izvršiv kod u jednom ```html code bloku (uključi <script> tagove, po potrebi Three.js sa cdnjs.cloudflare.com CDN-a) — nemoj samo najavljivati šta ćeš uraditi, nego odmah generiši gotov kod.'
},
// Historija razgovora (bez system poruke) - koristi se kao kontekst za API pozive.
// NAPOMENA: nema više hardkodiranog limita na broj poruka - historija raste
// prirodno sa razgovorom (provider/model API sam prijavi grešku ako se pređe kontekst).
// Popunjava se u loadChats() pri pokretanju (iz sačuvanog chata ili novog).
messages: [],
// Svi sačuvani razgovori (sidebar istorija) - { id, title, messages, updatedAt }[]
chats: [],
currentChatId: null,
// MCP serveri (runtime lista, sa statusom konekcije i keširanim tools/list po serveru)
mcpServers: [],
// Trenutno priložen fajl (slika ili tekst) čeka da bude poslan uz sljedeću poruku
pendingAttachment: null,
// Da li je trenutno u toku poziv modelu (streaming ili ne) - kontroliše
// izgled/ponašanje send-btn dugmeta (pretvara se u Stop dugme) i signal
// za AbortController koji prekida fetch/stream na zahtjev korisnika.
isGenerating: false,
abortController: null,
// Definicije alata (OpenAI-kompatibilan "tools" format) koje model može pozvati.
// Podržano od providera koji implementiraju function/tool calling (OpenAI, Mistral,
// Gemini, NVIDIA NIM, Qwen...). Ako provider/model ne podržava "tools" parametar,
// runConversationTurn() automatski pokušava ponovo bez njega.
toolDefinitions: [
{
type: 'function',
function: {
name: 'execute_code',
description: 'Izvršava proizvoljan JavaScript kod u izolovanom sandbox okruženju (browser iframe) i vraća console.log izlaz i rezultat izraza. Koristi ovo za računanje, obradu podataka, testiranje logike ili provjeru ispravnosti koda prije nego što ga ponudiš korisniku.',
parameters: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'JavaScript kod za izvršavanje. Koristi console.log() za ispis međurezultata.'
}
},
required: ['code']
}
}
},
{
type: 'function',
function: {
name: 'create_artifact',
description: 'Kreira i odmah prikazuje korisniku vizuelni ili interaktivni artifact (kompletna HTML stranica, 3D scena/Three.js, igra, animacija, kalkulator, dashboard i sl.) u live preview-u unutar chata. Kod mora biti kompletan i samostalno izvršiv.',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Kratak, opisan naziv artifacta.' },
language: { type: 'string', enum: ['html', 'javascript'], description: 'Jezik koda - "html" za kompletan HTML dokument, "javascript" za čisti JS (npr. Three.js scenu) koji će biti automatski umotan u minimalan HTML/canvas okvir.' },
code: { type: 'string', description: 'Kompletan izvršivi kod artifacta.' }
},
required: ['code']
}
}
}
],
// --- KONFIGURACIJA PROVIDERA ---
// Svaki provider ima chatUrl (chat/completions), modelsUrl (za dinamički fetch liste
// modela, ili null ako provider ne podržava listing endpoint), tokenParam (naziv
// parametra za limit tokena - OpenAI novije modele traži "max_completion_tokens",
// svi ostali standardni "max_tokens"), keyRequired i staticModels kao fallback lista
// koja se koristi kad dinamički fetch nije moguć (nema ključa, nema mreže, CORS blok...).
//
// NAPOMENA O CORS-u: pozivi idu direktno iz browsera prema API-ju providera (bez
// backend proxy-ja). Neki provideri (npr. Mistral, SambaNova, NVIDIA) su primarno
// zamišljeni za server-side pozive i mogu odbiti cross-origin zahtjev iz browsera -
// u tom slučaju će sendMessage() prijaviti grešku fetch-a, a lista modela će automatski
// pasti na ugrađenu (staticModels) listu.
providers: {
huggingface: {
label: 'Hugging Face',
chatUrl: 'https://router.huggingface.co/v1/chat/completions',
modelsUrl: 'https://router.huggingface.co/v1/models',
tokenParam: 'max_tokens',
keyRequired: true,
staticModels: [
"openai/gpt-oss-120b:fastest",
"openai/gpt-oss-20b:fastest",
"deepseek-ai/DeepSeek-R1:fastest",
"meta-llama/Llama-3.3-70B-Instruct:fastest",
"Qwen/Qwen2.5-72B-Instruct:fastest"
]
},
openai: {
label: 'OpenAI',
chatUrl: 'https://api.openai.com/v1/chat/completions',
modelsUrl: 'https://api.openai.com/v1/models',
tokenParam: 'max_completion_tokens',
keyRequired: true,
staticModels: [
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-4.1-mini", "gpt-4o-mini"
]
},
mistral: {
label: 'Mistral AI',
chatUrl: 'https://api.mistral.ai/v1/chat/completions',
modelsUrl: 'https://api.mistral.ai/v1/models',
tokenParam: 'max_tokens',
keyRequired: true,
staticModels: [
"mistral-large-latest", "mistral-medium-latest", "mistral-small-latest", "codestral-latest", "open-mistral-nemo"
]
},
samba: {
label: 'SambaNova',
chatUrl: 'https://api.sambanova.ai/v1/chat/completions',
// SambaNova OpenAI-kompatibilan API (još) nema /v1/models endpoint
// (potvrđeno u njihovoj developer zajednici), zato je modelsUrl null
// i uvijek se koristi ugrađena lista za ovaj provider.
modelsUrl: null,
tokenParam: 'max_tokens',
keyRequired: true,
// Poznato: ne dozvoljava direktne (cross-origin) pozive iz browsera.
corsBlocked: true,
staticModels: [
"Meta-Llama-3.3-70B-Instruct", "Llama-4-Maverick-17B-128E-Instruct", "DeepSeek-V3.1", "DeepSeek-R1", "Qwen3-32B"
]
},
gemini: {
label: 'Google Gemini',
chatUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
modelsUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/models',
tokenParam: 'max_tokens',
keyRequired: true,
staticModels: [
"gemini-3.5-flash", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"
]
},
nvidia: {
label: 'NVIDIA NIM',
chatUrl: 'https://integrate.api.nvidia.com/v1/chat/completions',
modelsUrl: 'https://integrate.api.nvidia.com/v1/models',
tokenParam: 'max_tokens',
keyRequired: true,
// Poznato (potvrđeno na NVIDIA developer forumu): integrate.api.nvidia.com
// ne šalje Access-Control-Allow-Origin header, pa browser blokira poziv
// prije nego što uopšte stigne do servera - otud sirovi "Failed to fetch".
corsBlocked: true,
staticModels: [
"meta/llama-3.3-70b-instruct", "deepseek-ai/deepseek-r1", "nvidia/llama-3.3-nemotron-super-49b-v1.5", "qwen/qwen2.5-72b-instruct", "mistralai/mixtral-8x22b-instruct-v0.1"
]
},
pollinations: {
label: 'Pollinations AI',
chatUrl: 'https://gen.pollinations.ai/v1/chat/completions',
modelsUrl: 'https://gen.pollinations.ai/v1/models',
tokenParam: 'max_tokens',
keyRequired: false, // Pollinations radi i bez ključa za javne modele (uz ograničenja)
staticModels: [
"openai", "openai-large", "openai-fast", "gemini", "gemini-large", "claude", "claude-large", "mistral", "deepseek", "grok", "qwen-coder"
]
},
kimi: {
label: 'Kimi (Moonshot AI)',
chatUrl: 'https://api.moonshot.ai/v1/chat/completions',
modelsUrl: 'https://api.moonshot.ai/v1/models',
tokenParam: 'max_tokens',
keyRequired: true,
// NAPOMENA: Kimi/Moonshot API nije trajno besplatan - ključ se pravi besplatno,
// ali korištenje traži minimalni "recharge" od $1 na platform.moonshot.ai.
staticModels: [
"kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "moonshot-v1-128k", "moonshot-v1-32k", "moonshot-v1-8k"
]
},
qwen: {
label: 'Qwen (Alibaba DashScope)',
chatUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions',
modelsUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models',
tokenParam: 'max_tokens',
keyRequired: true,
// NAPOMENA: Novi Alibaba Cloud nalozi dobiju besplatnu kvotu od 1M tokena po
// modelu (90 dana) na international/Singapore endpointu koji se koristi ovdje.
staticModels: [
"qwen-max", "qwen-plus", "qwen-flash", "qwen-turbo", "qwen3-coder-plus", "qwen-vl-plus"
]
}
},
init() {
this.loadSettings();
this.loadChats();
this.mcpInit();
this.loadModelsIntoSelect(this.config.provider, this.config.apiKey);
this.bindEvents();
this.setupPWAInstall();
},
// --- MCP SERVERI ---
mcpInit() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('agentscope_mcp_servers') || 'null'); } catch (e) { /* ignoriši neispravan JSON */ }
if (saved && Array.isArray(saved) && saved.length) {
const savedIds = new Set(saved.map(s => s.id));
this.mcpServers = saved.slice();
MCP_DEFAULTS.forEach(d => { if (!savedIds.has(d.id)) this.mcpServers.push({ ...d, enabled: false }); });
} else {
this.mcpServers = MCP_DEFAULTS.map(d => ({ ...d, enabled: false }));
}
this.mcpServers.forEach(s => { s.status = 'idle'; s.tools = null; s.error = null; });
this.mcpRenderList();
// Serveri koji su bili uključeni u prošloj sesiji - tiho pokušaj ponovnu konekciju
this.mcpServers.filter(s => s.enabled).forEach(s => this.mcpConnect(s));
},
mcpSave() {
const toSave = this.mcpServers.map(({ tools, status, error, ...rest }) => rest);
localStorage.setItem('agentscope_mcp_servers', JSON.stringify(toSave));
},
mcpRenderList() {
UI.mcpList.innerHTML = '';
this.mcpServers.forEach(s => {
const row = document.createElement('div');
row.className = 'mcp-row';
const statusText = {
idle: s.enabled ? 'Čeka konekciju...' : 'Isključen',
connecting: 'Povezivanje...',
connected: `Povezan - ${(s.tools || []).length} alat(a)`,
error: 'Greška: ' + (s.error || 'nepoznata')
}[s.status] || '';
const label = document.createElement('label');
label.className = 'mcp-row-toggle';
label.innerHTML = `
<input type="checkbox" ${s.enabled ? 'checked' : ''}>
<div>
<div class="mcp-row-name">${this.escapeHtml(s.name)}</div>
${s.note ? `<div class="mcp-row-note">${this.escapeHtml(s.note)}</div>` : ''}
<div class="mcp-row-status ${s.status}">${this.escapeHtml(statusText)}</div>
</div>`;
label.querySelector('input').addEventListener('change', (e) => this.mcpToggle(s.id, e.target.checked));
if (s.headerName) {
const keyRow = document.createElement('div');
keyRow.style.cssText = 'display:flex; gap:0.4rem; margin-top:0.4rem; width:100%;';
keyRow.innerHTML = `
<input type="password" placeholder="${this.escapeHtml(s.headerName)} (tvoj ključ/token)" value="${this.escapeHtml(s.headerValue || '')}" style="flex:1; padding:0.5rem; font-size:0.8rem;">
<button type="button" class="save-btn" style="width:auto; padding:0.5rem 0.8rem; font-size:0.8rem;">Spremi</button>`;
const keyInput = keyRow.querySelector('input');
keyRow.querySelector('button').addEventListener('click', () => {
s.headerValue = keyInput.value.trim();
this.mcpSave();
if (s.enabled) this.mcpConnect(s);
});
label.querySelector('div').appendChild(keyRow);
}
row.appendChild(label);
if (!s.builtin) {
const removeBtn = document.createElement('button');
removeBtn.className = 'mcp-remove';
removeBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
removeBtn.title = 'Ukloni server';
removeBtn.addEventListener('click', () => this.mcpRemove(s.id));
row.appendChild(removeBtn);
}
UI.mcpList.appendChild(row);
});
},
mcpToggle(id, checked) {
const s = this.mcpServers.find(x => x.id === id);
if (!s) return;
s.enabled = checked;
this.mcpSave();
if (checked) {
this.mcpConnect(s);
} else {
s.status = 'idle';
s.tools = null;
this.mcpRenderList();
}
},
mcpRemove(id) {
this.mcpServers = this.mcpServers.filter(s => s.id !== id);
this.mcpSave();
this.mcpRenderList();
},
mcpAddCustom() {
const name = UI.mcpNewName.value.trim();
const url = UI.mcpNewUrl.value.trim();
const headerName = UI.mcpNewHeaderName.value.trim();
const headerValue = UI.mcpNewHeaderValue.value.trim();
if (!name || !url) {
this.addSystemMessage('⚠️ Unesi barem naziv i URL MCP servera.');
return;
}
this.mcpServers.push({
id: 'custom_' + Date.now(),
name, url, headerName, headerValue,
builtin: false, enabled: false, status: 'idle', tools: null, error: null
});
UI.mcpNewName.value = '';
UI.mcpNewUrl.value = '';
UI.mcpNewHeaderName.value = '';
UI.mcpNewHeaderValue.value = '';
this.mcpSave();
this.mcpRenderList();
},
// Šalje jedan JSON-RPC 2.0 zahtjev MCP serveru (Streamable HTTP transport) i vraća
// "result" polje odgovora. Podržava i JSON i SSE (text/event-stream) format odgovora,
// pošto neki serveri vraćaju jedno, a neki drugo za isti endpoint.
async mcpRequest(server, method, params) {
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' };
if (server.headerName && server.headerValue) {
let val = server.headerValue;
if (/^authorization$/i.test(server.headerName) && !/^(bearer|basic)\s/i.test(val)) {
val = 'Bearer ' + val;
}
headers[server.headerName] = val;
}
let response;
try {
response = await fetch(server.url, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params: params || {} })
});
} catch (e) {
throw new Error('Konekcija nije uspjela (CORS ili mrežna greška): ' + e.message);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
let payload;
if (contentType.includes('text/event-stream')) {
const text = await response.text();
const dataLine = text.split('\n').reverse().find(l => l.startsWith('data:'));
payload = dataLine ? JSON.parse(dataLine.slice(5).trim()) : null;
} else {
payload = await response.json().catch(() => null);
}
if (!payload) throw new Error('Prazan ili neočekivan odgovor sa servera.');
if (payload.error) throw new Error(payload.error.message || 'MCP greška');
return payload.result;
},
async mcpConnect(server) {
server.status = 'connecting';
server.error = null;
this.mcpRenderList();
try {
await this.mcpRequest(server, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'AgentScope AI', version: '1.0' }
});
const toolsRes = await this.mcpRequest(server, 'tools/list', {});
server.tools = (toolsRes && toolsRes.tools) || [];
server.status = 'connected';
} catch (e) {
server.status = 'error';
server.error = e.message;
}
this.mcpRenderList();
},
// Vraća "tools" definicije (OpenAI-kompatibilan format) za sve povezane MCP servere,
// da bi se spojile sa ugrađenim toolDefinitions prije slanja modelu.
getMcpToolDefinitions() {
const defs = [];
this.mcpServers.filter(s => s.enabled && s.status === 'connected' && s.tools).forEach(s => {
s.tools.forEach(t => {
defs.push({
type: 'function',
function: {
name: `mcp__${s.id}__${t.name}`,
description: `[MCP: ${s.name}] ${t.description || ''}`.slice(0, 1000),
parameters: t.inputSchema || { type: 'object', properties: {} }
}
});
});
});
return defs;
},
loadSettings() {
const saved = localStorage.getItem('agentscope_settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
// Spoji sa defaultima da stariji zapisi (npr. prazan systemPrompt) ne pokvare UI
this.config = { ...this.config, ...parsed };
if (!this.config.systemPrompt) {
this.config.systemPrompt = 'Ti si pomoćni asistent. Kada korisnik traži da napraviš nešto vizuelno ili interaktivno (3D scenu, animaciju, igru, sat i sl.), odmah vrati KOMPLETAN izvršiv kod u jednom ```html code bloku (uključi <script> tagove, po potrebi Three.js sa cdnjs.cloudflare.com CDN-a) — nemoj samo najavljivati šta ćeš uraditi, nego odmah generiši gotov kod.';
}
if (!this.config.maxTokens) {
this.config.maxTokens = 4096;
}
if (typeof this.config.toolsEnabled !== 'boolean') {
this.config.toolsEnabled = true;
}
if (typeof this.config.streamingEnabled !== 'boolean') {
this.config.streamingEnabled = true;
}
if (!this.providers[this.config.provider]) {
this.config.provider = 'huggingface';
}
} catch (e) {
console.error('Neispravne sačuvane postavke, koristim default.', e);
}
}
UI.providerSelect.value = this.config.provider;
UI.apiKeyInput.value = this.config.apiKey;
UI.systemPromptInput.value = this.config.systemPrompt;
UI.maxTokensInput.value = this.config.maxTokens;
UI.toolsEnabledInput.checked = this.config.toolsEnabled;
UI.streamingEnabledInput.checked = this.config.streamingEnabled;
this.updateApiKeyProviderHint(this.config.provider);
},
// Ažurira napomenu ispod polja za API ključ u zavisnosti od odabranog providera
// (npr. da NVIDIA/SambaNova ne rade iz browsera zbog CORS-a, ili da Pollinations
// ključ nije obavezan ali je preporučen).
updateApiKeyProviderHint(providerKey) {
const cfg = this.providers[providerKey];
if (!UI.apiKeyProviderHint || !cfg) return;
if (cfg.corsBlocked) {
UI.apiKeyProviderHint.textContent = `⚠️ ${cfg.label} ne dozvoljava direktne pozive iz browsera (CORS ograničenje na strani providera) - poruke neće raditi bez backend proxy-ja, bez obzira na ključ.`;
} else if (providerKey === 'pollinations') {
UI.apiKeyProviderHint.textContent = 'Kod Pollinations AI ključ nije obavezan za javne modele, ali je preporučen - besplatan pk_ ključ napraviš na enter.pollinations.ai. Bez ključa je limit vrlo nizak (~1 zahtjev/sat).';
} else {
UI.apiKeyProviderHint.textContent = '';
}
},
saveSettings() {
this.config.provider = UI.providerSelect.value;
this.config.apiKey = UI.apiKeyInput.value.trim();
this.config.systemPrompt = UI.systemPromptInput.value;
this.config.model = UI.modelSelect.value || this.config.model;
this.config.maxTokens = parseInt(UI.maxTokensInput.value, 10) || 4096;
this.config.toolsEnabled = UI.toolsEnabledInput.checked;
this.config.streamingEnabled = UI.streamingEnabledInput.checked;
localStorage.setItem('agentscope_settings', JSON.stringify(this.config));
UI.modal.classList.remove('open');
this.addSystemMessage("Postavke spremljene.");
},
clearSettings() {
localStorage.removeItem('agentscope_settings');
this.config = {
provider: 'huggingface',
apiKey: '',
model: '',
maxTokens: 4096,
toolsEnabled: true,
streamingEnabled: true,
systemPrompt: 'Ti si pomoćni asistent. Kada korisnik traži da napraviš nešto vizuelno ili interaktivno (3D scenu, animaciju, igru, sat i sl.), odmah vrati KOMPLETAN izvršiv kod u jednom ```html code bloku (uključi <script> tagove, po potrebi Three.js sa cdnjs.cloudflare.com CDN-a) — nemoj samo najavljivati šta ćeš uraditi, nego odmah generiši gotov kod.'
};
UI.providerSelect.value = this.config.provider;
UI.apiKeyInput.value = '';
UI.systemPromptInput.value = this.config.systemPrompt;
UI.maxTokensInput.value = this.config.maxTokens;
UI.toolsEnabledInput.checked = this.config.toolsEnabled;
UI.streamingEnabledInput.checked = this.config.streamingEnabled;
this.updateApiKeyProviderHint(this.config.provider);
this.loadModelsIntoSelect(this.config.provider, '');
this.addSystemMessage('Sačuvane postavke (uklj. API ključ) su obrisane.');
},
// Pokušaj dinamičkog fetch-a liste modela sa modelsUrl endpointa datog providera.
// Vraća sortiran niz string ID-ova modela, ili null ako fetch/parsing ne uspije
// (nedostupan endpoint, CORS, neispravan ključ, neočekivan format odgovora...).
async fetchDynamicModels(providerKey, apiKey) {
const cfg = this.providers[providerKey];
if (!cfg || !cfg.modelsUrl) return null;
try {
const headers = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
const res = await fetch(cfg.modelsUrl, { headers });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
// Podržava standardni OpenAI oblik { data: [...] } i nekoliko alternativa
const list = Array.isArray(data.data) ? data.data
: Array.isArray(data.models) ? data.models
: Array.isArray(data) ? data
: null;
if (!list || !list.length) throw new Error('Prazan/neočekivan odgovor');
let ids = list
.map(m => (typeof m === 'string' ? m : (m.id || m.name || m.model || '')))
.filter(Boolean)
.map(id => id.replace(/^models\//, '')); // Gemini native format ima prefiks "models/"
// HuggingFace router vraća bazni ID modela bez policy sufiksa;
// dodaj ":fastest" da poziv bude validan (vidi napomenu kod ":auto" greške).
if (providerKey === 'huggingface') {
ids = ids.map(id => id.includes(':') ? id : `${id}:fastest`);
}
return [...new Set(ids)].sort();
} catch (e) {
console.warn(`Dinamički fetch modela (${cfg.label}) nije uspio:`, e.message);
return null;
}
},
// Puni <select id="model-select"> - prvo pokušava dinamički fetch, uz fallback
// na ugrađenu (staticModels) listu. opts.manual=true prikazuje status poruku u čatu
// (koristi se kad korisnik klikne dugme za osvježavanje ili promijeni providera).
async loadModelsIntoSelect(providerKey, apiKey, opts = {}) {
const cfg = this.providers[providerKey];
if (!cfg) return;
UI.modelSelect.innerHTML = '<option value="" disabled selected>Učitavanje modela...</option>';
UI.modelStatus.textContent = 'Dohvatam listu modela...';
UI.refreshModelsBtn.disabled = true;
let models = cfg.staticModels;
let dynamic = false;
if (cfg.modelsUrl && (!cfg.keyRequired || apiKey)) {
const fetched = await this.fetchDynamicModels(providerKey, apiKey);
if (fetched && fetched.length) {
models = fetched;
dynamic = true;
}
}
UI.modelSelect.innerHTML = '';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
UI.modelSelect.appendChild(opt);
});
// Zadrži prethodno odabrani model ako je i dalje na listi i provider se nije promijenio
const preferred = (this.config.provider === providerKey && this.config.model && models.includes(this.config.model))
? this.config.model
: models[0];
UI.modelSelect.value = preferred;
UI.refreshModelsBtn.disabled = false;
if (dynamic) {
UI.modelStatus.textContent = `✅ Dinamički dohvaćeno ${models.length} modela (${cfg.label}).`;
} else if (cfg.modelsUrl) {
UI.modelStatus.textContent = `⚠️ Dinamički fetch nije uspio — koristi se ugrađena lista (${models.length}).`;
} else {
UI.modelStatus.textContent = `ℹ️ ${cfg.label} ne podržava listing endpoint — koristi se ugrađena lista (${models.length}).`;
}
if (opts.manual) {
this.addSystemMessage(UI.modelStatus.textContent);
}
},
// --- SIDEBAR / ISTORIJA RAZGOVORA ---
// Sve u localStorage: 'agentscope_chats' (niz razgovora) i
// 'agentscope_current_chat_id' (koji je trenutno otvoren).
loadChats() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('agentscope_chats') || 'null'); } catch (e) { /* ignoriši neispravan JSON */ }
this.chats = Array.isArray(saved) ? saved : [];
const savedCurrentId = localStorage.getItem('agentscope_current_chat_id');
const existing = this.chats.find(c => c.id === savedCurrentId);
if (existing) {
this.currentChatId = existing.id;
this.messages = existing.messages || [];
this.renderChatMessages();
} else if (this.chats.length) {
this.currentChatId = this.chats[0].id;
this.messages = this.chats[0].messages || [];
this.renderChatMessages();
} else {
this.startNewChat(true);
}
this.renderSidebarChatList();
},
saveChats() {
localStorage.setItem('agentscope_chats', JSON.stringify(this.chats));
if (this.currentChatId) localStorage.setItem('agentscope_current_chat_id', this.currentChatId);
},
// Snima trenutni this.messages u odgovarajući unos u this.chats. Poziva se
// nakon svake razmjene poruka. Naslov razgovora se automatski izvodi iz
// prve korisničke poruke (dok god je naslov još uvijek podrazumijevan).
persistCurrentChat() {
if (!this.currentChatId) return;
let chat = this.chats.find(c => c.id === this.currentChatId);
if (!chat) {
chat = { id: this.currentChatId, title: 'Novi razgovor', messages: [], updatedAt: Date.now() };
this.chats.unshift(chat);
}
chat.messages = this.messages;
chat.updatedAt = Date.now();
if (!chat.title || chat.title === 'Novi razgovor') {
const firstUserMsg = this.messages.find(m => m.role === 'user');
if (firstUserMsg) {
const text = (this.extractDisplayText(firstUserMsg.content) || '').trim();
if (text) chat.title = text.slice(0, 45) + (text.length > 45 ? '…' : '');
}
}
this.chats.sort((a, b) => b.updatedAt - a.updatedAt);
this.saveChats();
this.renderSidebarChatList();
},
// Kreira nov, prazan razgovor i prebacuje se na njega
startNewChat(skipUiReset) {
const id = 'chat_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
const chat = { id, title: 'Novi razgovor', messages: [], updatedAt: Date.now() };
this.chats.unshift(chat);
this.currentChatId = id;
this.messages = [];
this.saveChats();
this.renderSidebarChatList();
if (!skipUiReset) {
UI.chatContainer.innerHTML = '<div class="message system">Dobrodošao. Odaberi model i unesi API Ključ u postavkama za početak.</div>';
this.closeSidebar();
}
},
// Prebacuje se na sačuvani razgovor i ponovo iscrtava poruke iz njegove historije
switchChat(id) {
if (id === this.currentChatId) { this.closeSidebar(); return; }
const chat = this.chats.find(c => c.id === id);
if (!chat) return;
this.currentChatId = id;
this.messages = chat.messages || [];
localStorage.setItem('agentscope_current_chat_id', id);
this.renderChatMessages();
this.renderSidebarChatList();
this.closeSidebar();
},
deleteChat(id, event) {
if (event) event.stopPropagation();
if (!confirm('Obrisati ovaj razgovor? Ovo se ne može poništiti.')) return;
this.chats = this.chats.filter(c => c.id !== id);
this.saveChats();
if (this.currentChatId === id) {
if (this.chats.length) {
this.switchChat(this.chats[0].id);
} else {
this.startNewChat();
}
} else {
this.renderSidebarChatList();
}
},
// Izvlači tekst za prikaz iz sadržaja poruke (može biti string ili
// multimodal niz [{type:'text',...}, {type:'image_url',...}])
extractDisplayText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
const textPart = content.find(p => p.type === 'text');
return textPart ? textPart.text : '[slika]';
}
return '';
},
// AI poruka renderovana odjednom (bez typewriter efekta) - koristi se pri
// ponovnom iscrtavanju sačuvane historije razgovora (nema potrebe za animacijom).
addAIMessageInstant(text) {
const div = document.createElement('div');
div.className = 'message ai';
const parts = this.parseMessageParts(text);
parts.forEach(part => {
if (part.type === 'code') {
div.appendChild(this.buildCodeBlockElement(part.lang, part.content));
} else if (part.content) {
const span = document.createElement('span');
span.className = 'msg-text';
span.textContent = part.content;
div.appendChild(span);
}
});
UI.chatContainer.appendChild(div);
},
// Ponovo iscrtava chat-container na osnovu this.messages. Interne
// "vodovodne" poruke (role: 'tool', ili assistant poruke sa tool_calls a
// bez sadržaja) se ne prikazuju ponovo - ostaju samo kao API kontekst.
renderChatMessages() {
UI.chatContainer.innerHTML = '';
if (!this.messages.length) {
UI.chatContainer.innerHTML = '<div class="message system">Dobrodošao. Odaberi model i unesi API Ključ u postavkama za početak.</div>';
return;
}
this.messages.forEach(msg => {
if (msg.role === 'user') {
const text = this.extractDisplayText(msg.content);
const imagePart = Array.isArray(msg.content) ? msg.content.find(p => p.type === 'image_url') : null;
this.addUserMessage(text, imagePart ? { type: 'image', dataUrl: imagePart.image_url.url } : null);
} else if (msg.role === 'assistant' && msg.content && msg.content.trim()) {
this.addAIMessageInstant(msg.content);
}
});
this.scrollToBottom();
},
renderSidebarChatList() {
UI.sidebarChatList.innerHTML = '';
this.chats.forEach(chat => {
const item = document.createElement('div');
item.className = 'sidebar-chat-item' + (chat.id === this.currentChatId ? ' active' : '');
const icon = document.createElement('i');
icon.className = 'fa-regular fa-message';
item.appendChild(icon);
const label = document.createElement('span');
label.textContent = chat.title || 'Novi razgovor';
item.appendChild(label);
const delBtn = document.createElement('button');
delBtn.className = 'sidebar-chat-delete';
delBtn.title = 'Obriši razgovor';
delBtn.innerHTML = '<i class="fa-regular fa-trash-can"></i>';
delBtn.addEventListener('click', (e) => this.deleteChat(chat.id, e));
item.appendChild(delBtn);
item.addEventListener('click', () => this.switchChat(chat.id));
UI.sidebarChatList.appendChild(item);
});
},
openSidebar() {
UI.sidebar.classList.add('open');
UI.sidebarOverlay.classList.add('open');
},
closeSidebar() {
UI.sidebar.classList.remove('open');
UI.sidebarOverlay.classList.remove('open');
},
bindEvents() {
// Textarea auto-resize
UI.userInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
if (!App.isGenerating) {
UI.sendBtn.disabled = this.value.trim() === '' && !App.pendingAttachment;
}
});
// Send on Enter (but Shift+Enter for new line)
UI.userInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!this.isGenerating) this.sendMessage();
}
});
UI.sendBtn.addEventListener('click', () => {
if (this.isGenerating) {
this.stopGeneration();
} else {
this.sendMessage();
}
});
// Sidebar (istorija razgovora)
UI.openSidebarBtn.addEventListener('click', () => this.openSidebar());
UI.closeSidebarBtn.addEventListener('click', () => this.closeSidebar());
UI.sidebarOverlay.addEventListener('click', () => this.closeSidebar());
UI.sidebarNewChatBtn.addEventListener('click', () => this.startNewChat());
// File upload (attach button otvara sistemski file picker)
UI.attachBtn.addEventListener('click', () => UI.fileInput.click());
UI.fileInput.addEventListener('change', (e) => this.handleFileSelect(e.target.files[0]));
// Modal controls
UI.openSettingsBtn.addEventListener('click', () => {
UI.modal.classList.add('open');
// Osvježi listu modela svaki put kad se postavke otvore (koristi trenutno sačuvane vrijednosti)
this.loadModelsIntoSelect(UI.providerSelect.value, UI.apiKeyInput.value.trim());
});
UI.closeSettingsBtn.addEventListener('click', () => UI.modal.classList.remove('open'));
// MCP Serveri modal
UI.mcpBtn.addEventListener('click', () => {
this.mcpRenderList();
UI.mcpModal.classList.add('open');
});
UI.closeMcpBtn.addEventListener('click', () => UI.mcpModal.classList.remove('open'));
UI.closeMcpFooterBtn.addEventListener('click', () => UI.mcpModal.classList.remove('open'));
UI.mcpAddBtn.addEventListener('click', () => this.mcpAddCustom());
// Preview modal (zatvaranje + čišćenje iframe-a da se zaustavi izvršavanje/animacije)
UI.closePreviewBtn.addEventListener('click', () => {
UI.previewModal.classList.remove('open');
UI.previewFrame.srcdoc = '';
});
UI.saveSettingsBtn.addEventListener('click', () => this.saveSettings());
UI.clearSettingsBtn.addEventListener('click', () => this.clearSettings());
// Promjena providera -> odmah pokušaj dinamički fetch modela za novi provider
UI.providerSelect.addEventListener('change', () => {
this.loadModelsIntoSelect(UI.providerSelect.value, UI.apiKeyInput.value.trim());
this.updateApiKeyProviderHint(UI.providerSelect.value);
});
// Ručno osvježavanje liste modela (npr. nakon što korisnik upiše/promijeni API ključ)
UI.refreshModelsBtn.addEventListener('click', () => {
this.loadModelsIntoSelect(UI.providerSelect.value, UI.apiKeyInput.value.trim(), { manual: true });
});
// PWA Install
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
UI.installPrompt.style.display = 'block';
UI.installBtn.addEventListener('click', () => {
UI.installPrompt.style.display = 'none';
e.prompt();
});
});
},
setupPWAInstall() {
window.addEventListener('appinstalled', () => {
UI.installPrompt.style.display = 'none';
});
},
addUserMessage(text, attachment) {
const div = document.createElement('div');
div.className = 'message user';
if (attachment) {
if (attachment.type === 'image') {
const img = document.createElement('img');
img.src = attachment.dataUrl;
img.style.cssText = 'max-width:100%;border-radius:8px;margin-bottom:0.5rem;display:block;';
div.appendChild(img);
} else {
const chip = document.createElement('div');
chip.style.cssText = 'font-size:0.8rem;opacity:0.85;margin-bottom:0.4rem;';
chip.innerHTML = `<i class="fa-solid fa-file-lines"></i> ${attachment.name}`;
div.appendChild(chip);
}
}
if (text) {
const span = document.createElement('span');
span.className = 'msg-text';
span.textContent = text;
div.appendChild(span);
}
UI.chatContainer.appendChild(div);
this.scrollToBottom();
},
// Razbija AI odgovor na naizmjenične dijelove: obični tekst i fenced code
// blokove (```jezik ... ```). Koristi se i za typewriter render i za
// instant render (npr. ako bi typewriter trebalo isključiti).
parseMessageParts(text) {
const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = codeBlockRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
}
parts.push({
type: 'code',
lang: (match[1] || '').toLowerCase(),
content: match[2]
});
lastIndex = codeBlockRegex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) });
}
return parts;
},
// Mapira jezik u ekstenziju fajla za dugme "Preuzmi"
langToExtension(lang) {
const map = {
javascript: 'js', js: 'js', jsx: 'jsx', typescript: 'ts', ts: 'ts',
python: 'py', py: 'py', html: 'html', xml: 'xml', css: 'css',
json: 'json', bash: 'sh', sh: 'sh', shell: 'sh', java: 'java',
c: 'c', cpp: 'cpp', 'c++': 'cpp', csharp: 'cs', cs: 'cs',
go: 'go', rust: 'rs', rb: 'rb', ruby: 'rb', php: 'php',
sql: 'sql', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md'
};
return map[lang] || 'txt';
},
// Kreira potpuni code-block-wrapper element: header (labela jezika +
// kopiraj/preuzmi/prikaži dugmad) i <pre><code> sa syntax highlighting-om.
buildCodeBlockElement(lang, code) {
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
const header = document.createElement('div');
header.className = 'code-block-header';
const label = document.createElement('span');
label.className = 'code-lang-label';
label.textContent = lang || 'text';
header.appendChild(label);
const actions = document.createElement('div');
actions.className = 'code-actions';
// Kopiraj
const copyBtn = document.createElement('button');
copyBtn.className = 'code-action-btn copy-btn';
copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i> Kopiraj';
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(code);
} catch (e) {
// Fallback ako Clipboard API nije dostupan
const ta = document.createElement('textarea');
ta.value = code;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (err) { /* ignoriši */ }
document.body.removeChild(ta);
}
const original = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fa-solid fa-check"></i> Kopirano';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.innerHTML = original;
copyBtn.classList.remove('copied');
}, 1500);
});
actions.appendChild(copyBtn);
// Preuzmi
const downloadBtn = document.createElement('button');
downloadBtn.className = 'code-action-btn download-btn';
downloadBtn.innerHTML = '<i class="fa-solid fa-download"></i>';
downloadBtn.title = 'Preuzmi';
downloadBtn.addEventListener('click', () => {
const ext = this.langToExtension(lang);
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `snippet.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
actions.appendChild(downloadBtn);
// Prikaži rezultat - samo ako kod izgleda izvršivo/renderabilno
const looksRenderable = /html|xml|svg|js|javascript|jsx/.test(lang) || /<[a-z][\s\S]*>/i.test(code);
if (looksRenderable) {
const previewBtn = document.createElement('button');
previewBtn.className = 'code-action-btn preview-btn';
previewBtn.innerHTML = '<i class="fa-solid fa-eye"></i> Prikaži';
previewBtn.addEventListener('click', () => this.showPreview(code, lang));
actions.appendChild(previewBtn);
}
header.appendChild(actions);
wrapper.appendChild(header);
const pre = document.createElement('pre');
pre.className = 'code-block';
const codeEl = document.createElement('code');
codeEl.className = lang ? `language-${lang}` : '';
codeEl.textContent = code;
pre.appendChild(codeEl);
wrapper.appendChild(pre);
if (window.hljs) {
try { hljs.highlightElement(codeEl); } catch (e) { /* ignoriši nepoznat jezik */ }
}
return wrapper;
},
addAIMessage(text) {
const div = document.createElement('div');
div.className = 'message ai';
UI.chatContainer.appendChild(div);
const parts = this.parseMessageParts(text);
this.typewriterRender(parts, div);
},
// Kreira praznu AI poruku (div) u koju se sirovi tekst uživo dopisuje čim
// stigne sa API-ja (pravi streaming, bez čekanja na cijeli odgovor). Kad
// stream završi, finalize() zamjenjuje sirovi tekst punim renderom (parsirani
// code blokovi, syntax highlighting, toolbar dugmad) - isto kao addAIMessage,
// samo bez lažne typewriter animacije jer je tekst već ispisan uživo.
createStreamingMessageDiv() {
const div = document.createElement('div');
div.className = 'message ai';
const textSpan = document.createElement('span');
textSpan.className = 'msg-text';
div.appendChild(textSpan);
const cursor = document.createElement('span');
cursor.className = 'typing-cursor';
div.appendChild(cursor);
UI.chatContainer.appendChild(div);
this.scrollToBottom();
return {
div,
textSpan,
cursor,
rawText: '',
append(deltaText) {
this.rawText += deltaText;
this.textSpan.textContent = this.rawText;
App.scrollToBottom();
},
finalize(fullText) {
this.cursor.remove();
this.div.innerHTML = '';
const parts = App.parseMessageParts(fullText);
parts.forEach(part => {
if (part.type === 'code') {
this.div.appendChild(App.buildCodeBlockElement(part.lang, part.content));
} else if (part.content) {
const span = document.createElement('span');
span.className = 'msg-text';
span.textContent = part.content;
this.div.appendChild(span);
}
});
App.scrollToBottom();
},
remove() {
this.div.remove();
}
};
},
// Ispisuje dijelove poruke u kontejner "riječ po riječ" (efekat pisaće
// mašine) za obični tekst. Code blokovi se renderuju odjednom (u punom
// obliku, sa syntax highlighting-om i toolbar dugmadima) čim ih typewriter
// dostigne - animirati otkucavanje koda znak-po-znak nema smisla jer bi
// highlight.js morao ponovo obrađivati kod pri svakom znaku.
typewriterRender(parts, container) {
const cursor = document.createElement('span');
cursor.className = 'typing-cursor';
container.appendChild(cursor);
let partIndex = 0;
let words = [];
let wordIndex = 0;
let currentSpan = null;
const finish = () => {
cursor.remove();
this.scrollToBottom();
};
const step = () => {
if (partIndex >= parts.length) {
finish();
return;
}
const part = parts[partIndex];
if (part.type === 'code') {
const codeEl = this.buildCodeBlockElement(part.lang, part.content);
container.insertBefore(codeEl, cursor);
partIndex++;
this.scrollToBottom();
setTimeout(step, 40);
return;
}
// type === 'text'
if (!currentSpan) {
currentSpan = document.createElement('span');
currentSpan.className = 'msg-text';
container.insertBefore(currentSpan, cursor);
words = part.content.match(/\S+\s*|\s+/g) || [];
wordIndex = 0;
}
if (wordIndex < words.length) {
currentSpan.textContent += words[wordIndex];
wordIndex++;
this.scrollToBottom();
setTimeout(step, 18);
} else {
currentSpan = null;
partIndex++;
setTimeout(step, 0);
}
};
step();
},
// Prikazuje generisani kod u iframe-u (live preview), umjesto da ostane samo tekst
showPreview(code, lang) {
const l = (lang || '').toLowerCase();
let doc;
if (l === 'html' || l === 'xml' || /<!DOCTYPE|<html[\s>]/i.test(code)) {
// Već je kompletan HTML dokument (ili fragment) - koristi direktno
doc = code;
} else if (l === 'css') {
doc = `<!DOCTYPE html><html><head><style>${code}</style></head>
<body style="font-family:sans-serif;color:#333;padding:1rem;">
Ovo je samo CSS kod bez HTML strukture, pa nema šta vizuelno prikazati u kontekstu.
</body></html>`;
} else {
// Tretiraj kao JS (npr. Three.js scena) - napravi minimalan HTML omotač
doc = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>html,body{margin:0;height:100%;background:#0a0a0a;overflow:hidden;}canvas{display:block;}</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"><\/script>
</head>
<body>
<script>
try {
${code}
} catch (e) {
document.body.innerHTML = '<pre style="color:#f66;padding:1rem;font-family:monospace;white-space:pre-wrap;">Greška pri izvršavanju koda:\\n' + e.message + '</pre>';
}
<\/script>
</body>
</html>`;
}
UI.previewFrame.srcdoc = doc;
UI.previewModal.classList.add('open');
},
addSystemMessage(text) {
const div = document.createElement('div');
div.className = 'message system';
div.textContent = text;
UI.chatContainer.appendChild(div);
this.scrollToBottom();
},
// Formatiran prikaz rezultata execute_code alata (umjesto sirovog JSON stringa).
addExecResultMessage(execResult) {
const wrapper = document.createElement('div');
wrapper.className = 'exec-result ' + (execResult.success ? 'success' : 'error');
const header = document.createElement('div');
header.className = 'exec-result-header';
if (execResult.success) {
header.innerHTML = `<i class="fa-solid fa-circle-check"></i><span>Kod izvršen</span>`;
} else {
const { type, line } = execResult.error || {};
header.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i><span>${this.escapeHtml(type || 'Greška')}</span>` +
(line ? `<span class="exec-error-meta">linija ${line}</span>` : '');
}
wrapper.appendChild(header);
const body = document.createElement('div');
if (execResult.success) {
body.className = 'exec-result-body';
const resultText = execResult.result === null || execResult.result === undefined
? '(nema povratne vrijednosti)'
: (typeof execResult.result === 'string' ? execResult.result : JSON.stringify(execResult.result, null, 2));
body.textContent = resultText;
} else {
body.className = 'exec-result-body error-text';
body.textContent = execResult.error?.message || 'Nepoznata greška.';
}
wrapper.appendChild(body);
if (execResult.logs && execResult.logs.length) {
const logs = document.createElement('div');
logs.className = 'exec-logs';
logs.textContent = execResult.logs.join('\n');
wrapper.appendChild(logs);
}
UI.chatContainer.appendChild(wrapper);
this.scrollToBottom();
},
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
scrollToBottom() {
UI.chatContainer.scrollTop = UI.chatContainer.scrollHeight;
},
async sendMessage() {
const text = UI.userInput.value.trim();
const attachment = this.pendingAttachment;
if (!text && !attachment) return;
// Reset Input
UI.userInput.value = '';
UI.userInput.style.height = 'auto';
// UI Updates - dugme za slanje postaje "Stop" dok traje generisanje
this.isGenerating = true;
this.abortController = new AbortController();
UI.sendBtn.disabled = false;
UI.sendBtn.classList.add('generating');
UI.sendBtn.title = 'Zaustavi generisanje';
UI.loadingSpinner.style.display = 'none';
UI.sendIcon.style.display = 'none';
UI.stopIcon.style.display = 'block';
this.addUserMessage(text, attachment);
// Sastavi sadržaj poruke - multimodal format (tekst + slika) ako postoji prilog
let userContent;
if (attachment) {
if (attachment.type === 'image') {
userContent = [];
if (text) userContent.push({ type: 'text', text });
userContent.push({ type: 'image_url', image_url: { url: attachment.dataUrl } });
} else {
userContent = `${text ? text + '\n\n' : ''}[Sadržaj priloženog fajla: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
}
} else {
userContent = text;
}
this.messages.push({ role: 'user', content: userContent });
this.clearAttachment();
try {
await this.runConversationTurn();
} catch (error) {
if (error.name === 'AbortError') {
this.addSystemMessage('⏹️ Generisanje zaustavljeno.');
} else {
console.error(error);
this.addSystemMessage("Greška: " + error.message);
}
} finally {
this.isGenerating = false;
this.abortController = null;
UI.sendBtn.classList.remove('generating');
UI.sendBtn.title = 'Pošalji poruku';
UI.loadingSpinner.style.display = 'none';
UI.stopIcon.style.display = 'none';
UI.sendIcon.style.display = 'block';
UI.sendBtn.disabled = UI.userInput.value.trim() === '' && !this.pendingAttachment;
this.persistCurrentChat();
}
},
// Prekida trenutni fetch/stream poziv modelu (klik na Stop dok generiše).
// callChatAPI/streamChatAPI prosljeđuju AbortSignal, pa fetch/reader.read()
// baca AbortError koji runConversationTurn hvata i lijepo finalizuje
// (zadrži djelimično ispisan tekst umjesto da ga baci).
stopGeneration() {
if (this.abortController) {
this.abortController.abort();
}
},
// Jedan "krug" razgovora sa API-jem - može se ponoviti više puta u istom potezu
// ako model zatraži tool_calls (npr. execute_code pa onda konačan odgovor).
async runConversationTurn(depth = 0) {
if (depth > 6) {
this.addSystemMessage('⚠️ Previše uzastopnih poziva alata - zaustavljeno radi sigurnosti.');
return;
}
const { provider, apiKey, model, systemPrompt, maxTokens, toolsEnabled, streamingEnabled } = this.config;
const cfg = this.providers[provider];
if (!cfg) {
throw new Error("Nepoznat provider. Provjerite Postavke.");
}
if (cfg.keyRequired && !apiKey) {
throw new Error("API Ključ nije postavljen. Idite u Postavke.");
}
// Svi provideri koriste isti OpenAI-kompatibilan /v1/chat/completions format,
// pa se poziv gradi generički - endpoint i naziv parametra za max tokene
// (max_tokens vs. max_completion_tokens kod novijih OpenAI modela) dolaze
// iz providers konfiguracije. Nema više hardkodiranog limita od 500 tokena -
// koristi se vrijednost iz postavki (podesivo, default 4096).
const basePayload = {
model: model,
messages: [
{ role: "system", content: systemPrompt || "Ti si pomoćni asistent." },
...this.messages
]
};
basePayload[cfg.tokenParam] = maxTokens || 4096;
const headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
const payloadWithTools = {
...basePayload,
tools: [...this.toolDefinitions, ...this.getMcpToolDefinitions()],
tool_choice: 'auto'
};
let data;
let streamHandle = null;
try {
if (streamingEnabled) {
streamHandle = this.createStreamingMessageDiv();
const onDelta = (chunk) => streamHandle.append(chunk);
if (toolsEnabled) {
data = await this.streamChatAPI(cfg, headers, payloadWithTools, onDelta);
// Neki provideri/modeli ne podržavaju "tools" parametar uopšte -
// u tom slučaju probaj ponovo bez njega (na svježoj poruci, jer je
// prethodna možda djelimično ispisala pogrešan/prazan tekst).
if (data.__error && /tool/i.test(data.__error)) {
streamHandle.remove();
streamHandle = this.createStreamingMessageDiv();
data = await this.streamChatAPI(cfg, headers, basePayload, (chunk) => streamHandle.append(chunk));
}
} else {
data = await this.streamChatAPI(cfg, headers, basePayload, onDelta);
}
// Streaming nije uspio (CORS, ReadableStream nepodržan, mrežna greška...) -
// ukloni prazan/djelimičan div i padni nazad na običan (non-streaming) poziv,
// da korisnik ipak dobije odgovor.
if (data.__error) {
streamHandle.remove();
streamHandle = null;
if (toolsEnabled) {
data = await this.callChatAPI(cfg, headers, payloadWithTools);
if (data.__error && /tool/i.test(data.__error)) {
data = await this.callChatAPI(cfg, headers, basePayload);
}
} else {
data = await this.callChatAPI(cfg, headers, basePayload);
}
}
} else if (toolsEnabled) {
data = await this.callChatAPI(cfg, headers, payloadWithTools);
// Neki provideri/modeli ne podržavaju "tools" parametar uopšte -
// u tom slučaju automatski probaj ponovo bez njega.
if (data.__error && /tool/i.test(data.__error)) {
data = await this.callChatAPI(cfg, headers, basePayload);
}
} else {
data = await this.callChatAPI(cfg, headers, basePayload);
}
} catch (e) {
// Korisnik je kliknuo Stop (AbortController.abort()) - ako je streaming
// već ispisao dio teksta, zadrži ga kao konačan (djelimičan) odgovor
// umjesto da ga jednostavno odbaci; inače samo ukloni prazan div.
if (e.name === 'AbortError') {
const partial = (e.partialContent || '').trim();
if (streamHandle) {
if (partial) {
streamHandle.finalize(e.partialContent);
this.messages.push({ role: 'assistant', content: e.partialContent });
} else {
streamHandle.remove();
}
}
}
throw e;
}
if (data.__error) {
if (streamHandle) streamHandle.remove();
throw new Error(`${cfg.label} Error: ${data.__error}`);
}
const message = data.choices?.[0]?.message;
if (!message) {
if (streamHandle) streamHandle.remove();
throw new Error("Neočekivan format odgovora.");
}
// Model je zatražio poziv jednog ili više alata
if (message.tool_calls && message.tool_calls.length) {
// Ako je streaming uz poziv alata ispisao i neki tekst, prikaži ga u
// konačnom obliku; inače ukloni prazan streaming div (poruka sa alatom
// i konačan odgovor stižu odvojeno, u nastavku razgovora ispod).
if (streamHandle) {
if (message.content && message.content.trim()) {
streamHandle.finalize(message.content);
} else {
streamHandle.remove();
}
}
this.messages.push({
role: 'assistant',
content: message.content || '',
tool_calls: message.tool_calls
});
for (const call of message.tool_calls) {
let args = {};
try { args = JSON.parse(call.function.arguments || '{}'); } catch (e) { /* ignoriši neispravan JSON */ }
this.addSystemMessage(`🔧 Model koristi alat: ${call.function.name}`);
const result = await this.executeTool(call.function.name, args);
this.messages.push({
role: 'tool',
tool_call_id: call.id,
content: typeof result === 'string' ? result : JSON.stringify(result)
});
}
// Nastavi razgovor - pošalji rezultate alata nazad modelu za konačan odgovor
return this.runConversationTurn(depth + 1);
}
const responseText = message.content || '';
this.messages.push({ role: 'assistant', content: responseText });
if (responseText.trim()) {
if (streamHandle) {
streamHandle.finalize(responseText);
} else {
this.addAIMessage(responseText);
}
} else if (streamHandle) {
streamHandle.remove();
}
},
// Wrapper oko fetch() koji nikad ne baca grešku direktno, nego je vraća kao
// { __error: "..." } da runConversationTurn() može odlučiti da li da pokuša
// ponovo (npr. bez "tools" parametra) ili da je konačno prijavi korisniku.
async callChatAPI(cfg, headers, payload) {
// Poznati CORS-blokirani provideri (npr. NVIDIA NIM, SambaNova) - ne šalji
// ni pokušaj fetch, nego odmah javi jasnu poruku umjesto da korisnik čeka
// pa dobije sirovi "Failed to fetch".
if (cfg.corsBlocked) {
return { __error: `${cfg.label} ne dozvoljava direktne pozive iz browsera (CORS ograničenje na strani providera - nema Access-Control-Allow-Origin header). Ovo se ne može riješiti bez backend proxy-ja. Probaj drugog providera (npr. Gemini, OpenAI, Mistral, Hugging Face, Pollinations, Kimi, Qwen).` };
}
try {
const response = await fetch(cfg.chatUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: this.abortController ? this.abortController.signal : undefined
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
return { __error: data.error?.message || `HTTP ${response.status}` };
}
return data;
} catch (e) {
if (e.name === 'AbortError') throw e;
// "Failed to fetch" je generička poruka browsera - najčešći uzroci su
// CORS ograničenje providera, nema internet konekcije, ili blokira ga
// ad-blocker/Brave Shields. Dodajemo objašnjenje da korisnik zna šta da proba.
return { __error: `${e.message} (mogući uzrok: CORS ograničenje providera, nema internet konekcije, ili blokira ga ad-blocker/Brave Shields)` };
}
},
// Isto kao callChatAPI, ali sa "stream: true" - čita SSE (text/event-stream)
// odgovor komad po komad i uživo prikazuje tekst preko onDelta callback-a, umjesto
// da se čeka cijeli odgovor. Vraća isti oblik podataka kao callChatAPI
// ({ choices: [{ message: {...} }] } ili { __error }) da bi runConversationTurn()
// mogao ostatak logike (tool_calls, retry bez "tools"...) tretirati identično.
async streamChatAPI(cfg, headers, payload, onDelta) {
if (cfg.corsBlocked) {
return { __error: `${cfg.label} ne dozvoljava direktne pozive iz browsera (CORS ograničenje na strani providera - nema Access-Control-Allow-Origin header). Probaj drugog providera.` };
}
let response;
try {
response = await fetch(cfg.chatUrl, {
method: 'POST',
headers,
body: JSON.stringify({ ...payload, stream: true }),
signal: this.abortController ? this.abortController.signal : undefined
});
} catch (e) {
if (e.name === 'AbortError') throw e;
return { __error: `${e.message} (mogući uzrok: CORS ograničenje providera, nema internet konekcije, ili blokira ga ad-blocker/Brave Shields)` };
}
if (!response.ok) {
let errBody = {};
try { errBody = await response.json(); } catch (e) { /* tijelo možda nije JSON */ }
return { __error: errBody.error?.message || `HTTP ${response.status}` };
}
if (!response.body || !response.body.getReader) {
return { __error: 'Streaming (ReadableStream) nije podržan u ovom browseru.' };
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
const toolCallsAcc = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Zadnja (možda nepotpuna) linija se čuva za sljedeći chunk
buffer = lines.pop();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith('data:')) continue;
const dataStr = line.slice(5).trim();
if (!dataStr || dataStr === '[DONE]') continue;
let json;
try { json = JSON.parse(dataStr); } catch (e) { continue; }
const delta = json.choices?.[0]?.delta;
if (!delta) continue;
if (delta.content) {
content += delta.content;
if (onDelta) onDelta(delta.content);
}
// Tool pozivi stižu razlomljeni na više chunk-ova (index + djelimičan
// naziv/argumenti) - sastavljamo ih po "index" polju svakog fragmenta.
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = typeof tc.index === 'number' ? tc.index : 0;
if (!toolCallsAcc[idx]) {
toolCallsAcc[idx] = { id: '', type: 'function', function: { name: '', arguments: '' } };
}
if (tc.id) toolCallsAcc[idx].id = tc.id;
if (tc.function?.name) toolCallsAcc[idx].function.name += tc.function.name;
if (tc.function?.arguments) toolCallsAcc[idx].function.arguments += tc.function.arguments;
}
}
}
}
} catch (e) {
if (e.name === 'AbortError') {
// Sačuvaj do sada primljen tekst na error objekat da runConversationTurn
// može finalizovati djelimičan odgovor umjesto da ga jednostavno odbaci.
e.partialContent = content;
throw e;
}
return { __error: 'Greška tokom čitanja streaming odgovora: ' + e.message };
}
const toolCalls = toolCallsAcc.filter(Boolean);
return {
choices: [{
message: {
content,
tool_calls: toolCalls.length ? toolCalls : undefined
}
}]
};
},
// Izvršava alat koji je model pozvao i vraća string/objekat koji se šalje nazad modelu
async executeTool(name, args) {
if (name === 'execute_code') {
const execResult = await this.runSandboxedCode(args.code || '');
this.addExecResultMessage(execResult);
// Modelu i dalje šaljemo JSON string (kompatibilno sa ranijim ponašanjem)
return JSON.stringify(execResult);
}
if (name === 'create_artifact') {
// Ne otvaramo preview automatski - samo dodajemo poruku sa code
// blokom koji ima dugme "Prikaži" (korisnik sam bira kad da klikne).
this.addAIMessage(`🎨 Artifact "${args.title || 'bez naziva'}" je kreiran.\n\n\`\`\`${args.language || 'html'}\n${args.code || ''}\n\`\`\``);
return 'Artifact je uspješno kreiran. Korisnik može kliknuti dugme "Prikaži" da ga vidi u live pregledu.';
}
if (name.startsWith('mcp__')) {
const [, serverId, ...rest] = name.split('__');
const toolName = rest.join('__');
const server = this.mcpServers.find(s => s.id === serverId);
if (!server) return JSON.stringify({ error: 'MCP server nije pronađen.' });
this.addSystemMessage(`🔌 MCP poziv: ${server.name} → ${toolName}`);
try {
const result = await this.mcpRequest(server, 'tools/call', { name: toolName, arguments: args });
return typeof result === 'string' ? result : JSON.stringify(result);
} catch (e) {
return JSON.stringify({ error: e.message });
}
}
return `Nepoznat alat: ${name}`;
},
// Izvršava proizvoljan JS kod u izolovanom, sandboxed iframe-u (bez pristupa
// App/DOM-u glavne stranice) i vraća strukturiran objekat:
// uspjeh: { success: true, logs: [...], result: ... }
// greška: { success: false, logs: [...], error: { type, message, line } }
runSandboxedCode(code) {
return new Promise((resolve) => {
const requestId = 'exec_' + Date.now() + '_' + Math.random().toString(36).slice(2);
let settled = false;
const handler = (event) => {
if (!event.data || event.data.requestId !== requestId) return;
window.removeEventListener('message', handler);
settled = true;
resolve(event.data.result);
};
window.addEventListener('message', handler);
const escapedCode = code.replace(/<\/script/gi, '<\\/script');
const wrapped = `<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>
<script>
const __logs = [];
console.log = (...args) => { __logs.push(args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch(e) { return String(a); } }).join(' ')); };
let __payload;
try {
const __result = (function() {
${escapedCode}
})();
__payload = { success: true, logs: __logs, result: __result === undefined ? null : __result };
} catch (e) {
const __lineMatch = e.stack && e.stack.match(/<anonymous>:(\\d+):(\\d+)/);
__payload = {
success: false,
logs: __logs,
error: {
type: e.name || 'RuntimeError',
message: e.message,
line: __lineMatch ? parseInt(__lineMatch[1], 10) : null
}
};
}
parent.postMessage({ requestId: '${requestId}', result: __payload }, '*');
<\/script>
</body></html>`;
UI.execFrame.srcdoc = wrapped;
setTimeout(() => {
if (settled) return;
window.removeEventListener('message', handler);
resolve({
success: false,
logs: [],
error: { type: 'TimeoutError', message: 'Izvršavanje je trajalo predugo (>5s).', line: null }
});
}, 5000);
});
},
// --- FILE UPLOAD ---
async handleFileSelect(file) {
if (!file) return;
const maxSize = 8 * 1024 * 1024; // 8MB
if (file.size > maxSize) {
this.addSystemMessage(`⚠️ Fajl "${file.name}" je prevelik (max 8MB).`);
UI.fileInput.value = '';
return;
}
const isImage = file.type.startsWith('image/');
try {
if (isImage) {
const dataUrl = await this.readFileAsDataURL(file);
this.pendingAttachment = { type: 'image', name: file.name, dataUrl };
} else {
const text = await this.readFileAsText(file);
this.pendingAttachment = { type: 'text', name: file.name, content: text };
}
} catch (e) {
this.addSystemMessage(`⚠️ Ne mogu pročitati fajl "${file.name}".`);
UI.fileInput.value = '';
return;
}
this.renderAttachmentChip();
UI.fileInput.value = '';
},
readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(file);
});
},
renderAttachmentChip() {
if (!this.pendingAttachment) {
UI.attachmentChip.style.display = 'none';
UI.attachmentChip.innerHTML = '';
UI.sendBtn.disabled = UI.userInput.value.trim() === '';
return;
}
const a = this.pendingAttachment;
UI.attachmentChip.style.display = 'flex';
UI.attachmentChip.innerHTML = `
<i class="fa-solid ${a.type === 'image' ? 'fa-image' : 'fa-file-lines'}"></i>
<span>${a.name}</span>
<button type="button" id="remove-attachment" aria-label="Ukloni">&times;</button>
`;
document.getElementById('remove-attachment').addEventListener('click', () => this.clearAttachment());
UI.sendBtn.disabled = false;
},
clearAttachment() {
this.pendingAttachment = null;
this.renderAttachmentChip();
}
};
// Start App
document.addEventListener('DOMContentLoaded', () => {
App.init();
});
</script>
</body>
</html>