Scope3 / static /index.html
Nerdur's picture
Upload index.html
72f16ca verified
Raw
History Blame Contribute Delete
98.5 kB
<!DOCTYPE html>
<html lang="bs" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>AgentScope AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Space+Mono:wght@400;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/atom-one-dark.min.css" id="hljs-theme">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
/* ═══════════════════════════════════════════════
CSS CUSTOM PROPERTIES β€” THEME SYSTEM
═══════════════════════════════════════════════ */
:root {
--bg0: #0a0a0f;
--bg1: #111118;
--bg2: #18181f;
--bg3: #22222c;
--bg4: #2c2c38;
--surface: #1c1c25;
--surface2: #242430;
--border: #2e2e3d;
--border2: #3a3a4e;
--text0: #f0f0f8;
--text1: #c0c0d0;
--text2: #8888a0;
--text3: #555568;
--accent: #7c6dfa;
--accent2: #9b8dff;
--accent-glow: rgba(124,109,250,0.15);
--green: #4ade80;
--amber: #fbbf24;
--red: #f87171;
--cyan: #22d3ee;
--sidebar-w: 260px;
--radius: 10px;
--radius-sm: 6px;
--font-mono: 'JetBrains Mono', monospace;
--font-ui: 'Syne', sans-serif;
--transition: 0.18s cubic-bezier(0.4,0,0.2,1);
}
[data-theme="light"] {
--bg0: #f5f5f7;
--bg1: #ffffff;
--bg2: #f0f0f5;
--bg3: #e8e8ef;
--bg4: #dedee8;
--surface: #ffffff;
--surface2: #f8f8fc;
--border: #dddde8;
--border2: #c8c8d8;
--text0: #111118;
--text1: #333340;
--text2: #666678;
--text3: #999aaa;
--accent: #5b4ddb;
--accent2: #7060f0;
--accent-glow: rgba(91,77,219,0.12);
}
/* ═══════════════════════════════════════════════
RESET & BASE
═══════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--font-ui);
background: var(--bg0);
color: var(--text0);
line-height: 1.6;
font-size: 14px;
/* Fix za mobilne browsere gdje 100vh uključuje adresnu traku */
height: 100dvh;
}
input, textarea, select, button { font-family: inherit; font-size: inherit; }
button { cursor: pointer; border: none; background: none; }
a { color: var(--accent2); text-decoration: none; }
a:hover { text-decoration: underline; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; }
/* ═══════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════ */
#app {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: 100dvh;
height: 100dvh;
}
/* ═══════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════ */
#sidebar {
background: var(--bg1);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
z-index: 10;
height: 100dvh;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.logo-icon {
width: 28px; height: 28px;
background: var(--accent);
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
.logo-text {
font-weight: 700;
font-size: 15px;
letter-spacing: -0.3px;
color: var(--text0);
}
.logo-text span { color: var(--accent2); }
.btn-new-chat {
background: var(--accent);
color: #fff;
border-radius: var(--radius-sm);
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
transition: background var(--transition);
white-space: nowrap;
}
.btn-new-chat:hover { background: var(--accent2); }
.btn-sidebar-close {
display: none;
width: 28px; height: 28px;
border-radius: var(--radius-sm);
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text2);
font-size: 15px;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition);
}
.btn-sidebar-close:hover { color: var(--text0); background: var(--bg4); border-color: var(--border2); }
.sidebar-search {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.sidebar-search input {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text0);
padding: 7px 10px 7px 30px;
font-size: 12px;
outline: none;
transition: border-color var(--transition);
}
.sidebar-search input:focus { border-color: var(--accent); }
.sidebar-search { position: relative; }
.search-icon {
position: absolute;
left: 23px; top: 50%;
transform: translateY(-50%);
color: var(--text3);
font-size: 12px;
pointer-events: none;
}
#sessions-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition);
position: relative;
group: true;
}
.session-item:hover { background: var(--bg3); }
.session-item.active { background: var(--bg3); border: 1px solid var(--border2); }
.session-item .session-icon { font-size: 13px; color: var(--text2); flex-shrink: 0; }
.session-item .session-name {
flex: 1;
font-size: 12.5px;
color: var(--text1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item.active .session-name { color: var(--text0); font-weight: 500; }
.session-actions {
display: none;
gap: 3px;
}
.session-item:hover .session-actions { display: flex; }
.session-actions button {
color: var(--text3);
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
transition: color var(--transition);
}
.session-actions button:hover { color: var(--text0); background: var(--bg4); }
.sessions-empty {
text-align: center;
color: var(--text3);
font-size: 12px;
padding: 40px 16px;
}
.sidebar-footer {
padding: 12px;
border-top: 1px solid var(--border);
display: flex;
gap: 6px;
}
.sidebar-footer button {
flex: 1;
padding: 7px;
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--text2);
background: var(--bg2);
border: 1px solid var(--border);
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sidebar-footer button:hover { color: var(--text0); border-color: var(--border2); }
/* ═══════════════════════════════════════════════
MAIN AREA
═══════════════════════════════════════════════ */
#main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg0);
height: 100dvh;
}
/* ─── Top Bar ─── */
#topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg1);
min-height: 54px;
flex-shrink: 0;
}
.model-selector-wrap {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.provider-badge {
font-size: 10px;
padding: 3px 7px;
border-radius: 99px;
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
#model-select {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text0);
padding: 6px 10px;
font-size: 12.5px;
outline: none;
cursor: pointer;
min-width: 180px;
max-width: 280px;
transition: border-color var(--transition);
}
#model-select:focus { border-color: var(--accent); }
.btn-refresh-models {
padding: 6px 8px;
border-radius: var(--radius-sm);
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text2);
font-size: 13px;
transition: all var(--transition);
}
.btn-refresh-models:hover { color: var(--text0); border-color: var(--accent); }
.btn-refresh-models.spinning { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.token-bar-mini {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text3);
}
.token-bar-mini .bar {
width: 80px;
height: 4px;
background: var(--bg3);
border-radius: 99px;
overflow: hidden;
}
.token-bar-mini .bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.5s ease, background 0.5s ease;
background: var(--green);
}
.bar-fill.amber { background: var(--amber); }
.bar-fill.red { background: var(--red); }
.btn-icon {
width: 32px; height: 32px;
border-radius: var(--radius-sm);
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text2);
font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.btn-icon:hover { color: var(--text0); border-color: var(--border2); }
.btn-icon.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ─── Tool Status Bar ─── */
#tool-status-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: var(--bg1);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--text3);
flex-shrink: 0;
overflow-x: auto;
min-height: 34px;
}
.tool-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 99px;
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text2);
white-space: nowrap;
transition: all var(--transition);
}
.tool-chip.active {
background: var(--accent-glow);
border-color: var(--accent);
color: var(--accent2);
}
.tool-chip.done {
background: rgba(74,222,128,0.08);
border-color: rgba(74,222,128,0.3);
color: var(--green);
}
#tool-log-panel {
background: var(--bg2);
border-bottom: 1px solid var(--border);
padding: 8px 16px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text2);
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
#tool-log-panel.open { max-height: 200px; overflow-y: auto; }
.tool-log-entry { padding: 2px 0; display: flex; gap: 8px; }
.tool-log-entry .tl-time { color: var(--text3); flex-shrink: 0; }
.tool-log-entry .tl-tool { color: var(--accent2); }
.tool-log-entry .tl-msg { color: var(--text1); }
/* ─── Messages ─── */
#messages-area {
flex: 1;
overflow-y: auto;
padding: 24px 0;
display: flex;
flex-direction: column;
}
#messages-wrap {
max-width: 780px;
width: 100%;
margin: 0 auto;
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 24px;
flex: 1;
}
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 0;
text-align: center;
gap: 16px;
color: var(--text2);
padding: 16px 0;
}
.welcome-screen .big-logo {
width: 72px; height: 72px;
background: linear-gradient(135deg, var(--accent), var(--cyan));
border-radius: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 32px;
margin-bottom: 8px;
box-shadow: 0 8px 32px var(--accent-glow);
}
.welcome-screen h1 {
font-size: 28px;
font-weight: 800;
color: var(--text0);
letter-spacing: -0.5px;
}
.welcome-screen p { font-size: 14px; max-width: 400px; color: var(--text2); }
.welcome-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
max-width: 480px;
width: 100%;
}
.welcome-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
text-align: left;
cursor: pointer;
transition: all var(--transition);
font-size: 12.5px;
}
.welcome-card:hover { border-color: var(--accent); background: var(--bg2); transform: translateY(-1px); }
.welcome-card .wc-emoji { font-size: 18px; margin-bottom: 6px; }
.welcome-card .wc-title { font-weight: 600; color: var(--text0); margin-bottom: 2px; }
.welcome-card .wc-desc { color: var(--text2); font-size: 11.5px; }
/* Message bubbles */
.msg {
display: flex;
gap: 12px;
animation: msgIn 0.25s ease;
}
@keyframes msgIn { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform:none; } }
.msg.user { flex-direction: row-reverse; }
.msg-avatar {
width: 30px; height: 30px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.msg.user .msg-avatar { background: var(--accent); color: #fff; }
.msg.assistant .msg-avatar { background: var(--bg3); border: 1px solid var(--border2); }
.msg-body { flex: 1; min-width: 0; max-width: 90%; }
.msg.user .msg-body { display: flex; flex-direction: column; align-items: flex-end; }
.msg-role {
font-size: 11px;
color: var(--text3);
margin-bottom: 4px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.msg-content {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 14px;
color: var(--text0);
line-height: 1.7;
word-break: break-word;
}
.msg.user .msg-content {
background: var(--accent);
border-color: var(--accent);
color: #fff;
border-bottom-right-radius: 3px;
}
.msg.assistant .msg-content {
border-bottom-left-radius: 3px;
}
.msg-content p { margin-bottom: 10px; }
.msg-content p:last-child { margin-bottom: 0; }
.msg-content h1,.msg-content h2,.msg-content h3 {
margin: 16px 0 8px;
color: var(--text0);
font-weight: 700;
}
.msg-content h1 { font-size: 18px; }
.msg-content h2 { font-size: 16px; }
.msg-content h3 { font-size: 14px; }
.msg-content ul, .msg-content ol { padding-left: 20px; margin-bottom: 10px; }
.msg-content li { margin-bottom: 4px; }
.msg-content blockquote {
border-left: 3px solid var(--accent);
padding: 4px 12px;
color: var(--text2);
margin: 8px 0;
background: var(--bg3);
border-radius: 0 4px 4px 0;
}
.msg-content table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 12.5px; }
.msg-content th { background: var(--bg3); font-weight: 600; }
.msg-content th, .msg-content td { border: 1px solid var(--border); padding: 6px 10px; }
.msg-content a { color: var(--accent2); }
.msg-content code:not(.hljs) {
background: var(--bg3);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--cyan);
}
.msg.user .msg-content code:not(.hljs) { background: rgba(255,255,255,0.15); border-color: rgba(255,255,255,0.2); color: #fff; }
/* Code blocks */
.code-wrapper {
position: relative;
margin: 12px 0;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg3);
padding: 7px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text2);
border-bottom: 1px solid var(--border);
}
.code-lang {
color: var(--accent2);
font-weight: 600;
text-transform: lowercase;
}
.code-actions { display: flex; gap: 4px; }
.code-actions button {
padding: 3px 8px;
border-radius: 4px;
background: var(--bg4);
color: var(--text2);
font-size: 11px;
border: 1px solid var(--border2);
transition: all var(--transition);
display: flex; align-items: center; gap: 3px;
}
.code-actions button:hover { color: var(--text0); background: var(--accent); border-color: var(--accent); }
.code-actions button.copied { background: var(--green); border-color: var(--green); color: #000; }
.code-wrapper pre { margin: 0 !important; border-radius: 0 !important; }
.code-wrapper pre code { font-size: 12.5px !important; font-family: var(--font-mono) !important; }
.code-output {
background: var(--bg0);
border-top: 1px solid var(--border);
padding: 10px 14px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text1);
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.code-output.error { color: var(--red); }
.code-output .exec-time { color: var(--text3); font-size: 10px; margin-top: 4px; }
.msg-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 5px;
font-size: 11px;
color: var(--text3);
}
.msg.user .msg-meta { justify-content: flex-end; }
.msg-tokens { background: var(--bg3); border-radius: 99px; padding: 1px 6px; }
.streaming-cursor {
display: inline-block;
width: 2px;
height: 14px;
background: var(--accent2);
border-radius: 1px;
margin-left: 2px;
animation: blink 0.8s ease infinite;
vertical-align: text-bottom;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
.tool-usage-inline {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text2);
padding: 6px 12px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin: 6px 0;
font-family: var(--font-mono);
}
.tool-usage-inline .tui-icon { font-size: 14px; }
.tool-usage-inline.done { color: var(--green); border-color: rgba(74,222,128,0.25); background: rgba(74,222,128,0.05); }
.tool-usage-inline.active { color: var(--accent2); border-color: rgba(124,109,250,0.3); background: var(--accent-glow); animation: pulse 1.5s ease infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
/* Attachments preview */
.attachments-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.attachment-thumb {
position: relative;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
}
.attachment-thumb img {
width: 80px; height: 80px;
object-fit: cover;
display: block;
}
.attachment-thumb .att-remove {
position: absolute; top: 2px; right: 2px;
background: rgba(0,0,0,0.6);
color: #fff;
border-radius: 99px;
width: 16px; height: 16px;
display: flex; align-items: center; justify-content: center;
font-size: 10px;
}
.attachment-file {
display: flex; align-items: center; gap: 6px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 11.5px;
color: var(--text1);
}
.attachment-file .att-remove { margin-left: 4px; color: var(--text3); }
.attachment-file .att-remove:hover { color: var(--red); }
/* ─── Input Area ─── */
#input-area {
border-top: 1px solid var(--border);
background: var(--bg1);
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
flex-shrink: 0;
}
#input-area-inner { max-width: 780px; margin: 0 auto; }
#drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 20px;
text-align: center;
color: var(--text3);
font-size: 12px;
margin-bottom: 10px;
display: none;
transition: all var(--transition);
}
#drop-zone.dragover { border-color: var(--accent); color: var(--accent2); background: var(--accent-glow); }
#drop-zone.visible { display: block; }
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.input-attachments-bar {
margin-bottom: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#message-input {
flex: 1;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text0);
padding: 10px 14px;
resize: none;
outline: none;
font-size: 14px;
line-height: 1.5;
min-height: 44px;
max-height: 160px;
transition: border-color var(--transition);
}
#message-input:focus { border-color: var(--accent); }
#message-input::placeholder { color: var(--text3); }
.input-buttons {
display: flex;
align-items: flex-end;
gap: 6px;
}
.btn-upload {
width: 38px; height: 38px;
border-radius: var(--radius-sm);
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text2);
font-size: 16px;
display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.btn-upload:hover { color: var(--text0); border-color: var(--border2); }
.btn-send {
width: 38px; height: 38px;
border-radius: var(--radius-sm);
background: var(--accent);
color: #fff;
font-size: 16px;
display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.btn-send:hover { background: var(--accent2); transform: scale(1.05); }
.btn-send:disabled { background: var(--bg3); color: var(--text3); transform: none; }
.btn-stop {
width: 38px; height: 38px;
border-radius: var(--radius-sm);
background: var(--red);
color: #fff;
font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.input-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
font-size: 11px;
color: var(--text3);
}
/* ─── Settings Panel ─── */
#settings-panel {
position: fixed;
top: 0; right: -400px;
width: 380px;
height: 100vh;
background: var(--bg1);
border-left: 1px solid var(--border);
z-index: 100;
overflow-y: auto;
transition: right 0.3s cubic-bezier(0.4,0,0.2,1);
display: flex;
flex-direction: column;
}
#settings-panel.open { right: 0; }
.settings-header {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.settings-header h2 { font-size: 15px; font-weight: 700; }
.settings-body { padding: 16px; flex: 1; display: flex; flex-direction: column; gap: 20px; }
.settings-section h3 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text3);
margin-bottom: 12px;
}
.form-group { margin-bottom: 12px; }
.form-group label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--text1);
margin-bottom: 6px;
font-weight: 500;
}
.form-group label .val { color: var(--accent2); font-family: var(--font-mono); }
.form-group input[type="range"] {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="password"],
.form-group select,
.form-group textarea {
width: 100%;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text0);
padding: 8px 10px;
font-size: 12.5px;
outline: none;
transition: border-color var(--transition);
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--accent); }
.form-group textarea { resize: vertical; min-height: 80px; font-family: var(--font-mono); font-size: 12px; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.toggle-row span { font-size: 12.5px; color: var(--text1); }
.toggle {
position: relative;
width: 36px; height: 20px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--bg4);
border-radius: 99px;
cursor: pointer;
transition: background var(--transition);
}
.toggle-slider::after {
content: '';
position: absolute;
width: 14px; height: 14px;
background: #fff;
border-radius: 50%;
top: 3px; left: 3px;
transition: transform var(--transition);
}
.toggle input:checked + .toggle-slider { background: var(--accent); }
.toggle input:checked + .toggle-slider::after { transform: translateX(16px); }
.api-key-wrap { position: relative; }
.api-key-wrap input { padding-right: 36px; }
.api-key-wrap button {
position: absolute;
right: 6px; top: 50%;
transform: translateY(-50%);
color: var(--text3);
font-size: 13px;
padding: 4px;
}
.btn-save-keys {
width: 100%;
background: var(--accent);
color: #fff;
border-radius: var(--radius-sm);
padding: 9px;
font-weight: 600;
font-size: 13px;
transition: background var(--transition);
}
.btn-save-keys:hover { background: var(--accent2); }
/* ─── Provider tabs in settings ─── */
.provider-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.provider-tab {
padding: 4px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
color: var(--text2);
background: var(--bg3);
border: 1px solid var(--border);
cursor: pointer;
transition: all var(--transition);
}
.provider-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.provider-tab:hover:not(.active) { border-color: var(--border2); color: var(--text0); }
/* ─── Modals ─── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
z-index: 200;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--bg1);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 24px;
max-width: 480px;
width: 90%;
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
}
.modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
.modal input {
width: 100%;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text0);
padding: 9px 12px;
font-size: 13px;
outline: none;
margin-bottom: 12px;
}
.modal input:focus { border-color: var(--accent); }
.modal-buttons { display: flex; gap: 8px; justify-content: flex-end; }
.modal-buttons button {
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent2); }
.btn-ghost { background: var(--bg3); color: var(--text1); border: 1px solid var(--border); }
.btn-ghost:hover { border-color: var(--border2); }
.btn-danger { background: var(--red); color: #fff; }
/* ─── Toast notifications ─── */
#toast-container {
position: fixed;
bottom: 20px; right: 20px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
padding: 10px 16px;
font-size: 13px;
color: var(--text0);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
animation: toastIn 0.3s ease;
display: flex; align-items: center; gap: 8px;
max-width: 300px;
}
.toast.success { border-color: rgba(74,222,128,0.4); }
.toast.error { border-color: rgba(248,113,113,0.4); }
.toast.info { border-color: rgba(124,109,250,0.4); }
@keyframes toastIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
@keyframes toastOut { to { opacity:0; transform:translateY(10px); } }
/* ─── Pyodide output sandbox ─── */
#preview-modal .modal { max-width: 720px; }
#preview-frame {
width: 100%;
height: 400px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
}
/* ─── Mobile ─── */
@media (max-width: 900px) {
#app { grid-template-columns: 1fr; }
#sidebar { display: none; }
#sidebar.mobile-open {
display: flex;
position: fixed; inset: 0;
z-index: 50;
width: 85vw;
max-width: 320px;
}
#btn-sidebar-toggle { display: flex !important; }
.btn-sidebar-close { display: flex !important; }
#topbar { padding: 8px 10px; flex-wrap: wrap; gap: 6px; }
#model-select { min-width: 120px; max-width: 160px; font-size: 12px; }
#provider-select { font-size: 12px; max-width: 100px; }
.token-bar-mini { display: none; }
#messages-wrap { padding: 0 10px; }
#messages-area { padding: 16px 0; }
#input-area { padding: 8px 10px; padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); }
.welcome-grid { grid-template-columns: 1fr; max-width: 100%; }
.welcome-screen h1 { font-size: 22px; }
.welcome-screen { padding: 0 12px; }
#settings-panel { width: 100vw; right: -100vw; }
.msg-content { font-size: 13px; padding: 10px 12px; }
.code-wrapper pre code { font-size: 11px !important; }
#tool-status-bar { padding: 5px 10px; font-size: 10px; }
.tool-chip { padding: 2px 6px; font-size: 10px; }
.topbar-right { gap: 5px; }
.btn-icon { width: 28px; height: 28px; font-size: 13px; }
}
/* ─── Mali ekrani (≀480px) ─── */
@media (max-width: 480px) {
/* Topbar β€” jedan red, kompaktan */
#topbar { padding: 5px 8px; flex-wrap: nowrap; gap: 4px; min-height: 42px; }
.model-selector-wrap { gap: 3px; flex: 1; min-width: 0; overflow: hidden; }
#provider-select { font-size: 10px; padding: 3px 4px; max-width: 80px; min-width: 0; }
#model-select { font-size: 10px; padding: 3px 4px; min-width: 0; max-width: 130px; flex: 1; }
#btn-refresh { display: none; }
.topbar-right { gap: 2px; flex-shrink: 0; }
.btn-icon { width: 26px; height: 26px; font-size: 12px; }
.token-bar-mini { display: none; }
/* Input β€” manji */
#input-area { padding: 5px 8px; padding-bottom: calc(5px + env(safe-area-inset-bottom, 0px)); }
#message-input { padding: 7px 10px; font-size: 13px; min-height: 38px; }
.btn-send, .btn-upload { width: 34px; height: 34px; font-size: 15px; }
.input-footer { display: none; }
/* Poruke */
#messages-wrap { padding: 0 8px; }
#messages-area { padding: 10px 0; }
.msg-content { font-size: 13px; padding: 8px 10px; }
/* Tool bar */
#tool-status-bar { padding: 4px 8px; font-size: 10px; min-height: 28px; }
/* Welcome screen β€” kompaktan da sve stane */
.welcome-screen { padding: 8px 10px; gap: 8px; }
.welcome-screen .big-logo { width: 48px; height: 48px; font-size: 22px; border-radius: 12px; margin-bottom: 0; }
.welcome-screen h1 { font-size: 18px; }
.welcome-screen p { font-size: 12px; }
.welcome-grid { grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; }
.welcome-card { padding: 8px 10px; }
.welcome-card .wc-emoji { font-size: 14px; margin-bottom: 3px; }
.welcome-card .wc-title { font-size: 11.5px; }
.welcome-card .wc-desc { font-size: 10px; }
}
.loading-dots span {
display: inline-block;
animation: ldot 1.2s ease infinite;
opacity: 0;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes ldot { 0%,100%{opacity:0} 50%{opacity:1} }
</style>
</head>
<body>
<div id="app">
<!-- SIDEBAR -->
<aside id="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon">πŸ€–</div>
<div class="logo-text">Agent<span>Scope</span></div>
</div>
<button class="btn-new-chat" onclick="App.newChat()">+ Novi</button>
<button class="btn-sidebar-close" onclick="App.toggleSidebar()" title="Zatvori">βœ•</button>
</div>
<div class="sidebar-search">
<span class="search-icon">πŸ”</span>
<input type="text" id="search-sessions" placeholder="PretraΕΎi razgovore..." oninput="App.filterSessions(this.value)">
</div>
<div id="sessions-list">
<div class="sessions-empty">Nema razgovora.<br>Klikni "Novi" da počneő.</div>
</div>
<div class="sidebar-footer">
<button onclick="App.exportSession()">⬆ Export</button>
<button onclick="App.importSession()">⬇ Import</button>
<button onclick="App.clearAll()" style="color:var(--red)">πŸ—‘ BriΕ‘i sve</button>
</div>
</aside>
<!-- MAIN -->
<main id="main">
<!-- TOPBAR -->
<div id="topbar">
<button class="btn-icon" onclick="App.toggleSidebar()" title="Sidebar" style="display:none" id="btn-sidebar-toggle">☰</button>
<div class="model-selector-wrap">
<select id="provider-select" onchange="App.onProviderChange()" style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text0);padding:6px 10px;font-size:12.5px;outline:none;cursor:pointer;">
<option value="groq">Groq</option>
<option value="mistral">Mistral AI</option>
<option value="sambanova">SambaNova</option>
<option value="nvidia">NVIDIA NIM</option>
<option value="cohere">Cohere</option>
<option value="gemini">Google Gemini</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
<select id="model-select">
<option value="">β€” odaberi model β€”</option>
</select>
<button class="btn-refresh-models" onclick="App.fetchModels()" title="Refresh modela" id="btn-refresh">↻</button>
</div>
<div class="topbar-right">
<div class="token-bar-mini">
<span id="token-text">0 / 0</span>
<div class="bar"><div class="bar-fill" id="token-bar-fill" style="width:0%"></div></div>
</div>
<button class="btn-icon" onclick="App.toggleToolLog()" title="Tool log" id="btn-toollog">πŸ”§</button>
<button class="btn-icon" onclick="App.toggleTheme()" title="Tema" id="btn-theme">πŸŒ™</button>
<button class="btn-icon" onclick="App.openSettings()" title="Postavke">βš™</button>
</div>
</div>
<!-- TOOL STATUS BAR -->
<div id="tool-status-bar">
<span style="color:var(--text3);font-size:10px;text-transform:uppercase;letter-spacing:0.5px;font-weight:600;">Alati:</span>
<div class="tool-chip" id="tc-web_search">πŸ” Web</div>
<div class="tool-chip" id="tc-python">🐍 Python</div>
<div class="tool-chip" id="tc-calculator">πŸ”’ Kalk.</div>
<div class="tool-chip" id="tc-wikipedia">πŸ“š Wiki</div>
<div class="tool-chip" id="tc-file">πŸ“„ Fajl</div>
<div style="flex:1"></div>
<button onclick="App.toggleToolLog()" style="color:var(--text3);font-size:11px;background:none;border:none;cursor:pointer;">Log β–Ύ</button>
</div>
<div id="tool-log-panel"></div>
<!-- MESSAGES -->
<div id="messages-area">
<div id="messages-wrap">
<div id="welcome-screen" class="welcome-screen">
<div class="big-logo">πŸ€–</div>
<h1>AgentScope AI</h1>
<p>Napredni AI asistent sa pristupom alatima β€” web pretraga, izvrΕ‘avanje koda, analiza fajlova i viΕ‘e.</p>
<div class="welcome-grid">
<div class="welcome-card" onclick="App.startPrompt('PretraΕΎi web: najnovije vijesti iz AI svijeta')">
<div class="wc-emoji">πŸ”</div>
<div class="wc-title">Web pretraga</div>
<div class="wc-desc">PretraΕΎujem aktuelne informacije</div>
</div>
<div class="welcome-card" onclick="App.startPrompt('NapiΕ‘i Python kod koji sortira listu i objasni ga')">
<div class="wc-emoji">🐍</div>
<div class="wc-title">IzvrΕ‘avanje koda</div>
<div class="wc-desc">PiΕ‘em i testiram kod u browseru</div>
</div>
<div class="welcome-card" onclick="App.startPrompt('Analiziraj ovaj tekst i napravi saΕΎetak')">
<div class="wc-emoji">πŸ“Š</div>
<div class="wc-title">Analiza dokumenta</div>
<div class="wc-desc">Upload fajla β†’ automatska analiza</div>
</div>
<div class="welcome-card" onclick="App.startPrompt('Izračunaj: integral od x^2 od 0 do 5')">
<div class="wc-emoji">πŸ”’</div>
<div class="wc-title">Matematika</div>
<div class="wc-desc">Kompleksni proračuni i formule</div>
</div>
</div>
</div>
</div>
</div>
<!-- INPUT -->
<div id="input-area">
<div id="input-area-inner">
<div id="drop-zone">⬆ Prevuci fajlove ovdje (slike, kod, PDF, dokumente...)</div>
<div class="input-attachments-bar" id="attachments-bar"></div>
<div class="input-row">
<textarea id="message-input"
placeholder="Pitaj neΕ‘to... (Shift+Enter za novi red, Enter za slanje)"
rows="1"
onkeydown="App.onInputKeydown(event)"
oninput="App.onInputChange(this)"></textarea>
<div class="input-buttons">
<button class="btn-upload" onclick="document.getElementById('file-input').click()" title="Dodaj fajl">πŸ“Ž</button>
<button class="btn-send" id="btn-send" onclick="App.sendMessage()" title="Poőalji">➀</button>
</div>
</div>
<div class="input-footer">
<span id="input-hint" style="font-size:10px">Enter = poΕ‘alji &nbsp;Β·&nbsp; Shift+Enter = novi red</span>
<span id="char-count" style="color:var(--text3)"></span>
</div>
</div>
</div>
</main>
</div>
<!-- SETTINGS PANEL -->
<div id="settings-panel">
<div class="settings-header">
<h2>βš™ Postavke</h2>
<button class="btn-icon" onclick="App.closeSettings()">βœ•</button>
</div>
<div class="settings-body">
<!-- API Keys -->
<div class="settings-section">
<h3>API Ključevi</h3>
<div class="provider-tabs" id="api-key-tabs">
<button class="provider-tab active" onclick="App.switchApiTab('groq', this)">Groq</button>
<button class="provider-tab" onclick="App.switchApiTab('mistral', this)">Mistral</button>
<button class="provider-tab" onclick="App.switchApiTab('sambanova', this)">SambaNova</button>
<button class="provider-tab" onclick="App.switchApiTab('nvidia', this)">NVIDIA</button>
<button class="provider-tab" onclick="App.switchApiTab('cohere', this)">Cohere</button>
<button class="provider-tab" onclick="App.switchApiTab('gemini', this)">Gemini</button>
<button class="provider-tab" onclick="App.switchApiTab('openai', this)">OpenAI</button>
<button class="provider-tab" onclick="App.switchApiTab('anthropic', this)">Anthropic</button>
</div>
<div class="form-group">
<label>API Ključ za <span id="api-key-provider-label">Groq</span></label>
<div class="api-key-wrap">
<input type="password" id="api-key-input" placeholder="gsk_..." autocomplete="off">
<button onclick="App.toggleKeyVisibility()" id="key-vis-btn">πŸ‘</button>
</div>
<p style="font-size:11px;color:var(--text3);margin-top:4px">Čuva se lokalno, enkriptovano. Nikad ne odlazi na server.</p>
</div>
<button class="btn-save-keys" onclick="App.saveApiKey()">Sačuvaj ključ</button>
</div>
<!-- Model Config -->
<div class="settings-section">
<h3>Konfiguracija Modela</h3>
<div class="form-group">
<label>Temperature <span class="val" id="temp-val">0.7</span></label>
<input type="range" id="cfg-temperature" min="0" max="2" step="0.05" value="0.7" oninput="document.getElementById('temp-val').textContent=this.value">
</div>
<div class="form-group">
<label>Max Tokens <span class="val" id="maxtok-val">4096</span></label>
<input type="range" id="cfg-max-tokens" min="256" max="32768" step="256" value="4096" oninput="document.getElementById('maxtok-val').textContent=this.value">
</div>
<div class="form-group">
<label>Top P <span class="val" id="topp-val">0.9</span></label>
<input type="range" id="cfg-top-p" min="0" max="1" step="0.05" value="0.9" oninput="document.getElementById('topp-val').textContent=this.value">
</div>
<div class="toggle-row">
<span>Streaming</span>
<label class="toggle"><input type="checkbox" id="cfg-stream" checked><div class="toggle-slider"></div></label>
</div>
</div>
<!-- System Prompt -->
<div class="settings-section">
<h3>System Prompt</h3>
<div class="form-group">
<textarea id="cfg-system-prompt" rows="6" placeholder="Unesi system prompt..."></textarea>
</div>
<button class="btn-save-keys" onclick="App.saveConfig()">Sačuvaj konfiguraciju</button>
</div>
<!-- Export/Import -->
<div class="settings-section">
<h3>Upravljanje Podacima</h3>
<div style="display:flex;gap:8px">
<button class="btn-ghost" style="flex:1;padding:8px;border-radius:var(--radius-sm);font-size:12px" onclick="App.exportAllSessions()">Export sve sesije (JSON)</button>
<button class="btn-ghost" style="flex:1;padding:8px;border-radius:var(--radius-sm);font-size:12px" onclick="App.importSession()">Import sesija</button>
</div>
</div>
</div>
</div>
<!-- MODALS -->
<div class="modal-overlay" id="rename-modal">
<div class="modal">
<h3>✏ Preimenuj razgovor</h3>
<input type="text" id="rename-input" placeholder="Naziv razgovora...">
<div class="modal-buttons">
<button class="btn-ghost" onclick="App.closeModal('rename-modal')">OtkaΕΎi</button>
<button class="btn-primary" onclick="App.confirmRename()">Preimenuj</button>
</div>
</div>
</div>
<div class="modal-overlay" id="preview-modal">
<div class="modal" style="max-width:720px;width:95%">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h3>πŸ‘ Preview</h3>
<button class="btn-icon" onclick="App.closeModal('preview-modal')">βœ•</button>
</div>
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</div>
<div class="modal-overlay" id="confirm-modal">
<div class="modal">
<h3 id="confirm-title">Potvrdi akciju</h3>
<p id="confirm-msg" style="color:var(--text2);margin-bottom:16px;font-size:13px"></p>
<div class="modal-buttons">
<button class="btn-ghost" onclick="App.closeModal('confirm-modal')">OtkaΕΎi</button>
<button class="btn-danger" id="confirm-ok-btn" onclick="">Potvrdi</button>
</div>
</div>
</div>
<!-- TOAST -->
<div id="toast-container"></div>
<!-- Hidden file input -->
<input type="file" id="file-input" multiple accept="*/*" style="display:none" onchange="App.onFileSelect(event)">
</body>
<script>
/* ═══════════════════════════════════════════════════════════════════
AgentScope AI β€” Frontend Application
Real API calls, real streaming, real tool execution
═══════════════════════════════════════════════════════════════════ */
// ─── Utilities ────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const el = (tag, attrs={}, ...children) => {
const e = document.createElement(tag);
Object.entries(attrs).forEach(([k,v]) => {
if (k === 'class') e.className = v;
else if (k.startsWith('on')) e[k] = v;
else e.setAttribute(k, v);
});
children.forEach(c => typeof c === 'string' ? e.appendChild(document.createTextNode(c)) : e.appendChild(c));
return e;
};
// ─── Encryption helpers (Web Crypto API) ──────────────────────────
const Crypto = {
async getKey() {
const raw = 'agentscope-key-v1-' + navigator.userAgent.slice(0,20);
const enc = new TextEncoder().encode(raw);
const hash = await crypto.subtle.digest('SHA-256', enc);
return crypto.subtle.importKey('raw', hash, {name:'AES-GCM'}, false, ['encrypt','decrypt']);
},
async encrypt(text) {
const key = await this.getKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = new TextEncoder().encode(text);
const ct = await crypto.subtle.encrypt({name:'AES-GCM',iv}, key, enc);
const buf = new Uint8Array(iv.length + ct.byteLength);
buf.set(iv); buf.set(new Uint8Array(ct), iv.length);
return btoa(String.fromCharCode(...buf));
},
async decrypt(b64) {
try {
const key = await this.getKey();
const buf = Uint8Array.from(atob(b64), c=>c.charCodeAt(0));
const iv = buf.slice(0,12), ct = buf.slice(12);
const dec = await crypto.subtle.decrypt({name:'AES-GCM',iv}, key, ct);
return new TextDecoder().decode(dec);
} catch { return ''; }
}
};
// ─── Storage helpers ───────────────────────────────────────────────
const Store = {
get(k, def=null) {
try { const v=localStorage.getItem(k); return v!==null?JSON.parse(v):def; } catch { return def; }
},
set(k,v) { try { localStorage.setItem(k,JSON.stringify(v)); } catch {} },
remove(k) { localStorage.removeItem(k); },
async setEncrypted(k,v) {
const enc = await Crypto.encrypt(v);
localStorage.setItem(k+'_enc', enc);
},
async getEncrypted(k) {
const enc = localStorage.getItem(k+'_enc');
if (!enc) return '';
return Crypto.decrypt(enc);
}
};
// ─── Toast system ──────────────────────────────────────────────────
const Toast = {
show(msg, type='info', dur=3000) {
const icons = {info:'β„Ή',success:'βœ…',error:'❌',warning:'⚠'};
const t = el('div', {class:`toast ${type}`});
t.textContent = (icons[type]||'') + ' ' + msg;
$('toast-container').appendChild(t);
setTimeout(() => { t.style.animation='toastOut 0.3s ease forwards'; setTimeout(()=>t.remove(),300); }, dur);
}
};
// ─── Token counting (rough estimate) ──────────────────────────────
const TokenEstimator = {
count(text) { return Math.ceil((text||'').length / 4); },
countMessages(msgs) { return msgs.reduce((a,m)=>a+this.count(m.content||''),0); }
};
// ─── Markdown renderer ─────────────────────────────────────────────
marked.setOptions({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try { return hljs.highlight(code, {language:lang}).value; } catch {}
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true
});
function renderMarkdown(text) {
// Convert markdown to HTML, then wrap code blocks
let html = marked.parse(text || '');
// Wrap pre>code with our custom code-wrapper
html = html.replace(/<pre><code class="(language-[^"]+)">([\s\S]*?)<\/code><\/pre>/g, (match, cls, code) => {
const lang = cls.replace('language-','');
const rawCode = code.replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&').replace(/&#39;/g,"'").replace(/&quot;/g,'"');
const id = 'code-' + Math.random().toString(36).slice(2,8);
return `<div class="code-wrapper" id="${id}">
<div class="code-header">
<span class="code-lang">${lang}</span>
<div class="code-actions">
<button onclick="App.copyCode('${id}')" title="Kopiraj">πŸ“‹ Kopiraj</button>
${['html','css','js','javascript','svg'].includes(lang)?`<button onclick="App.previewCode('${id}')" title="Preview">πŸ‘ Preview</button>`:''}
${['python','py'].includes(lang)?`<button onclick="App.runPython('${id}')" title="Pokreni">β–Ά Pokreni</button>`:''}
<button onclick="App.downloadCode('${id}','${lang}')" title="Preuzmi">⬇ Save</button>
</div>
</div>
<pre><code class="${cls}">${code}</code></pre>
</div>`;
});
html = html.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (match, code) => {
const id = 'code-' + Math.random().toString(36).slice(2,8);
return `<div class="code-wrapper" id="${id}">
<div class="code-header"><span class="code-lang">text</span>
<div class="code-actions">
<button onclick="App.copyCode('${id}')">πŸ“‹ Kopiraj</button>
</div>
</div>
<pre><code>${code}</code></pre>
</div>`;
});
return html;
}
// ─── Pyodide Python executor ───────────────────────────────────────
const PyRunner = {
_pyodide: null,
_loading: false,
_queue: [],
async ensureLoaded() {
if (this._pyodide) return this._pyodide;
if (this._loading) return new Promise(r=>this._queue.push(r));
this._loading = true;
Toast.show('Učitavam Python (Pyodide)...', 'info', 6000);
try {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js';
document.head.appendChild(script);
await new Promise((r,rej)=>{ script.onload=r; script.onerror=rej; });
this._pyodide = await loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/' });
this._queue.forEach(r=>r(this._pyodide));
Toast.show('Python spreman!', 'success');
return this._pyodide;
} catch(e) {
Toast.show('Greőka pri učitavanju Pythona: '+e.message, 'error', 5000);
throw e;
}
},
async run(code) {
const py = await this.ensureLoaded();
const t0 = performance.now();
let stdout = '', stderr = '';
py.setStdout({ batched: s => { stdout += s + '\n'; }});
py.setStderr({ batched: s => { stderr += s + '\n'; }});
try {
const result = await py.runPythonAsync(code);
const ms = Math.round(performance.now()-t0);
return { stdout: stdout.trim(), stderr: '', result: result!==undefined&&result!==null?String(result):'', time: ms };
} catch(e) {
const ms = Math.round(performance.now()-t0);
return { stdout: stdout.trim(), stderr: e.message || String(e), result:'', time: ms };
}
}
};
// ─── Web search (via backend proxy) ───────────────────────────────
const WebSearch = {
async search(query) {
try {
const r = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await r.json();
return data.results || [];
} catch(e) {
return [{title:`GreΕ‘ka pretrage: ${e.message}`, url:''}];
}
},
formatResults(results) {
if (!results.length) return 'Nema rezultata za upit.';
return results.map((r,i)=>`${i+1}. ${r.title}${r.url?'\n URL: '+r.url:''}`).join('\n\n');
}
};
// ─── Tool system ───────────────────────────────────────────────────
const ToolDetector = {
patterns: [
{ tool: 'web_search', patterns: [/pretraΕΎi|pretraΕΎuj|googl|web search|najnovij|aktueln|vijest|news|Ε‘to je\s+\w+\s+(danas|2024|2025|2026)/i] },
{ tool: 'calculator', patterns: [/izračunaj|izračun|koliko je\s+[\d+\-*/()]/i, /^\s*[\d\s+\-*\/\(\)\.%^]+\s*[=?]?\s*$/] },
{ tool: 'wikipedia', patterns: [/wikipedia|wiki|őta je|ko je|őto znači|ko je bio/i] },
{ tool: 'python', patterns: [/pokreni|izvr[sΕ‘]i|testiraj|kod|python|script|program/i] },
],
detect(userMessage) {
const tools = [];
for (const {tool, patterns} of this.patterns) {
if (patterns.some(p=>p.test(userMessage))) tools.push(tool);
}
return tools;
}
};
// ─── Tool executor (auto-detects and runs tools before AI call) ────
const ToolRunner = {
async run(toolName, userMessage, appendToolLog) {
appendToolLog(toolName, 'aktiviran', 'active');
if (toolName === 'web_search') {
appendToolLog('web_search', `TraΕΎim: "${userMessage.slice(0,60)}..."`, 'active');
const results = await WebSearch.search(userMessage);
const formatted = WebSearch.formatResults(results);
appendToolLog('web_search', `PronaΔ‘eno ${results.length} rezultata`, 'done');
return `Web rezultati:\n${formatted}`;
}
if (toolName === 'calculator') {
const expr = userMessage.replace(/izračunaj|izračun|koliko je/gi,'').trim();
try {
const safe = expr.replace(/[^0-9+\-*/().% \t\n^]/g,'');
// eslint-disable-next-line no-new-func
const result = Function('"use strict";return ('+safe+')')();
appendToolLog('calculator', `${safe} = ${result}`, 'done');
return `Rezultat kalkulacije: ${safe} = ${result}`;
} catch(e) {
appendToolLog('calculator', 'GreΕ‘ka kalkulacije', 'done');
return '';
}
}
if (toolName === 'wikipedia') {
const query = userMessage.replace(/wikipedia|wiki|őta je|őto je|ko je|őto znači/gi,'').trim().split('\n')[0].slice(0,50);
const results = await WebSearch.search(query + ' wikipedia');
appendToolLog('wikipedia', `Wiki: "${query}"`, 'done');
if (results.length) return `Wikipedia info:\n${results[0].title}`;
return '';
}
return '';
}
};
// ─── Session Manager ───────────────────────────────────────────────
const Sessions = {
_sessions: {},
_activeId: null,
init() {
const ids = Store.get('sessions_index', []);
ids.forEach(id => {
const s = Store.get('session_'+id);
if (s) this._sessions[id] = s;
});
this._activeId = Store.get('active_session');
if (this._activeId && !this._sessions[this._activeId]) this._activeId = null;
},
create(opts={}) {
const id = 'sess_' + Date.now() + '_' + Math.random().toString(36).slice(2,6);
const sess = {
id,
name: opts.name || 'Novi razgovor',
createdAt: new Date().toISOString(),
model: opts.model || '',
provider: opts.provider || 'groq',
messages: [],
config: opts.config || {},
tokenCount: 0
};
this._sessions[id] = sess;
this._saveIndex();
Store.set('session_'+id, sess);
return sess;
},
get(id) { return this._sessions[id||this._activeId]; },
getActive() { return this._sessions[this._activeId]; },
setActive(id) {
this._activeId = id;
Store.set('active_session', id);
},
update(id, data) {
if (!this._sessions[id]) return;
Object.assign(this._sessions[id], data);
Store.set('session_'+id, this._sessions[id]);
},
addMessage(sessionId, msg) {
const s = this._sessions[sessionId];
if (!s) return;
s.messages.push(msg);
s.tokenCount = TokenEstimator.countMessages(s.messages);
Store.set('session_'+sessionId, s);
},
delete(id) {
delete this._sessions[id];
Store.remove('session_'+id);
this._saveIndex();
if (this._activeId === id) {
const ids = Object.keys(this._sessions);
this._activeId = ids.length ? ids[ids.length-1] : null;
Store.set('active_session', this._activeId);
}
},
rename(id, name) {
this.update(id, {name});
},
all() { return Object.values(this._sessions).sort((a,b)=>b.createdAt.localeCompare(a.createdAt)); },
_saveIndex() {
Store.set('sessions_index', Object.keys(this._sessions));
},
// Token compression
compressMessages(messages, maxTokens=6000) {
const total = TokenEstimator.countMessages(messages);
if (total <= maxTokens) return messages;
if (messages.length <= 6) return messages;
const keep = messages.slice(-5);
const middle = messages.slice(1, -5);
const summaryText = `[${middle.length} starijih poruka komprimovano radi tokena]`;
const summary = { role: 'system', content: summaryText };
return [messages[0], summary, ...keep];
}
};
// ─── API Keys Manager ──────────────────────────────────────────────
const ApiKeys = {
_keys: {},
_currentTab: 'groq',
async init() {
const enc = localStorage.getItem('api_keys_enc');
if (enc) {
try {
const dec = await Crypto.decrypt(enc);
this._keys = JSON.parse(dec);
} catch { this._keys = {}; }
}
},
get(provider) { return this._keys[provider] || ''; },
async set(provider, key) {
this._keys[provider] = key;
await this._save();
},
async _save() {
const enc = await Crypto.encrypt(JSON.stringify(this._keys));
localStorage.setItem('api_keys_enc', enc);
}
};
// ─── Model cache ───────────────────────────────────────────────────
const ModelCache = {};
// ─── Main Application ──────────────────────────────────────────────
const App = {
_streaming: false,
_abortController: null,
_pendingAttachments: [],
_theme: 'dark',
_toolLogOpen: false,
_renameTarget: null,
_currentConfig: {
temperature: 0.7,
max_tokens: 4096,
top_p: 0.9,
stream: true,
system_prompt: `Ti si napredni AI asistent sa pristupom raznim alatima.
JEZIK: Uvijek odgovaraj NA BOSANSKOM JEZIKU osim ako korisnik eksplicitno ne zatraΕΎi drugi jezik.
PONAΕ ANJE:
- Budi precizan, konkretan i koristan
- Ako neΕ‘to ne znaΕ‘, reci to otvoreno
- Za aktuelne informacije koristi web_search alat
- Za kodiranje: uvijek dodaj objaΕ‘njenje na bosanskom
- Koristi markdown formatiranje za strukturirane odgovore
- Code blokovi sa ispravnim jezičnim tagovima`
},
async init() {
await ApiKeys.init();
Sessions.init();
this._loadConfig();
this._loadTheme();
this.renderSessions();
this._setupDragDrop();
// Load active session or create new
const active = Sessions.getActive();
if (active) {
this.loadSession(active.id);
} else {
this.newChat();
}
// Auto-resize textarea
const inp = $('message-input');
inp.addEventListener('input', ()=>this._resizeTextarea(inp));
// Mobile sidebar toggle
if (window.innerWidth <= 900) {
$('btn-sidebar-toggle').style.display = 'flex';
}
window.addEventListener('resize', () => {
$('btn-sidebar-toggle').style.display = window.innerWidth <= 900 ? 'flex' : 'none';
if (window.innerWidth > 900) $('sidebar').classList.remove('mobile-open');
});
// Populate settings
this._populateSettings();
// Učitaj modele za defaultni provider odmah
this.onProviderChange();
},
// ─── Session Management ──────────────────────────────────────────
newChat() {
const config = {...this._currentConfig};
const sess = Sessions.create({
model: $('model-select').value || '',
provider: $('provider-select').value || 'groq',
config
});
Sessions.setActive(sess.id);
this.renderSessions();
this.renderMessages(sess);
this.updateTokenBar(0, parseInt($('cfg-max-tokens').value)||4096);
$('message-input').focus();
},
loadSession(id) {
Sessions.setActive(id);
const sess = Sessions.get(id);
if (!sess) return;
this.renderSessions();
this.renderMessages(sess);
// Set provider/model from session
if (sess.provider) $('provider-select').value = sess.provider;
if (sess.model) {
const opt = $('model-select').querySelector(`option[value="${sess.model}"]`);
if (opt) $('model-select').value = sess.model;
}
this.updateTokenBar(sess.tokenCount, parseInt($('cfg-max-tokens').value)||4096);
},
renderSessions(filter='') {
const list = $('sessions-list');
const sessions = Sessions.all().filter(s => !filter || s.name.toLowerCase().includes(filter.toLowerCase()));
if (!sessions.length) {
list.innerHTML = '<div class="sessions-empty">Nema razgovora.<br>Klikni "Novi" da počneő.</div>';
return;
}
const activeId = Sessions._activeId;
list.innerHTML = sessions.map(s => `
<div class="session-item ${s.id===activeId?'active':''}" onclick="App.loadSession('${s.id}')">
<span class="session-icon">πŸ’¬</span>
<span class="session-name" id="sname-${s.id}">${this._esc(s.name)}</span>
<div class="session-actions">
<button onclick="event.stopPropagation();App.renameSession('${s.id}')" title="Preimenuj">✏</button>
<button onclick="event.stopPropagation();App.deleteSession('${s.id}')" title="BriΕ‘i" style="color:var(--red)">πŸ—‘</button>
</div>
</div>
`).join('');
},
filterSessions(val) { this.renderSessions(val); },
renameSession(id) {
this._renameTarget = id;
const sess = Sessions.get(id);
$('rename-input').value = sess?.name || '';
this.openModal('rename-modal');
setTimeout(()=>$('rename-input').focus(),100);
},
confirmRename() {
const name = $('rename-input').value.trim();
if (name && this._renameTarget) {
Sessions.rename(this._renameTarget, name);
this.renderSessions();
Toast.show('Razgovor preimenovan', 'success');
}
this.closeModal('rename-modal');
this._renameTarget = null;
},
deleteSession(id) {
$('confirm-title').textContent = 'BriΕ‘i razgovor';
$('confirm-msg').textContent = 'Ova akcija je nepovratna. Razgovor Δ‡e biti obrisan.';
$('confirm-ok-btn').onclick = () => {
Sessions.delete(id);
this.renderSessions();
const active = Sessions.getActive();
if (active) this.loadSession(active.id);
else this.newChat();
this.closeModal('confirm-modal');
Toast.show('Razgovor obrisan', 'success');
};
this.openModal('confirm-modal');
},
clearAll() {
$('confirm-title').textContent = 'ObriΕ‘i sve razgovore';
$('confirm-msg').textContent = 'Svi razgovori i API ključevi Δ‡e biti obrisani. Ova akcija je nepovratna.';
$('confirm-ok-btn').onclick = () => {
localStorage.clear();
location.reload();
};
this.openModal('confirm-modal');
},
// ─── Message Rendering ───────────────────────────────────────────
renderMessages(sess) {
const wrap = $('messages-wrap');
if (!sess.messages.length) {
wrap.innerHTML = `<div id="welcome-screen" class="welcome-screen">
<div class="big-logo">πŸ€–</div>
<h1>AgentScope AI</h1>
<p>Napredni AI asistent sa pristupom alatima β€” web pretraga, izvrΕ‘avanje koda, analiza fajlova i viΕ‘e.</p>
<div class="welcome-grid">
<div class="welcome-card" onclick="App.startPrompt('PretraΕΎi web: najnovije vijesti iz AI svijeta')">
<div class="wc-emoji">πŸ”</div><div class="wc-title">Web pretraga</div><div class="wc-desc">PretraΕΎujem aktuelne informacije</div>
</div>
<div class="welcome-card" onclick="App.startPrompt('NapiΕ‘i Python Fibonacci niz i pokreni ga')">
<div class="wc-emoji">🐍</div><div class="wc-title">Python kod</div><div class="wc-desc">PiΕ‘em i pokreΔ‡em Python odmah</div>
</div>
<div class="welcome-card" onclick="App.startPrompt('Objasni mi kako radi JWT autentifikacija')">
<div class="wc-emoji">πŸ“š</div><div class="wc-title">Objasni koncept</div><div class="wc-desc">Detaljna objaΕ‘njenja na bosanskom</div>
</div>
<div class="welcome-card" onclick="App.startPrompt('Izračunaj: integral od x^2 dx od 0 do 5')">
<div class="wc-emoji">πŸ”’</div><div class="wc-title">Matematika</div><div class="wc-desc">Kompleksni proračuni</div>
</div>
</div>
</div>`;
return;
}
wrap.innerHTML = '';
sess.messages.filter(m=>m.role!=='system').forEach(m => {
wrap.appendChild(this._buildMessageEl(m));
});
this._scrollToBottom();
},
_buildMessageEl(msg) {
const div = el('div', {class:`msg ${msg.role}`});
const avatar = el('div', {class:'msg-avatar'});
avatar.textContent = msg.role === 'user' ? 'πŸ‘€' : 'πŸ€–';
const body = el('div', {class:'msg-body'});
const role = el('div', {class:'msg-role'});
role.textContent = msg.role === 'user' ? 'Ja' : 'AI Asistent';
const content = el('div', {class:'msg-content'});
if (msg.role === 'user') {
// Uvijek koristi displayText β€” NIKAD ne prikazuj msg.content koji moΕΎe sadrΕΎati kod fajlova
// Ako displayText nije sačuvan, pokuΕ‘aj izvuΔ‡i samo prvu liniju prije [Fajl:
let displayText = msg.displayText;
if (displayText === undefined || displayText === null) {
// Starije poruke β€” odreΕΎi dio sa sadrΕΎajem fajlova
const raw = msg.content || '';
const fajlIdx = raw.indexOf('\n\n[Fajl:');
displayText = fajlIdx !== -1 ? raw.slice(0, fajlIdx).trim() : raw;
}
if (displayText) {
const textNode = el('div');
textNode.textContent = displayText;
content.appendChild(textNode);
}
// PrikaΕΎi attachment ikonce
if (msg.attachments && msg.attachments.length) {
const attBar = el('div', {style:'display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;'});
msg.attachments.forEach(att => {
if (att.preview) {
const img = el('img', {src: att.preview, style:'width:80px;height:80px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,0.2);'});
attBar.appendChild(img);
} else {
const ext = (att.name || '').split('.').pop().toLowerCase();
const icon = ext === 'py' ? '🐍' : ['js','ts'].includes(ext) ? 'πŸ“œ' : ext === 'json' ? '{}' : ext === 'html' ? '🌐' : ext === 'css' ? '🎨' : ext === 'md' ? 'πŸ“' : 'πŸ“„';
const chip = el('div', {style:'display:flex;align-items:center;gap:5px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.2);border-radius:6px;padding:4px 8px;font-size:11.5px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'});
chip.textContent = icon + ' ' + att.name;
chip.title = att.name;
attBar.appendChild(chip);
}
});
content.appendChild(attBar);
}
} else {
content.innerHTML = renderMarkdown(msg.content || '');
}
const meta = el('div', {class:'msg-meta'});
if (msg.tokens) {
const tok = el('span', {class:'msg-tokens'});
tok.textContent = `~${msg.tokens} tok`;
meta.appendChild(tok);
}
if (msg.time) {
const t = el('span');
t.textContent = new Date(msg.time).toLocaleTimeString('bs-BA');
meta.appendChild(t);
}
body.append(role, content, meta);
div.append(avatar, body);
return div;
},
appendMessage(msg) {
const welcome = $('welcome-screen');
if (welcome) welcome.remove();
const wrap = $('messages-wrap');
wrap.appendChild(this._buildMessageEl(msg));
this._scrollToBottom();
},
_scrollToBottom() {
const area = $('messages-area');
requestAnimationFrame(()=>{ area.scrollTop = area.scrollHeight; });
},
// ─── Sending Messages ────────────────────────────────────────────
async sendMessage() {
if (this._streaming) return;
const input = $('message-input');
const text = input.value.trim();
if (!text && !this._pendingAttachments.length) return;
const provider = $('provider-select').value;
const model = $('model-select').value;
const apiKey = await ApiKeys.get(provider);
if (!apiKey) {
Toast.show(`Nema API ključa za ${provider}. Dodaj ga u Postavkama.`, 'error', 5000);
this.openSettings();
return;
}
if (!model) {
Toast.show('Odaberi model prvo.', 'error');
return;
}
let sess = Sessions.getActive();
if (!sess) {
sess = Sessions.create({model, provider, config: this._currentConfig});
Sessions.setActive(sess.id);
}
// Update session model/provider
Sessions.update(sess.id, {model, provider});
// Build user message content
// apiContent = Ε‘to se Ε‘alje AI-u (tekst + puni sadrΕΎaj fajlova)
// displayText = Ε‘to korisnik vidi u chatu (samo tekst poruke)
let apiContent = text;
const attachmentMeta = this._pendingAttachments.map(att => ({
name: att.name, type: att.type, size: att.size, preview: att.preview || null
}));
const attachmentTexts = [];
for (const att of this._pendingAttachments) {
attachmentTexts.push(`[Fajl: ${att.name}]\n${att.text||'(binaran fajl)'}`);
}
if (attachmentTexts.length) apiContent += '\n\n' + attachmentTexts.join('\n\n');
const userMsg = { role: 'user', content: apiContent, displayText: text, attachments: attachmentMeta, time: new Date().toISOString(), tokens: TokenEstimator.count(apiContent) };
Sessions.addMessage(sess.id, userMsg);
this.appendMessage(userMsg);
input.value = '';
this._resizeTextarea(input);
this._pendingAttachments = [];
$('attachments-bar').innerHTML = '';
// Auto-rename session from first message
if (sess.messages.filter(m=>m.role==='user').length === 1) {
const name = text.slice(0,45) + (text.length>45?'...':'');
Sessions.rename(sess.id, name);
this.renderSessions();
}
// ─── Tool detection & execution ─────────────────────────
const detectedTools = ToolDetector.detect(apiContent);
const toolContextParts = [];
if (detectedTools.length) {
this._setToolsActive(detectedTools);
for (const tool of detectedTools) {
const result = await ToolRunner.run(tool, userContent, (name, msg, state) => {
this._logTool(name, msg, state);
});
if (result) toolContextParts.push(result);
}
this._setToolsDone(detectedTools);
}
// ─── Build messages for API ──────────────────────────────
const sysPrompt = this._currentConfig.system_prompt;
let apiMessages = [...sess.messages.filter(m=>m.role!=='system')];
// Inject tool results into last user message context
if (toolContextParts.length) {
const lastIdx = apiMessages.length - 1;
apiMessages[lastIdx] = {
...apiMessages[lastIdx],
content: apiMessages[lastIdx].content + '\n\n--- Kontekst od alata ---\n' + toolContextParts.join('\n\n')
};
}
// Compress if needed
const maxTok = parseInt($('cfg-max-tokens').value) || 4096;
apiMessages = Sessions.compressMessages(apiMessages, maxTok * 0.7);
// ─── Streaming ──────────────────────────────────────────
await this._streamResponse(provider, model, apiKey, apiMessages, sysPrompt, sess.id);
},
async _streamResponse(provider, model, apiKey, messages, systemPrompt, sessionId) {
this._streaming = true;
this._abortController = new AbortController();
const sendBtn = $('btn-send');
sendBtn.innerHTML = '<span onclick="App.stopStreaming()" style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;background:var(--red);border-radius:var(--radius-sm)">β– </span>';
// Create assistant message container
const welcome = $('welcome-screen');
if (welcome) welcome.remove();
const wrap = $('messages-wrap');
const msgDiv = el('div', {class:'msg assistant'});
const avatar = el('div', {class:'msg-avatar'}); avatar.textContent='πŸ€–';
const body = el('div', {class:'msg-body'});
const role = el('div', {class:'msg-role'}); role.textContent='AI Asistent';
const content = el('div', {class:'msg-content'});
content.innerHTML = '<span class="loading-dots"><span>.</span><span>.</span><span>.</span></span>';
body.append(role, content);
msgDiv.append(avatar, body);
wrap.appendChild(msgDiv);
this._scrollToBottom();
let fullText = '';
const cursor = '<span class="streaming-cursor"></span>';
try {
const timeoutId = setTimeout(() => this._abortController.abort(), 60000);
const resp = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: this._abortController.signal,
body: JSON.stringify({
provider, model, messages,
api_key: apiKey,
temperature: this._currentConfig.temperature,
max_tokens: this._currentConfig.max_tokens,
top_p: this._currentConfig.top_p,
stream: true,
system_prompt: systemPrompt || ''
})
});
clearTimeout(timeoutId);
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const j = JSON.parse(data);
if (j.error) throw new Error(j.error);
if (j.content) {
fullText += j.content;
content.innerHTML = renderMarkdown(fullText) + cursor;
this._scrollToBottom();
this._updateTokenBar(sessionId, fullText);
}
} catch(e) {
if (e.message && e.message.length < 200) console.warn('SSE parse:', e.message);
}
}
}
} catch(e) {
if (e.name === 'AbortError') {
if (fullText) {
fullText += '\n\n*[Prekinuto]*';
} else {
fullText = `⏱️ **Timeout ili prekinuto**\n\nServer nije odgovorio. Provjeri:\n- Je li API ključ ispravan?\n- Je li server aktivan?\n- Pokuőaj ponovo`;
}
} else {
const errMsg = e.message || String(e);
fullText = `❌ **Greőka pri odgovoru:** ${errMsg}\n\nProvjeri:\n- API ključ za **${provider}**\n- Odabrani model: **${model}**\n- Internetsku vezu`;
Toast.show(errMsg, 'error', 6000);
}
}
// Finalize message
content.innerHTML = renderMarkdown(fullText);
const assistantMsg = { role: 'assistant', content: fullText, time: new Date().toISOString(), tokens: TokenEstimator.count(fullText) };
Sessions.addMessage(sessionId, assistantMsg);
const meta = el('div', {class:'msg-meta'});
const tok = el('span', {class:'msg-tokens'}); tok.textContent=`~${assistantMsg.tokens} tok`;
const t = el('span'); t.textContent=new Date(assistantMsg.time).toLocaleTimeString('bs-BA');
meta.append(tok, t);
body.appendChild(meta);
this._streaming = false;
sendBtn.innerHTML = '➀';
this._scrollToBottom();
this._updateTokenBarFromSession(sessionId);
},
stopStreaming() {
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
}
},
startPrompt(text) {
$('message-input').value = text;
this.sendMessage();
},
// ─── Token Bar ───────────────────────────────────────────────────
updateTokenBar(used, max) {
const pct = max > 0 ? Math.min((used/max)*100, 100) : 0;
const fill = $('token-bar-fill');
fill.style.width = pct + '%';
fill.className = 'bar-fill' + (pct>90?' red':pct>70?' amber':'');
$('token-text').textContent = `${used} / ${max}`;
},
_updateTokenBar(sessionId, text) {
const sess = Sessions.get(sessionId);
if (!sess) return;
const total = sess.messages.reduce((a,m)=>a+TokenEstimator.count(m.content||''),0) + TokenEstimator.count(text);
const max = this._currentConfig.max_tokens || 4096;
this.updateTokenBar(total, max);
},
_updateTokenBarFromSession(sessionId) {
const sess = Sessions.get(sessionId);
if (!sess) return;
this.updateTokenBar(sess.tokenCount, this._currentConfig.max_tokens||4096);
},
// ─── Tool Status Bar ─────────────────────────────────────────────
_setToolsActive(tools) {
tools.forEach(t => {
const el = $('tc-'+t);
if (el) { el.classList.add('active'); el.classList.remove('done'); }
});
},
_setToolsDone(tools) {
tools.forEach(t => {
const el = $('tc-'+t);
if (el) { el.classList.remove('active'); el.classList.add('done'); }
});
setTimeout(()=>{
tools.forEach(t=>{ const el=$('tc-'+t); if(el){el.classList.remove('done');} });
}, 4000);
},
_logTool(name, msg, state) {
const panel = $('tool-log-panel');
const entry = el('div', {class:'tool-log-entry'});
const time = el('span',{class:'tl-time'}); time.textContent=new Date().toLocaleTimeString('bs-BA');
const tool = el('span',{class:'tl-tool'}); tool.textContent=name;
const m = el('span',{class:'tl-msg'}); m.textContent=msg;
entry.append(time,tool,m);
panel.appendChild(entry);
panel.scrollTop=panel.scrollHeight;
},
toggleToolLog() {
this._toolLogOpen = !this._toolLogOpen;
$('tool-log-panel').classList.toggle('open', this._toolLogOpen);
$('btn-toollog').classList.toggle('active', this._toolLogOpen);
},
// ─── Model Fetching ──────────────────────────────────────────────
// Hardkodirane liste modela po provideru (fallback + brzo učitavanje)
_staticModels: {
groq: [
{id:'llama-3.3-70b-versatile',name:'Llama 3.3 70B Versatile'},
{id:'llama-3.1-8b-instant',name:'Llama 3.1 8B Instant'},
{id:'llama3-70b-8192',name:'Llama 3 70B'},
{id:'llama3-8b-8192',name:'Llama 3 8B'},
{id:'mixtral-8x7b-32768',name:'Mixtral 8x7B'},
{id:'gemma2-9b-it',name:'Gemma 2 9B'},
{id:'deepseek-r1-distill-llama-70b',name:'DeepSeek R1 Llama 70B'},
{id:'qwen-qwq-32b',name:'Qwen QwQ 32B'},
],
mistral: [
{id:'mistral-large-latest',name:'Mistral Large'},
{id:'mistral-medium-latest',name:'Mistral Medium'},
{id:'mistral-small-latest',name:'Mistral Small'},
{id:'open-mixtral-8x22b',name:'Mixtral 8x22B'},
{id:'open-mixtral-8x7b',name:'Mixtral 8x7B'},
{id:'codestral-latest',name:'Codestral'},
],
sambanova: [
{id:'Meta-Llama-3.3-70B-Instruct',name:'Llama 3.3 70B Instruct'},
{id:'Meta-Llama-3.1-405B-Instruct',name:'Llama 3.1 405B Instruct'},
{id:'Meta-Llama-3.1-70B-Instruct',name:'Llama 3.1 70B Instruct'},
{id:'Meta-Llama-3.1-8B-Instruct',name:'Llama 3.1 8B Instruct'},
{id:'DeepSeek-R1',name:'DeepSeek R1'},
{id:'DeepSeek-V3-0324',name:'DeepSeek V3'},
{id:'Qwen2.5-72B-Instruct',name:'Qwen 2.5 72B'},
{id:'Qwen3-32B',name:'Qwen3 32B'},
],
nvidia: [
{id:'nvidia/llama-3.1-nemotron-ultra-253b-v1',name:'Nemotron Ultra 253B'},
{id:'nvidia/llama-3.3-nemotron-super-49b-v1',name:'Nemotron Super 49B'},
{id:'meta/llama-3.3-70b-instruct',name:'Llama 3.3 70B'},
{id:'meta/llama-3.1-405b-instruct',name:'Llama 3.1 405B'},
{id:'deepseek-ai/deepseek-r1',name:'DeepSeek R1'},
{id:'qwen/qwen2.5-72b-instruct',name:'Qwen 2.5 72B'},
{id:'mistralai/mixtral-8x22b-instruct-v0.1',name:'Mixtral 8x22B'},
],
cohere: [
{id:'command-r-plus-08-2024',name:'Command R+ (Aug 2024)'},
{id:'command-r-08-2024',name:'Command R (Aug 2024)'},
{id:'command-r-plus',name:'Command R+'},
{id:'command-r',name:'Command R'},
{id:'command-light',name:'Command Light'},
],
gemini: [
{id:'gemini-2.5-pro-preview-06-05',name:'Gemini 2.5 Pro'},
{id:'gemini-2.5-flash-preview-05-20',name:'Gemini 2.5 Flash'},
{id:'gemini-2.0-flash',name:'Gemini 2.0 Flash'},
{id:'gemini-1.5-pro',name:'Gemini 1.5 Pro'},
{id:'gemini-1.5-flash',name:'Gemini 1.5 Flash'},
],
openai: [
{id:'gpt-4o',name:'GPT-4o'},
{id:'gpt-4o-mini',name:'GPT-4o Mini'},
{id:'gpt-4-turbo',name:'GPT-4 Turbo'},
{id:'gpt-3.5-turbo',name:'GPT-3.5 Turbo'},
{id:'o1',name:'o1'},
{id:'o1-mini',name:'o1 Mini'},
{id:'o3-mini',name:'o3 Mini'},
],
anthropic: [
{id:'claude-opus-4-5',name:'Claude Opus 4.5'},
{id:'claude-sonnet-4-5',name:'Claude Sonnet 4.5'},
{id:'claude-haiku-4-5',name:'Claude Haiku 4.5'},
{id:'claude-3-5-sonnet-20241022',name:'Claude 3.5 Sonnet'},
{id:'claude-3-5-haiku-20241022',name:'Claude 3.5 Haiku'},
{id:'claude-3-opus-20240229',name:'Claude 3 Opus'},
],
},
async fetchModels() {
const provider = $('provider-select').value;
const apiKey = ApiKeys.get(provider);
if (!apiKey) {
// Bez API ključa β€” prikaΕΎi statičke modele
const staticList = this._staticModels[provider] || [];
if (staticList.length > 0) {
ModelCache[provider] = staticList;
this._populateModelSelect(staticList);
Toast.show(`Prikazujem offline listu β€” dodaj API ključ za live modele`, 'info', 4000);
} else {
Toast.show(`Dodaj API ključ za ${provider} u Postavkama.`, 'warning', 4000);
this.openSettings();
}
return;
}
const btn = $('btn-refresh');
if (btn) { btn.classList.add('spinning'); btn.disabled = true; }
try {
const resp = await fetch('/api/models', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({provider, api_key: apiKey})
});
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
ModelCache[provider] = data.models;
this._populateModelSelect(data.models);
Toast.show(`${data.models.length} modela učitano za ${provider}`, 'success');
} catch(e) {
// Fallback na statičke modele
const staticList = this._staticModels[provider] || [];
if (staticList.length > 0) {
ModelCache[provider] = staticList;
this._populateModelSelect(staticList);
Toast.show(`Server nedostupan β€” offline lista modela`, 'info', 4000);
} else {
Toast.show(`GreΕ‘ka: ${e.message}`, 'error', 5000);
}
} finally {
if (btn) { btn.classList.remove('spinning'); btn.disabled = false; }
}
},
onProviderChange() {
const provider = $('provider-select').value;
if (ModelCache[provider]) {
this._populateModelSelect(ModelCache[provider]);
} else {
// Odmah pokaži statičke modele bez čekanja na API key
const staticList = this._staticModels[provider] || [];
if (staticList.length > 0) {
ModelCache[provider] = staticList;
this._populateModelSelect(staticList);
} else {
$('model-select').innerHTML = '<option value="">β€” učitaj modele ↻ β€”</option>';
}
}
},
_populateModelSelect(models) {
const sel = $('model-select');
sel.innerHTML = '';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
if (m.context_window) opt.title = `Context: ${m.context_window} tokena`;
sel.appendChild(opt);
});
},
// ─── File handling ───────────────────────────────────────────────
_setupDragDrop() {
const area = $('messages-area');
const drop = $('drop-zone');
area.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('visible','dragover'); });
area.addEventListener('dragleave', e => { if (!area.contains(e.relatedTarget)) drop.classList.remove('visible','dragover'); });
area.addEventListener('drop', e => {
e.preventDefault();
drop.classList.remove('visible','dragover');
this._processFiles(Array.from(e.dataTransfer.files));
});
},
onFileSelect(e) {
this._processFiles(Array.from(e.target.files));
e.target.value = '';
},
async _processFiles(files) {
for (const file of files) {
await this._processFile(file);
}
},
async _processFile(file) {
const isImage = file.type.startsWith('image/');
const isText = file.type.startsWith('text/') || /\.(py|js|ts|html|css|json|yaml|yml|xml|csv|md|txt|sh|java|cpp|c|h|rs|go|sql)$/i.test(file.name);
let text = '';
let preview = null;
if (isText) {
text = await file.text();
} else if (isImage) {
const buf = await file.arrayBuffer();
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
preview = `data:${file.type};base64,${b64}`;
text = `[Slika: ${file.name}]`;
}
const att = { name: file.name, type: file.type, text, preview, size: file.size };
this._pendingAttachments.push(att);
this._renderAttachment(att);
Toast.show(`Dodan: ${file.name}`, 'success');
},
_renderAttachment(att) {
const bar = $('attachments-bar');
const idx = this._pendingAttachments.length - 1;
if (att.preview) {
const wrap = el('div', {class:'attachment-thumb'});
const img = el('img'); img.src=att.preview; img.alt=att.name;
const rm = el('button',{class:'att-remove',onclick:`App.removeAttachment(${idx})`}); rm.textContent='βœ•';
wrap.append(img,rm);
bar.appendChild(wrap);
} else {
const wrap = el('div',{class:'attachment-file'});
wrap.innerHTML = `πŸ“„ ${this._esc(att.name)} <span style="color:var(--text3)">(${this._fmtSize(att.size)})</span>`;
const rm = el('button',{class:'att-remove',onclick:`App.removeAttachment(${idx})`}); rm.textContent='βœ•';
wrap.appendChild(rm);
bar.appendChild(wrap);
}
},
removeAttachment(idx) {
this._pendingAttachments.splice(idx,1);
const bar = $('attachments-bar');
bar.innerHTML = '';
this._pendingAttachments.forEach((a,i)=>{
// re-render
});
this._pendingAttachments.forEach((a,i) => { this._renderAttachment(a); });
},
// ─── Code block actions ──────────────────────────────────────────
copyCode(id) {
const wrapper = $(id);
if (!wrapper) return;
const code = wrapper.querySelector('code');
const text = code?.textContent || '';
navigator.clipboard.writeText(text).then(()=>{
const btn = wrapper.querySelector('.code-actions button');
if (btn) { btn.classList.add('copied'); btn.textContent='βœ… Kopirano'; setTimeout(()=>{ btn.classList.remove('copied'); btn.textContent='πŸ“‹ Kopiraj'; },2000); }
});
},
previewCode(id) {
const wrapper = $(id);
if (!wrapper) return;
const code = wrapper.querySelector('code')?.textContent || '';
const frame = $('preview-frame');
frame.srcdoc = code;
this.openModal('preview-modal');
},
async runPython(id) {
const wrapper = $(id);
if (!wrapper) return;
const code = wrapper.querySelector('code')?.textContent || '';
let outputEl = wrapper.querySelector('.code-output');
if (!outputEl) {
outputEl = el('div',{class:'code-output'});
wrapper.appendChild(outputEl);
}
outputEl.textContent = '⟳ Izvrőavam Python...';
outputEl.className = 'code-output';
try {
const result = await PyRunner.run(code);
let out = '';
if (result.stdout) out += result.stdout;
if (result.result && result.result !== 'None') out += (out?'\n':'')+result.result;
if (result.stderr) {
outputEl.className = 'code-output error';
outputEl.textContent = result.stderr;
} else {
outputEl.textContent = out || '(bez izlaza)';
}
const timeEl = el('div',{class:'exec-time'}); timeEl.textContent=`IzvrΕ‘eno za ${result.time}ms`;
outputEl.appendChild(timeEl);
} catch(e) {
outputEl.className = 'code-output error';
outputEl.textContent = String(e);
}
},
downloadCode(id, lang) {
const wrapper = $(id);
if (!wrapper) return;
const code = wrapper.querySelector('code')?.textContent || '';
const exts = {python:'py',py:'py',javascript:'js',js:'js',typescript:'ts',ts:'ts',html:'html',css:'css',json:'json',yaml:'yaml',yml:'yml',bash:'sh',shell:'sh',rust:'rs',go:'go',java:'java',cpp:'cpp',c:'c',sql:'sql'};
const ext = exts[lang] || 'txt';
const blob = new Blob([code], {type:'text/plain'});
const a = el('a'); a.href=URL.createObjectURL(blob); a.download=`code.${ext}`; a.click();
Toast.show(`Preuzimam code.${ext}`, 'success');
},
// ─── Settings ────────────────────────────────────────────────────
openSettings() { $('settings-panel').classList.add('open'); },
closeSettings() { $('settings-panel').classList.remove('open'); },
async switchApiTab(provider, btn) {
document.querySelectorAll('.provider-tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
ApiKeys._currentTab = provider;
$('api-key-provider-label').textContent = provider;
$('api-key-input').value = await ApiKeys.get(provider);
const labels = {groq:'gsk_...',mistral:'sk-...',sambanova:'sn-...',nvidia:'nvapi-...',cohere:'sk-...',gemini:'AI...',openai:'sk-...',anthropic:'sk-ant-...'};
$('api-key-input').placeholder = labels[provider] || '...';
},
async saveApiKey() {
const key = $('api-key-input').value.trim();
const provider = ApiKeys._currentTab;
if (!key) { Toast.show('Ključ je prazan', 'warning'); return; }
await ApiKeys.set(provider, key);
Toast.show(`API ključ za ${provider} sačuvan`, 'success');
// Auto-fetch models
this.fetchModels();
},
toggleKeyVisibility() {
const inp = $('api-key-input');
inp.type = inp.type === 'password' ? 'text' : 'password';
$('key-vis-btn').textContent = inp.type === 'password' ? 'πŸ‘' : 'πŸ™ˆ';
},
saveConfig() {
this._currentConfig = {
temperature: parseFloat($('cfg-temperature').value),
max_tokens: parseInt($('cfg-max-tokens').value),
top_p: parseFloat($('cfg-top-p').value),
stream: $('cfg-stream').checked,
system_prompt: $('cfg-system-prompt').value
};
Store.set('model_config', this._currentConfig);
Toast.show('Konfiguracija sačuvana', 'success');
this.closeSettings();
},
_loadConfig() {
const saved = Store.get('model_config');
if (saved) {
this._currentConfig = {...this._currentConfig, ...saved};
$('cfg-temperature').value = this._currentConfig.temperature;
$('temp-val').textContent = this._currentConfig.temperature;
$('cfg-max-tokens').value = this._currentConfig.max_tokens;
$('maxtok-val').textContent = this._currentConfig.max_tokens;
$('cfg-top-p').value = this._currentConfig.top_p;
$('topp-val').textContent = this._currentConfig.top_p;
$('cfg-stream').checked = this._currentConfig.stream;
$('cfg-system-prompt').value = this._currentConfig.system_prompt || '';
}
},
_populateSettings() {
$('cfg-system-prompt').value = this._currentConfig.system_prompt || '';
},
// ─── Theme ───────────────────────────────────────────────────────
toggleTheme() {
this._theme = this._theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', this._theme);
$('btn-theme').textContent = this._theme === 'dark' ? 'πŸŒ™' : 'β˜€';
const hlLink = $('hljs-theme');
hlLink.href = this._theme === 'dark'
? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/atom-one-dark.min.css'
: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css';
Store.set('theme', this._theme);
},
_loadTheme() {
this._theme = Store.get('theme', 'dark');
document.documentElement.setAttribute('data-theme', this._theme);
$('btn-theme').textContent = this._theme === 'dark' ? 'πŸŒ™' : 'β˜€';
if (this._theme === 'light') {
$('hljs-theme').href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/github.min.css';
}
},
// ─── Import/Export ───────────────────────────────────────────────
exportSession() {
const sess = Sessions.getActive();
if (!sess) { Toast.show('Nema aktivne sesije', 'warning'); return; }
const blob = new Blob([JSON.stringify(sess, null, 2)], {type:'application/json'});
const a = el('a'); a.href=URL.createObjectURL(blob); a.download=`${sess.name.replace(/[^a-z0-9]/gi,'_')}.json`; a.click();
Toast.show('Sesija exportovana', 'success');
},
exportAllSessions() {
const all = Sessions.all();
const blob = new Blob([JSON.stringify(all, null, 2)], {type:'application/json'});
const a = el('a'); a.href=URL.createObjectURL(blob); a.download='agentscope_sessions.json'; a.click();
Toast.show(`${all.length} sesija exportovano`, 'success');
},
importSession() {
const inp = el('input'); inp.type='file'; inp.accept='.json';
inp.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
const sessions = Array.isArray(data) ? data : [data];
sessions.forEach(sess => {
if (!sess.id || !sess.messages) return;
Sessions._sessions[sess.id] = sess;
Store.set('session_'+sess.id, sess);
});
Sessions._saveIndex();
this.renderSessions();
Toast.show(`${sessions.length} sesija uvezeno`, 'success');
} catch(e) {
Toast.show('GreΕ‘ka pri uvozu: '+e.message, 'error');
}
};
inp.click();
},
// ─── Input helpers ───────────────────────────────────────────────
onInputKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
},
onInputChange(ta) {
this._resizeTextarea(ta);
$('char-count').textContent = ta.value.length > 0 ? ta.value.length + ' znakova' : '';
},
_resizeTextarea(ta) {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
},
// ─── Sidebar toggle (mobile) ─────────────────────────────────────
toggleSidebar() {
$('sidebar').classList.toggle('mobile-open');
},
// ─── Modals ──────────────────────────────────────────────────────
openModal(id) { $(id).classList.add('open'); },
closeModal(id) { $(id).classList.remove('open'); },
// ─── Utilities ───────────────────────────────────────────────────
_esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); },
_fmtSize(bytes) {
if (bytes < 1024) return bytes+'B';
if (bytes < 1048576) return (bytes/1024).toFixed(1)+'KB';
return (bytes/1048576).toFixed(1)+'MB';
}
};
// ─── Fix visine za mobilne browsere (100vh != stvarna visina) ─────
(function fixVh() {
const set = () => {
// window.innerHeight na Androidu veΔ‡ ne uključuje nav bar, ali dodaj mali buffer
const h = window.innerHeight;
const app = document.getElementById('app');
const main = document.getElementById('main');
const sidebar = document.getElementById('sidebar');
if (app) app.style.height = h + 'px';
if (main) main.style.maxHeight = h + 'px';
if (sidebar) sidebar.style.height = h + 'px';
};
set();
window.addEventListener('resize', set);
window.addEventListener('orientationchange', () => setTimeout(set, 150));
})();
// ─── Bootstrap ────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => App.init());
// Close settings on overlay click
document.addEventListener('click', e => {
const panel = $('settings-panel');
if (panel.classList.contains('open') && !panel.contains(e.target) && !e.target.closest('[onclick*="openSettings"]')) {
App.closeSettings();
}
// Close modals on overlay
document.querySelectorAll('.modal-overlay.open').forEach(mo => {
if (e.target === mo) App.closeModal(mo.id);
});
});
</script>