Download index.html from Nerdur/Agent2: direct link, hf CLI and curl.
- Browser
- Download file 130 kB
-
https://huggingface.co/spaces/Nerdur/Agent2/resolve/main/index.html
- Command line
-
hf download hf://spaces/Nerdur/Agent2/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Nerdur/Agent2/resolve/main/index.html
130 kB
| <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 ; | |
| } | |
| .code-block code.hljs { | |
| background: transparent ; | |
| padding: 0 ; | |
| } | |
| .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">×</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">×</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">×</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">×</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> |