Files

288 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Notebook — Nexus One AI</title>
<link rel="stylesheet" href="style.css?v=12">
<style>
body { overflow: hidden; }
.nb-shell { height: calc(100vh - 64px); display:grid; grid-template-columns:260px minmax(420px,1fr) 330px; background:var(--bg); }
.nb-side,.nb-sources { background:var(--navy2); overflow:hidden; display:flex; flex-direction:column; }
.nb-side { border-right:1px solid var(--bdr); }
.nb-sources { border-left:1px solid var(--bdr); }
.nb-head { padding:16px; border-bottom:1px solid var(--bdr); }
.nb-product { display:flex; align-items:center; gap:9px; font-size:15px; font-weight:800; color:var(--ink); }
.nb-mark { width:28px; height:28px; border-radius:9px; display:grid; place-items:center; color:#fff; background:linear-gradient(135deg,var(--purple),#8b5cf6); }
.nb-sub { font-size:11px; color:var(--lt); margin-top:5px; }
.nb-actions { display:flex; gap:7px; margin-top:13px; }
.nb-btn { border:1px solid var(--bdr); background:var(--navy2); color:var(--med); border-radius:8px; padding:7px 10px; font:600 12px inherit; cursor:pointer; transition:.15s; }
.nb-btn:hover { border-color:var(--purple); color:var(--purple); }
.nb-btn.primary { color:#fff; background:var(--purple); border-color:var(--purple); }
.nb-btn.primary:hover { background:#6d28d9; color:#fff; }
.nb-btn:disabled { opacity:.5; cursor:not-allowed; }
.nb-search { margin:12px 12px 5px; width:calc(100% - 24px); box-sizing:border-box; border:1px solid var(--bdr); border-radius:8px; padding:8px 10px; color:var(--ink); background:var(--bg); font:12px inherit; }
.nb-label { padding:11px 14px 5px; font-size:10px; letter-spacing:.8px; text-transform:uppercase; font-weight:800; color:var(--lt); }
.nb-collections { padding:4px 7px 12px; overflow:auto; flex:1; }
.nb-history { border-top:1px solid var(--bdr); max-height:34%; overflow:auto; padding:4px 7px 10px; }
.nb-history-item { padding:8px 10px; border-radius:8px; font-size:11px; color:var(--med); cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.nb-history-item:hover,.nb-history-item.active { background:rgba(124,58,237,.08); color:var(--purple); }
.nb-collection { border:1px solid transparent; border-radius:9px; padding:10px; cursor:pointer; margin-bottom:4px; transition:.15s; }
.nb-collection:hover { background:var(--bg); }
.nb-collection.active { border-color:rgba(124,58,237,.25); background:rgba(124,58,237,.08); }
.nb-col-name { font-size:13px; font-weight:700; color:var(--ink); display:flex; justify-content:space-between; gap:8px; }
.nb-col-meta { font-size:10.5px; color:var(--lt); margin-top:4px; }
.nb-empty-small { color:var(--lt); font-size:12px; line-height:1.55; padding:18px 10px; text-align:center; }
.nb-main { display:flex; flex-direction:column; min-width:0; overflow:hidden; }
.nb-toolbar { min-height:58px; padding:10px 16px; background:var(--navy2); border-bottom:1px solid var(--bdr); display:flex; align-items:center; gap:10px; }
.nb-title-wrap { flex:1; min-width:0; }
.nb-title { font-size:15px; font-weight:800; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.nb-title-sub { font-size:11px; color:var(--lt); margin-top:2px; }
.nb-private { font-size:11px; color:#15803d; background:rgba(21,128,61,.08); border:1px solid rgba(21,128,61,.2); border-radius:20px; padding:5px 9px; cursor:pointer; }
.nb-private.private { color:#6d28d9; background:rgba(124,58,237,.08); border-color:rgba(124,58,237,.2); }
.nb-model { border:1px solid var(--bdr); background:var(--bg); color:var(--med); border-radius:8px; padding:7px 9px; max-width:180px; font:12px inherit; }
.nb-messages { flex:1; overflow:auto; padding:24px clamp(18px,5vw,70px); display:flex; flex-direction:column; gap:16px; }
.nb-welcome { margin:auto; max-width:650px; text-align:center; padding:28px; }
.nb-welcome-icon { width:54px; height:54px; border-radius:17px; margin:0 auto 15px; display:grid; place-items:center; font-size:23px; color:var(--purple); background:rgba(124,58,237,.1); border:1px solid rgba(124,58,237,.18); }
.nb-welcome h1 { font-size:26px; color:var(--ink); margin:0 0 9px; }
.nb-welcome p { color:var(--med); font-size:14px; line-height:1.65; margin:0 auto; max-width:560px; }
.nb-prompts { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:22px; }
.nb-prompt { border:1px solid var(--bdr); border-radius:10px; background:var(--navy2); padding:11px 12px; text-align:left; color:var(--med); font:12px/1.45 inherit; cursor:pointer; }
.nb-prompt:hover { border-color:var(--purple); color:var(--purple); }
.nb-msg { display:flex; gap:10px; align-items:flex-start; }
.nb-msg.user { flex-direction:row-reverse; }
.nb-avatar { width:30px; height:30px; border-radius:9px; display:grid; place-items:center; flex:0 0 auto; font-size:12px; font-weight:800; }
.nb-msg.user .nb-avatar { background:var(--purple); color:white; }
.nb-msg.assistant .nb-avatar { background:var(--ink); color:white; }
.nb-bubble { max-width:82%; padding:12px 14px; border-radius:13px; color:var(--ink); font-size:13.5px; line-height:1.65; white-space:pre-wrap; }
.nb-msg.user .nb-bubble { background:var(--purple); color:white; border-radius:13px 4px 13px 13px; }
.nb-msg.assistant .nb-bubble { background:var(--navy2); border:1px solid var(--bdr); border-radius:4px 13px 13px 13px; }
.nb-citation { display:inline-flex; border:0; border-radius:5px; padding:1px 5px; margin:0 1px; background:rgba(124,58,237,.12); color:var(--purple); font:700 11px inherit; cursor:pointer; }
.nb-warning { border:1px solid #f6d58b; background:#fff9e9; color:#8a5b00; border-radius:9px; padding:9px 11px; font-size:12px; margin-bottom:10px; }
.nb-compose { padding:12px 18px 16px; background:var(--navy2); border-top:1px solid var(--bdr); }
.nb-compose-inner { max-width:900px; margin:auto; border:1px solid var(--bdr); border-radius:13px; background:var(--bg); padding:10px; box-shadow:0 4px 18px rgba(31,41,55,.05); }
.nb-compose textarea { width:100%; box-sizing:border-box; resize:none; border:0; outline:0; background:transparent; color:var(--ink); min-height:44px; max-height:150px; font:13.5px/1.5 inherit; }
.nb-compose-row { display:flex; align-items:center; justify-content:space-between; gap:8px; border-top:1px solid var(--bdr); padding-top:8px; }
.nb-context { font-size:11px; color:var(--lt); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.nb-send { border:0; border-radius:8px; background:var(--purple); color:white; padding:7px 13px; font:700 12px inherit; cursor:pointer; }
.nb-send:disabled { opacity:.45; cursor:not-allowed; }
.nb-source-head { padding:17px 16px 12px; border-bottom:1px solid var(--bdr); display:flex; align-items:center; justify-content:space-between; }
.nb-source-title { font-size:13px; font-weight:800; color:var(--ink); }
.nb-tabs { padding:10px 12px 0; display:flex; gap:4px; }
.nb-tab { flex:1; border:0; border-bottom:2px solid transparent; background:none; color:var(--lt); padding:7px; font:700 11px inherit; cursor:pointer; }
.nb-tab.active { color:var(--purple); border-color:var(--purple); }
.nb-panel { padding:10px 12px 16px; overflow:auto; flex:1; }
.nb-doc { border:1px solid var(--bdr); border-radius:9px; background:var(--bg); padding:10px; margin-bottom:7px; }
.nb-doc-name { font-size:12px; font-weight:700; color:var(--ink); overflow-wrap:anywhere; }
.nb-doc-meta { font-size:10px; color:var(--lt); margin-top:4px; }
.nb-source { border:1px solid var(--bdr); border-radius:9px; padding:10px; margin-bottom:8px; background:var(--bg); cursor:pointer; }
.nb-source:hover,.nb-source.focused { border-color:var(--purple); }
.nb-source-top { display:flex; align-items:center; gap:6px; font-size:11px; font-weight:700; color:var(--ink); }
.nb-source-num { width:19px; height:19px; border-radius:5px; display:grid; place-items:center; background:rgba(124,58,237,.12); color:var(--purple); }
.nb-score { margin-left:auto; font-size:10px; color:#15803d; }
.nb-source-text { margin-top:7px; font-size:11px; line-height:1.5; color:var(--med); display:-webkit-box; -webkit-line-clamp:5; -webkit-box-orient:vertical; overflow:hidden; }
.nb-preview-link { margin-top:8px; display:inline-block; border:0; background:none; padding:0; color:var(--purple); font:700 10px inherit; cursor:pointer; }
.nb-preview { position:fixed; inset:76px 24px 24px; z-index:900; display:none; flex-direction:column; background:var(--navy2); border:1px solid var(--bdr); border-radius:14px; box-shadow:0 25px 80px rgba(15,23,42,.3); overflow:hidden; }
.nb-preview.open { display:flex; }
.nb-preview-head { padding:10px 14px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--bdr); color:var(--ink); font-size:12px; font-weight:800; }
.nb-preview iframe { width:100%; flex:1; border:0; background:white; }
.nb-studio-grid { display:grid; grid-template-columns:1fr 1fr; gap:7px; margin-bottom:12px; }
.nb-studio-action { border:1px solid var(--bdr); border-radius:9px; background:var(--bg); color:var(--med); padding:10px 7px; font:700 10px/1.35 inherit; cursor:pointer; }
.nb-studio-action:hover { color:var(--purple); border-color:var(--purple); }
.nb-artifact { border:1px solid var(--bdr); border-radius:9px; background:var(--bg); padding:10px; margin-bottom:7px; cursor:pointer; }
.nb-artifact:hover { border-color:var(--purple); }.nb-artifact-type{font-size:9px;text-transform:uppercase;letter-spacing:.7px;color:var(--purple);font-weight:800}.nb-artifact-title{font-size:11px;color:var(--ink);font-weight:700;margin-top:3px}.nb-artifact-meta{font-size:9px;color:var(--lt);margin-top:4px}
.nb-artifact-view { position:fixed; inset:76px max(24px,8vw) 24px; z-index:910; display:none; flex-direction:column; background:var(--navy2); border:1px solid var(--bdr); border-radius:14px; box-shadow:0 25px 80px rgba(15,23,42,.3); overflow:hidden; }.nb-artifact-view.open{display:flex}.nb-artifact-body{padding:24px clamp(18px,5vw,60px);overflow:auto;white-space:pre-wrap;color:var(--ink);font:13px/1.7 inherit}
.nb-cap { display:flex; align-items:flex-start; gap:8px; border:1px solid var(--bdr); border-radius:9px; padding:9px; margin-bottom:7px; background:var(--bg); font-size:11px; line-height:1.45; color:var(--med); }
.nb-cap-dot { width:8px; height:8px; border-radius:50%; margin-top:4px; flex:0 0 auto; background:#16a34a; }
.nb-cap.planned .nb-cap-dot { background:#d97706; }
.nb-note { border:1px solid var(--bdr); border-radius:9px; background:var(--bg); padding:9px; margin-bottom:8px; }
.nb-note input,.nb-note textarea { width:100%; box-sizing:border-box; border:0; outline:0; background:transparent; color:var(--ink); font:12px inherit; }
.nb-note input { font-weight:700; margin-bottom:6px; }.nb-note textarea{min-height:105px;resize:vertical;line-height:1.5}
.nb-note-actions{display:flex;justify-content:flex-end;gap:5px;margin-top:6px}
.nb-upload { margin:10px 12px 0; border:1px dashed rgba(124,58,237,.4); border-radius:9px; padding:10px; text-align:center; color:var(--purple); font:700 11px inherit; cursor:pointer; }
.nb-upload:hover { background:rgba(124,58,237,.05); }
.nb-modal { position:fixed; inset:0; z-index:1000; background:rgba(15,23,42,.45); display:none; align-items:center; justify-content:center; padding:20px; }
.nb-modal.open { display:flex; }
.nb-modal-card { width:min(440px,100%); border-radius:14px; background:var(--navy2); box-shadow:0 24px 70px rgba(0,0,0,.2); padding:22px; }
.nb-modal h2 { margin:0 0 5px; color:var(--ink); font-size:18px; }
.nb-modal p { margin:0 0 16px; color:var(--lt); font-size:12px; }
.nb-field { display:block; margin:11px 0; }
.nb-field span { display:block; font-size:11px; font-weight:700; color:var(--med); margin-bottom:5px; }
.nb-field input,.nb-field textarea { width:100%; box-sizing:border-box; border:1px solid var(--bdr); border-radius:8px; padding:9px 10px; background:var(--bg); color:var(--ink); font:13px inherit; }
.nb-modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:17px; }
.nb-toast { display:none; position:fixed; right:20px; bottom:20px; z-index:1200; border-radius:9px; background:var(--ink); color:white; padding:10px 14px; font-size:12px; box-shadow:0 10px 30px rgba(0,0,0,.22); }
.nb-toast.error { background:#991b1b; }
.nb-check{accent-color:var(--purple)}.nb-mini-actions{display:flex;gap:5px;flex-wrap:wrap;margin-top:7px}.nb-config{border:1px solid var(--bdr);border-radius:9px;padding:9px;margin-bottom:10px;background:var(--bg)}.nb-config input,.nb-config select,.nb-config textarea{width:100%;box-sizing:border-box;margin:3px 0;padding:6px;border:1px solid var(--bdr);border-radius:6px;background:var(--navy2);color:var(--ink);font:10px inherit}.nb-job{border-left:3px solid var(--purple);padding:7px;margin:6px 0;background:var(--bg);font-size:10px}.nb-progress{height:5px;border-radius:5px;background:var(--bdr);overflow:hidden;margin-top:5px}.nb-progress span{display:block;height:100%;background:var(--purple)}.nb-quiz-option{display:block;width:100%;text-align:left;margin:7px 0;padding:10px;border:1px solid var(--bdr);border-radius:8px;background:var(--bg);color:var(--ink);cursor:pointer}.nb-quiz-option.selected{border-color:var(--purple);background:rgba(124,58,237,.08)}.nb-card{min-height:210px;display:grid;place-items:center;text-align:center;padding:30px;border:1px solid var(--bdr);border-radius:14px;background:var(--bg);font-size:18px;cursor:pointer}.nb-governance{font-size:10px;color:var(--med)}
@media(max-width:1050px){ .nb-shell{grid-template-columns:230px 1fr}.nb-sources{display:none} }
@media(max-width:720px){ body{overflow:auto}.nb-shell{height:auto;min-height:calc(100vh - 64px);grid-template-columns:1fr}.nb-side{display:none}.nb-main{min-height:calc(100vh - 64px)}.nb-prompts{grid-template-columns:1fr} }
</style>
</head>
<body>
<header class="topnav">
<a href="index.html" class="brand">Nexus One <span>AI</span></a>
<nav>
<a href="index.html">Home</a><a href="quickstart.html">Quick Start</a><a href="prompts.html">Prompt Library</a>
<div class="nav-dropdown"><button class="nav-drop-btn active">AI Tools ▾</button><div class="nav-drop-menu">
<span class="nav-drop-cat">MODEL BUILD /</span>
<a href="dataset-creation.html">Dataset Creation</a>
<a href="training.html">Model Training</a><span class="nav-drop-cat">ENABLED /</span><a href="tool-openwebui.html">Open WebUI</a><a href="nexus-notebook.html" class="active">Nexus Notebook</a><a href="nexus-compare.html">Nexus Compare</a><a href="tool-jupyter.html">Jupyter Notebook</a></div></div>
</nav>
<div class="nav-right"><div id="nav-org-logo" class="nav-org-logo"></div><span class="nav-tier-badge" data-brand="tier" data-tier-slug="basic">Basic Tier</span><a href="notifications.html" class="nav-icon-btn" title="Notifications">○</a></div>
</header>
<main class="nb-shell">
<aside class="nb-side">
<div class="nb-head">
<div class="nb-product"><span class="nb-mark">N</span><span>Nexus Notebook</span></div>
<div class="nb-sub">Knowledge centre + AI workspace</div>
<div class="nb-actions"><button class="nb-btn primary" onclick="openNewCollection()">+ Collection</button><button class="nb-btn" onclick="newConversation()">New chat</button></div>
</div>
<input class="nb-search" id="collection-search" placeholder="Search collections…" oninput="renderCollections()">
<div class="nb-label">Knowledge collections</div>
<div class="nb-collections" id="collections"><div class="nb-empty-small">Loading collections…</div></div>
<div class="nb-label">Recent conversations</div><div class="nb-history" id="conversation-list"><div class="nb-empty-small">Select a collection.</div></div>
</aside>
<section class="nb-main">
<div class="nb-toolbar">
<div class="nb-title-wrap"><div class="nb-title" id="workspace-title">Select a knowledge collection</div><div class="nb-title-sub" id="workspace-sub">Choose the evidence this notebook may use</div></div>
<button class="nb-private private" id="visibility-btn" onclick="toggleVisibility()" disabled>Private collection</button>
<select class="nb-model" id="model-select" title="Answer model"><option value="llama3">Loading models…</option></select>
</div>
<div class="nb-messages" id="messages">
<div class="nb-welcome" id="welcome"><div class="nb-welcome-icon">N</div><h1>Work with trusted knowledge</h1><p>Select a collection, upload source documents, and ask questions. Nexus Notebook retrieves relevant evidence before the local model answers.</p><div class="nb-prompts"><button class="nb-prompt" onclick="usePrompt('Summarise the key points in this collection.')">Summarise the collection</button><button class="nb-prompt" onclick="usePrompt('What policies or requirements should I pay attention to?')">Find important requirements</button><button class="nb-prompt" onclick="usePrompt('Compare the main positions across these documents.')">Compare the documents</button><button class="nb-prompt" onclick="usePrompt('List any dates, deadlines, or action items in the sources.')">Extract dates and actions</button></div></div>
</div>
<div class="nb-compose"><div class="nb-compose-inner"><textarea id="question" placeholder="Ask a question about the selected collection…" oninput="resizeComposer()"></textarea><div class="nb-compose-row"><span class="nb-context" id="context-label">No collection selected</span><button class="nb-send" id="send" onclick="askNotebook()" disabled>Ask Nexus</button></div></div></div>
</section>
<aside class="nb-sources">
<div class="nb-source-head"><span class="nb-source-title">Notebook context</span><button class="nb-btn" onclick="refreshWorkspace()">Refresh</button></div>
<div class="nb-tabs"><button class="nb-tab active" data-panel="documents" onclick="switchPanel('documents')">Docs</button><button class="nb-tab" data-panel="sources" onclick="switchPanel('sources')">Citations</button><button class="nb-tab" data-panel="notes" onclick="switchPanel('notes')">Notes</button><button class="nb-tab" data-panel="studio" onclick="switchPanel('studio')">Studio</button><button class="nb-tab" data-panel="capabilities" onclick="switchPanel('capabilities')">Engine</button></div>
<label class="nb-upload" for="file-upload">+ Add local sources<input id="file-upload" type="file" hidden multiple accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.txt,.md,.png,.jpg,.jpeg,.tif,.tiff,.mp3,.wav,.m4a,.flac,.ogg" onchange="uploadDocuments(this.files)"></label>
<div class="nb-mini-actions" style="padding:0 12px"><button class="nb-btn" onclick="addTextSource('text')">Paste text</button><button class="nb-btn" onclick="addTextSource('webpage')">Add webpage</button></div>
<div class="nb-panel" id="documents-panel"><div class="nb-empty-small">Select a collection to see its documents.</div></div>
<div class="nb-panel" id="sources-panel" hidden><div class="nb-empty-small">Citations from your latest answer will appear here.</div></div>
<div class="nb-panel" id="notes-panel" hidden><button class="nb-btn primary" style="width:100%;margin-bottom:9px" onclick="newNote()">+ New note</button><div id="notes-list"><div class="nb-empty-small">Select a collection to see your notes.</div></div></div>
<div class="nb-panel" id="studio-panel" hidden><div class="nb-config"><strong>Artifact settings</strong><select id="cfg-difficulty"><option>Standard</option><option>Beginner</option><option>Advanced</option></select><input id="cfg-audience" placeholder="Audience (e.g. managers)"><input id="cfg-language" value="English" placeholder="Language"><select id="cfg-length"><option>Medium</option><option>Short</option><option>Long</option></select><textarea id="cfg-instructions" placeholder="Custom instructions"></textarea><small>Checked documents are used; none means all sources.</small></div><div class="nb-studio-grid"><button class="nb-studio-action" onclick="generateArtifact('briefing')">Briefing document</button><button class="nb-studio-action" onclick="generateArtifact('faq')">FAQ</button><button class="nb-studio-action" onclick="generateArtifact('timeline')">Timeline</button><button class="nb-studio-action" onclick="generateArtifact('study_guide')">Study guide</button><button class="nb-studio-action" onclick="generateArtifact('quiz')">Quiz</button><button class="nb-studio-action" onclick="generateArtifact('flashcards')">Flashcards</button><button class="nb-studio-action" onclick="generateArtifact('mind_map')">Mind map</button><button class="nb-studio-action" onclick="generateArtifact('audio_overview')">Audio overview <small>Beta</small></button></div><div id="job-list"></div><div id="artifact-list"><div class="nb-empty-small">Select a collection to create Studio outputs.</div></div></div>
<div class="nb-panel" id="capabilities-panel" hidden><div class="nb-empty-small">Loading engine capabilities…</div></div>
</aside>
</main>
<div class="nb-modal" id="collection-modal"><div class="nb-modal-card"><h2>Create knowledge collection</h2><p>Group documents that should be searched and cited together.</p><label class="nb-field"><span>Name</span><input id="new-name" maxlength="80" placeholder="e.g. HR Policies"></label><label class="nb-field"><span>Description</span><textarea id="new-description" rows="3" placeholder="What belongs in this collection?"></textarea></label><label style="display:flex;gap:8px;align-items:flex-start;color:var(--med);font-size:12px;line-height:1.4"><input type="checkbox" id="new-shared" checked><span><strong>Share with all users</strong><br><span style="color:var(--lt)">Every authenticated Nexus user can search and chat with this collection. Only the owner and administrators can modify it.</span></span></label><div class="nb-modal-actions"><button class="nb-btn" onclick="closeNewCollection()">Cancel</button><button class="nb-btn primary" id="create-btn" onclick="createCollection()">Create collection</button></div></div></div>
<div class="nb-preview" id="preview"><div class="nb-preview-head"><span id="preview-title">Document preview</span><button class="nb-btn" onclick="closePreview()">Close</button></div><iframe id="preview-frame" title="PDF document preview"></iframe></div>
<div class="nb-artifact-view" id="artifact-view"><div class="nb-preview-head"><span id="artifact-title">Studio artifact</span><div class="nb-mini-actions"><button class="nb-btn" onclick="exportArtifact('md')">Markdown</button><button class="nb-btn" onclick="exportArtifact('docx')">Word</button><button class="nb-btn" onclick="exportArtifact('pdf')">PDF</button><button class="nb-btn" onclick="exportArtifact('pptx')">PowerPoint</button><button class="nb-btn" onclick="exportArtifact('csv')">CSV</button><button class="nb-btn" onclick="closeArtifact()">Close</button></div></div><audio id="artifact-audio" controls style="display:none;width:calc(100% - 32px);margin:14px 16px 0"></audio><div class="nb-artifact-body" id="artifact-body"></div></div>
<div class="nb-toast" id="toast"></div>
<script>
const API='/api';
const NOTEBOOK_API='/notebook-api';
let collections=[];
let selectedCollection=null;
let latestSources=[];
let conversation=[];
let activeConversationId=null;
let conversationHistory=[];
let notes=[];
let documents=[];let selectedDocIds=new Set();let activeArtifact=null;let quizState={};let flashIndex=0;let flashFlipped=false;let jobTimer=null;
function esc(value){ return String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function notify(message,error=false){const el=document.getElementById('toast');el.textContent=message;el.className='nb-toast'+(error?' error':'');el.style.display='block';setTimeout(()=>el.style.display='none',3500)}
function fmtBytes(bytes){if(!bytes)return '—';if(bytes<1024*1024)return (bytes/1024).toFixed(0)+' KB';return (bytes/1024/1024).toFixed(1)+' MB'}
function openNewCollection(){document.getElementById('collection-modal').classList.add('open');setTimeout(()=>document.getElementById('new-name').focus(),50)}
function closeNewCollection(){document.getElementById('collection-modal').classList.remove('open')}
async function loadCollections(){
try{const res=await fetch(`${NOTEBOOK_API}/collections`,{credentials:'include'});if(!res.ok)throw new Error('Unable to load collections');const data=await res.json();collections=data.collections||[];renderCollections();if(selectedCollection){const updated=collections.find(c=>c.id===selectedCollection.id);if(updated)selectedCollection=updated}}
catch(error){document.getElementById('collections').innerHTML='<div class="nb-empty-small">Collections could not be loaded.<br>Check the knowledge services.</div>';notify(error.message,true)}
}
function renderCollections(){
const q=document.getElementById('collection-search').value.trim().toLowerCase();const list=q?collections.filter(c=>(c.name+' '+(c.description||'')).toLowerCase().includes(q)):collections;const el=document.getElementById('collections');
if(!list.length){el.innerHTML='<div class="nb-empty-small">No collections yet.<br>Create one to begin.</div>';return}
el.innerHTML=list.map(c=>`<div class="nb-collection ${selectedCollection?.id===c.id?'active':''}" onclick="selectCollection(${c.id})"><div class="nb-col-name"><span>${esc(c.name)}</span><span>${c.doc_count||0}</span></div><div class="nb-col-meta">${c.visibility==='all'?'Shared with all users':'Private'} · Owner: ${esc(c.owner_username||'admin')} · ${c.chunk_count||0} chunks</div></div>`).join('');
}
async function createCollection(){
const name=document.getElementById('new-name').value.trim();const description=document.getElementById('new-description').value.trim();const shareWithAll=document.getElementById('new-shared').checked;if(!name){notify('Enter a collection name',true);return}const btn=document.getElementById('create-btn');btn.disabled=true;
try{const res=await fetch(`${NOTEBOOK_API}/collections`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,description,embed_model:'nomic-embed-text',share_with_all:shareWithAll})});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Collection creation failed');closeNewCollection();document.getElementById('new-name').value='';document.getElementById('new-description').value='';await loadCollections();const created=collections.find(c=>c.name===name);if(created)selectCollection(created.id);notify(`Collection "${name}" created`)}catch(error){notify(error.message,true)}finally{btn.disabled=false}
}
async function selectCollection(id){selectedCollection=collections.find(c=>c.id===id)||null;if(!selectedCollection)return;selectedDocIds=new Set();renderCollections();document.getElementById('workspace-title').textContent=selectedCollection.name;document.getElementById('workspace-sub').textContent=`${selectedCollection.description||'Knowledge collection'} · Owner: ${selectedCollection.owner_username||'admin'}`;document.getElementById('context-label').textContent=`Using ${selectedCollection.name} · ${selectedCollection.doc_count||0} documents`;document.getElementById('send').disabled=false;updateVisibilityButton();latestSources=[];renderSources();activeConversationId=null;conversation=[];renderConversation();await Promise.all([loadDocuments(),loadArtifacts(),loadNotes(),loadConversations()]);}
function updateVisibilityButton(){const btn=document.getElementById('visibility-btn');if(!selectedCollection){btn.disabled=true;btn.textContent='Private collection';return}const shared=selectedCollection.visibility==='all';btn.textContent=shared?'Shared with all users':'Private collection';btn.classList.toggle('private',!shared);btn.disabled=!selectedCollection.can_manage;btn.title=selectedCollection.can_manage?'Change collection visibility':'Only the owner or an administrator can change sharing'}
async function toggleVisibility(){if(!selectedCollection?.can_manage)return;const visibility=selectedCollection.visibility==='all'?'private':'all';try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/share`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({visibility})});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Sharing update failed');selectedCollection.visibility=visibility;const cached=collections.find(c=>c.id===selectedCollection.id);if(cached)cached.visibility=visibility;updateVisibilityButton();renderCollections();notify(visibility==='all'?'Collection shared with all users':'Collection is now private')}catch(error){notify(error.message,true)}}
async function loadDocuments(){
if(!selectedCollection)return;const panel=document.getElementById('documents-panel');panel.innerHTML='<div class="nb-empty-small">Loading documents…</div>';
try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/documents`,{credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Unable to load documents');documents=data.documents||[];panel.innerHTML=documents.length?documents.map(d=>`<div class="nb-doc"><div class="nb-doc-name"><label><input class="nb-check" type="checkbox" ${selectedDocIds.has(d.id)?'checked':''} ${d.governance?.archived_at?'disabled':''} onchange="toggleDoc(${d.id},this.checked)"> ${esc(d.orig_name)}</label></div><div class="nb-doc-meta">${d.governance?.archived_at?'Archived · ':''}${d.status} · ${d.chunk_count||0} chunks · ${fmtBytes(d.size_bytes)} · v${d.governance?.version||1}${d.governance?.expires_at?` · expires ${new Date(d.governance.expires_at).toLocaleDateString()}`:''}</div>${selectedCollection.can_manage?`<div class="nb-mini-actions"><button class="nb-btn" onclick="replaceDocument(${d.id})">Upload replacement</button><button class="nb-btn" onclick="showVersions(${d.id})">Version history</button>${d.governance?.archived_at?`<button class="nb-btn" onclick="restoreVersion(${d.id})">Restore</button>`:''}</div>`:''}</div>`).join(''):'<div class="nb-empty-small">No documents yet.<br>Upload files to build this notebook.</div>'}
catch(error){panel.innerHTML=`<div class="nb-empty-small">${esc(error.message)}</div>`}
}
async function toggleDoc(id,checked){checked?selectedDocIds.add(id):selectedDocIds.delete(id);if(activeConversationId)await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/conversations/${activeConversationId}/sources`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({source_ids:[...selectedDocIds]})});document.getElementById('context-label').textContent=selectedDocIds.size?`Using ${selectedDocIds.size} selected source(s)`:`Using all sources in ${selectedCollection.name}`}
function replaceDocument(id){const input=document.createElement('input');input.type='file';input.onchange=async()=>{if(!input.files[0])return;const fd=new FormData();fd.append('file',input.files[0]);fd.append('supersedes_doc_id',id);const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/documents`,{method:'POST',credentials:'include',body:fd});const data=await res.json();if(!res.ok)notify(data.detail||'Replacement failed',true);else{notify('Replacement uploaded as a new version');loadDocuments()}};input.click()}
async function showVersions(id){const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/documents/${id}/versions`,{credentials:'include'});const data=await res.json();if(!res.ok){notify(data.detail||'Version history unavailable',true);return}alert(data.versions.map(v=>`v${v.version} — ${v.orig_name} — ${v.archived_at?'archived':'active'}`).join('\n'))}
async function restoreVersion(id){const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/documents/${id}/restore`,{method:'POST',credentials:'include'});const data=await res.json();if(!res.ok)notify(data.detail||'Restore failed',true);else{notify('Document version restored');loadDocuments()}}
async function uploadDocuments(files){
if(!selectedCollection){notify('Select a collection first',true);document.getElementById('file-upload').value='';return}if(!selectedCollection.can_manage){notify('Only the collection owner or an administrator can upload documents',true);return}for(const file of files){const fd=new FormData();fd.append('file',file);notify(`Uploading ${file.name}…`);try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/documents`,{method:'POST',credentials:'include',body:fd});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Upload failed');notify(`${file.name} uploaded and queued for indexing`)}catch(error){notify(`${file.name}: ${error.message}`,true)}}document.getElementById('file-upload').value='';await loadCollections();selectedCollection=collections.find(c=>c.id===selectedCollection.id)||selectedCollection;await loadDocuments();}
async function loadConversations(){if(!selectedCollection)return;try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/conversations`,{credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Conversation history unavailable');conversationHistory=data.conversations||[];renderConversationHistory();if(!activeConversationId&&conversationHistory.length)await openConversation(conversationHistory[0].id)}catch(error){document.getElementById('conversation-list').innerHTML=`<div class="nb-empty-small">${esc(error.message)}</div>`}}
function renderConversationHistory(){const el=document.getElementById('conversation-list');el.innerHTML=conversationHistory.length?conversationHistory.map(c=>`<div class="nb-history-item ${activeConversationId===c.id?'active':''}" onclick="openConversation('${c.id}')" title="${esc(c.title)}">${esc(c.title)}</div>`).join(''):'<div class="nb-empty-small">No saved conversations.</div>'}
async function newConversation(){if(!selectedCollection){notify('Select a collection first',true);return}try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/conversations`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({source_ids:[...selectedDocIds]})});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Unable to create conversation');activeConversationId=data.id;conversation=[];latestSources=[];conversationHistory.unshift(data);renderConversationHistory();renderConversation();renderSources();document.getElementById('question').focus()}catch(error){notify(error.message,true)}}
async function openConversation(id){if(!selectedCollection)return;try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/conversations/${id}`,{credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Conversation unavailable');activeConversationId=id;selectedDocIds=new Set(data.conversation?.source_ids||[]);conversation=(data.messages||[]).map(m=>({role:m.role,content:m.content,at:m.created_at,sources:m.sources||[]}));latestSources=[...(conversation.slice().reverse().find(m=>m.role==='assistant'&&m.sources?.length)?.sources||[])];renderConversationHistory();renderConversation();renderSources();loadDocuments()}catch(error){notify(error.message,true)}}
async function persistMessage(role,content,sources=[]){if(!activeConversationId)await newConversation();if(!activeConversationId)return;const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/conversations/${activeConversationId}/messages`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({role,content,sources})});if(!res.ok){const data=await res.json();throw new Error(data.detail||'Message could not be saved')}loadConversations()}
function renderConversation(){const messages=document.getElementById('messages');if(!conversation.length){messages.innerHTML=document.getElementById('welcome')?.outerHTML||`<div class="nb-welcome" id="welcome"><div class="nb-welcome-icon">N</div><h1>${esc(selectedCollection?.name||'Nexus Notebook')}</h1><p>Ask questions grounded in the selected knowledge collection.</p></div>`;return}messages.innerHTML=conversation.map((m,i)=>`<div class="nb-msg ${m.role}"><div class="nb-avatar">${m.role==='user'?'U':'N'}</div><div class="nb-bubble">${formatAnswer(m.content)}${m.role==='assistant'&&!m.pending?`<div class="nb-mini-actions"><button class="nb-btn" onclick="saveMessageNote(${i})">Save answer to note</button></div>`:''}</div></div>`).join('');messages.scrollTop=messages.scrollHeight}
function formatAnswer(text){return esc(text).replace(/\[(\d+)\]/g,'<button class="nb-citation" onclick="focusSource($1)">[$1]</button>')}
function addMessage(role,content){conversation.push({role,content,at:new Date().toISOString()});renderConversation()}
function usePrompt(text){document.getElementById('question').value=text;resizeComposer();document.getElementById('question').focus()}
function resizeComposer(){const el=document.getElementById('question');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,150)+'px'}
async function askNotebook(){
const input=document.getElementById('question');const question=input.value.trim();if(!selectedCollection||!question)return;const send=document.getElementById('send');send.disabled=true;if(!activeConversationId)await newConversation();addMessage('user',question);await persistMessage('user',question);input.value='';resizeComposer();
const waitIndex=conversation.length;conversation.push({role:'assistant',content:'Searching the selected knowledge collection…',pending:true});renderConversation();
try{
const retrieval=await fetch(`${NOTEBOOK_API}/query`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({collection_id:selectedCollection.id,query:question,n_results:6,source_ids:[...selectedDocIds]})});const rdata=await retrieval.json();if(!retrieval.ok)throw new Error(rdata.detail||'Knowledge retrieval failed');latestSources=rdata.results||[];renderSources();switchPanel('sources');
if(!latestSources.length)throw new Error('No relevant evidence was found in this collection');
const context=latestSources.map((s,i)=>`[${i+1}] SOURCE: ${s.source}${s.page?`, page ${s.page}`:''}\n${s.text}`).join('\n\n');
const system=`You are Nexus Notebook, an enterprise knowledge assistant. Answer only from the supplied evidence. Cite every factual claim with source numbers such as [1] or [2]. If evidence is insufficient, state that clearly. Do not invent facts.\n\nEVIDENCE:\n${context}`;
const model=document.getElementById('model-select').value||'llama3';const answerRes=await fetch(`${API}/chat`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({model,message:question,system_prompt:system,temperature:0.2,max_tokens:900})});const answerData=await answerRes.json();if(!answerRes.ok)throw new Error(answerData.detail||'Local model could not answer');
const answer=answerData.response||answerData.message||'No answer returned';conversation[waitIndex]={role:'assistant',content:answer,sources:latestSources};await persistMessage('assistant',answer,latestSources);renderConversation();
}catch(error){const failed=`I could not complete this notebook query: ${error.message}`;conversation[waitIndex]={role:'assistant',content:failed};try{await persistMessage('assistant',failed)}catch{}renderConversation();notify(error.message,true)}finally{send.disabled=false}
}
async function addTextSource(kind){if(!selectedCollection?.can_manage){notify('Only collection managers can add sources',true);return}const content=prompt(kind==='webpage'?'Enter webpage URL':'Paste source text');if(!content)return;const title=prompt('Source title',kind==='webpage'?'Web page':'Pasted text')||'Source';try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/text-source`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind,title,content})});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Source could not be added');notify('Source added and queued for local indexing');loadDocuments()}catch(error){notify(error.message,true)}}
function renderSources(){const panel=document.getElementById('sources-panel');if(!latestSources.length){panel.innerHTML='<div class="nb-empty-small">Citations from your latest answer will appear here.</div>';return}const weak=Math.max(...latestSources.map(s=>s.score||0))<.45;panel.innerHTML=(weak?'<div class="nb-warning">Low relevance: verify the answer or add stronger source documents.</div>':'')+latestSources.map((s,i)=>`<div class="nb-source" id="source-${i+1}"><div class="nb-source-top"><span class="nb-source-num">${i+1}</span><span>${esc(s.source||'Source')}</span><span class="nb-score">${Math.round((s.score||0)*100)}%</span></div><div class="nb-doc-meta">${s.page?`Page ${s.page} · `:''}Chunk ${(s.chunk??0)+1} · ${esc(s.retrieval||'vector')}</div><div class="nb-source-text">${esc(s.text)}</div><div class="nb-mini-actions">${s.preview_url?`<button class="nb-preview-link" onclick="openPreview(event,${i})">Preview source${s.page?` at page ${s.page}`:''} →</button>`:''}<button class="nb-preview-link" onclick="saveCitationNote(${i})">Save citation to note</button></div></div>`).join('')}
function openPreview(event,index){event.stopPropagation();const source=latestSources[index];if(!source?.preview_url)return;document.getElementById('preview-title').textContent=source.source+(source.page?` · Page ${source.page}`:'');document.getElementById('preview-frame').src=source.preview_url+(source.page?`#page=${source.page}`:'');document.getElementById('preview').classList.add('open')}
function closePreview(){document.getElementById('preview').classList.remove('open');document.getElementById('preview-frame').src='about:blank'}
function focusSource(number){switchPanel('sources');const el=document.getElementById(`source-${number}`);if(!el)return;document.querySelectorAll('.nb-source').forEach(x=>x.classList.remove('focused'));el.classList.add('focused');el.scrollIntoView({behavior:'smooth',block:'center'})}
async function loadNotes(){if(!selectedCollection)return;try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/notes`,{credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Notes unavailable');notes=data.notes||[];renderNotes()}catch(error){document.getElementById('notes-list').innerHTML=`<div class="nb-empty-small">${esc(error.message)}</div>`}}
function renderNotes(){const el=document.getElementById('notes-list');el.innerHTML=notes.length?notes.map(n=>`<div class="nb-note" data-id="${n.id}"><input value="${esc(n.title)}" maxlength="120" aria-label="Note title"><textarea maxlength="100000" aria-label="Note content">${esc(n.content)}</textarea><div class="nb-note-actions">${n.id.startsWith('new-')?'':`<button class="nb-btn" onclick="exportNote('${n.id}','md')">MD</button><button class="nb-btn" onclick="exportNote('${n.id}','docx')">Word</button><button class="nb-btn" onclick="exportNote('${n.id}','pdf')">PDF</button>`}<button class="nb-btn" onclick="deleteNote('${n.id}')">Delete</button><button class="nb-btn primary" onclick="saveNote('${n.id}')">Save</button></div><div class="nb-doc-meta">Updated ${new Date(n.updated_at).toLocaleString()}</div></div>`).join(''):'<div class="nb-empty-small">No notes yet. Create one for your private observations.</div>'}
function newNote(){if(!selectedCollection){notify('Select a collection first',true);return}notes.unshift({id:'new-'+Date.now(),title:'Untitled note',content:'',updated_at:new Date().toISOString()});renderNotes();switchPanel('notes');document.querySelector('.nb-note input')?.select()}
async function saveNote(id){const card=document.querySelector(`.nb-note[data-id="${id}"]`);if(!card)return;const isNew=id.startsWith('new-');try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/notes`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:isNew?null:id,title:card.querySelector('input').value,content:card.querySelector('textarea').value})});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Note could not be saved');await loadNotes();notify('Note saved on the server')}catch(error){notify(error.message,true)}}
async function deleteNote(id){if(id.startsWith('new-')){notes=notes.filter(n=>n.id!==id);renderNotes();return}try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/notes/${id}`,{method:'DELETE',credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Note could not be deleted');await loadNotes();notify('Note deleted')}catch(error){notify(error.message,true)}}
async function createNoteFrom(title,content){const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/notes`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,content})});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Note could not be saved');await loadNotes();switchPanel('notes');notify('Saved to an editable private note')}
function saveMessageNote(index){const item=conversation[index];createNoteFrom('Saved Nexus answer',item.content+'\n\n'+(item.sources||[]).map((s,i)=>`[${i+1}] ${s.source}${s.page?`, page ${s.page}`:''}`).join('\n')).catch(e=>notify(e.message,true))}
function saveCitationNote(index){const s=latestSources[index];createNoteFrom(`Citation — ${s.source}`,`${s.source}${s.page?`, page ${s.page}`:''}\n\n${s.text}`).catch(e=>notify(e.message,true))}
function exportNote(id,format){location.href=`${NOTEBOOK_API}/collections/${selectedCollection.id}/notes/${id}/export?format=${format}`}
async function loadArtifacts(){const list=document.getElementById('artifact-list');if(!selectedCollection){list.innerHTML='<div class="nb-empty-small">Select a collection to create Studio outputs.</div>';return}try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/studio`,{credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Studio unavailable');const artifacts=data.artifacts||[];list.innerHTML=artifacts.length?artifacts.map(a=>`<div class="nb-artifact" onclick="openArtifact('${a.id}')"><div class="nb-artifact-type">${esc(a.artifact_type.replace('_',' '))}</div><div class="nb-artifact-title">${esc(a.title)}</div><div class="nb-artifact-meta">Created by ${esc(a.owner_username)} · ${new Date(a.created_at).toLocaleString()}</div></div>`).join(''):'<div class="nb-empty-small">No Studio outputs yet.</div>'}catch(error){list.innerHTML=`<div class="nb-empty-small">${esc(error.message)}</div>`}}
async function generateArtifact(type){if(!selectedCollection){notify('Select a collection first',true);return}switchPanel('studio');const payload={type,model:document.getElementById('model-select').value,source_ids:[...selectedDocIds],difficulty:document.getElementById('cfg-difficulty').value,audience:document.getElementById('cfg-audience').value,language:document.getElementById('cfg-language').value,length:document.getElementById('cfg-length').value,instructions:document.getElementById('cfg-instructions').value};try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/jobs`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Generation failed');notify(`${type.replace('_',' ')} queued — you can keep working`);loadJobs()}catch(error){notify(error.message,true)}}
async function loadJobs(){if(!selectedCollection)return;const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/jobs`,{credentials:'include'});const data=await res.json();const jobs=data.jobs||[];document.getElementById('job-list').innerHTML=jobs.filter(j=>j.status!=='completed'||Date.now()-new Date(j.updated_at)<120000).map(j=>`<div class="nb-job"><strong>${esc(j.artifact_type.replace('_',' '))}</strong> · ${esc(j.status)}${j.error?` · ${esc(j.error)}`:''}<div class="nb-progress"><span style="width:${j.progress}%"></span></div><div class="nb-mini-actions">${['queued','running'].includes(j.status)?`<button class="nb-btn" onclick="jobAction('${j.id}','cancel')">Cancel</button>`:''}${['failed','cancelled'].includes(j.status)?`<button class="nb-btn" onclick="jobAction('${j.id}','retry')">Retry</button>`:''}</div></div>`).join('');if(jobs.some(j=>['queued','running'].includes(j.status))){clearTimeout(jobTimer);jobTimer=setTimeout(loadJobs,2000)}else{loadArtifacts()}}
async function jobAction(id,action){const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/jobs/${id}/${action}`,{method:'POST',credentials:'include'});const data=await res.json();if(!res.ok)notify(data.detail||`Could not ${action} job`,true);else{notify(`Job ${action} requested`);loadJobs()}}
async function openArtifact(id){try{const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/studio/${id}`,{credentials:'include'});const data=await res.json();if(!res.ok)throw new Error(data.detail||'Artifact unavailable');showArtifact(data)}catch(error){notify(error.message,true)}}
function showArtifact(data){activeArtifact=data;quizState={};flashIndex=0;flashFlipped=false;document.getElementById('artifact-title').textContent=data.title;const body=document.getElementById('artifact-body');let parsed=null;try{parsed=JSON.parse(data.content)}catch{}if(data.artifact_type==='quiz'&&parsed?.questions)renderQuiz(parsed.questions);else if(data.artifact_type==='flashcards'&&parsed?.cards)renderFlashcards(parsed.cards);else body.textContent=data.content;const audio=document.getElementById('artifact-audio');audio.style.display=data.audio_url?'block':'none';audio.src=data.audio_url||'';document.getElementById('artifact-view').classList.add('open')}
function renderQuiz(questions,reviewMissed=false){const items=reviewMissed?questions.filter((q,i)=>quizState[i]!==q.answer):questions;document.getElementById('artifact-body').innerHTML=items.map((q,i)=>`<section data-q="${i}"><strong>${i+1}. ${esc(q.question)}</strong>${q.options.map((o,j)=>`<button class="nb-quiz-option ${quizState[i]===j?'selected':''}" onclick="quizState[${i}]=${j};this.parentElement.querySelectorAll('.nb-quiz-option').forEach(x=>x.classList.remove('selected'));this.classList.add('selected')">${String.fromCharCode(65+j)}. ${esc(o)}</button>`).join('')}</section>`).join('')+`<div class="nb-mini-actions"><button class="nb-btn primary" onclick="scoreQuiz()">Score quiz</button></div>`}
async function scoreQuiz(){const questions=JSON.parse(activeArtifact.content).questions;const score=questions.filter((q,i)=>quizState[i]===q.answer).length;await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/studio/${activeArtifact.id}/attempts`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({answers:quizState,score,total:questions.length})});document.getElementById('artifact-body').insertAdjacentHTML('afterbegin',`<div class="nb-warning">Score: ${score}/${questions.length}. <button class="nb-btn" onclick="renderQuiz(JSON.parse(activeArtifact.content).questions,true)">Review missed</button> <button class="nb-btn" onclick="quizState={};renderQuiz(JSON.parse(activeArtifact.content).questions)">Retake</button></div>`)}
function renderFlashcards(cards){const card=cards[flashIndex];document.getElementById('artifact-body').innerHTML=`<div class="nb-card" onclick="flashFlipped=!flashFlipped;renderFlashcards(JSON.parse(activeArtifact.content).cards)">${esc(flashFlipped?card.back:card.front)}<small><br>${flashFlipped?esc(card.source||''):'Click to flip'}</small></div><div class="nb-mini-actions"><button class="nb-btn" onclick="flashIndex=(flashIndex-1+${cards.length})%${cards.length};flashFlipped=false;renderFlashcards(JSON.parse(activeArtifact.content).cards)">Previous</button><button class="nb-btn" onclick="cards.sort(()=>Math.random()-.5);flashIndex=0;flashFlipped=false;renderFlashcards(cards)">Shuffle</button><button class="nb-btn primary" onclick="flashIndex=(flashIndex+1)%${cards.length};flashFlipped=false;renderFlashcards(JSON.parse(activeArtifact.content).cards)">Next</button><span>${flashIndex+1}/${cards.length}</span></div>`}
function exportArtifact(format){if(activeArtifact)location.href=`${NOTEBOOK_API}/collections/${selectedCollection.id}/studio/${activeArtifact.id}/export?format=${format}`}
function closeArtifact(){document.getElementById('artifact-view').classList.remove('open');const audio=document.getElementById('artifact-audio');audio.pause();audio.src=''}
function switchPanel(name){document.getElementById('documents-panel').hidden=name!=='documents';document.getElementById('sources-panel').hidden=name!=='sources';document.getElementById('notes-panel').hidden=name!=='notes';document.getElementById('studio-panel').hidden=name!=='studio';document.getElementById('capabilities-panel').hidden=name!=='capabilities';document.querySelectorAll('.nb-tab').forEach(t=>t.classList.toggle('active',t.dataset.panel===name));if(name==='studio'){loadArtifacts();loadJobs()}if(name==='notes')loadNotes();if(name==='capabilities')loadCapabilities()}
async function loadCapabilities(){const panel=document.getElementById('capabilities-panel');try{const [capRes,govRes]=await Promise.all([fetch(`${NOTEBOOK_API}/capabilities`,{credentials:'include'}),fetch(`${NOTEBOOK_API}/governance`,{credentials:'include'})]);const data=await capRes.json(),gov=await govRes.json();if(!capRes.ok)throw new Error(data.detail||'Capabilities unavailable');let groupHtml='';if(gov.is_admin){const gr=await fetch(`${NOTEBOOK_API}/groups`,{credentials:'include'}),gd=await gr.json();groupHtml=`<div class="nb-label" style="padding-left:0">Permission groups</div><div class="nb-config"><input id="group-name" placeholder="Group name"><input id="group-members" placeholder="Usernames, comma-separated"><button class="nb-btn" onclick="createPermissionGroup()">Create group</button><button class="nb-btn" onclick="enforceRetention()">Enforce retention now</button></div>`+(gd.groups||[]).map(g=>`<div class="nb-cap"><span style="flex:1"><strong>${esc(g.name)}</strong><br>${esc(g.members.join(', ')||'No members')}<div class="nb-mini-actions"><button class="nb-btn" onclick="editPermissionGroup('${g.id}','${esc(g.name)}','${esc(g.members.join(','))}')">Edit</button>${selectedCollection?.can_manage?`<button class="nb-btn" onclick="grantPermissionGroup('${g.id}')">Grant collection</button>`:''}<button class="nb-btn" onclick="deletePermissionGroup('${g.id}')">Delete</button></div></span></div>`).join('')};panel.innerHTML='<div class="nb-label" style="padding-left:0">Governance & usage</div>'+(selectedCollection?.can_manage?`<div class="nb-config"><input id="retention-days" type="number" value="365" min="1" max="3650" title="Retention days"><input id="expiry-days" type="number" value="365" min="1" max="3650" title="Document expiry days"><button class="nb-btn" onclick="saveGovernance()">Save retention</button></div>`:'')+groupHtml+'<div class="nb-governance">'+gov.usage.map(x=>`${esc(x.action)}: ${x.count}`).join('<br>')+'</div><div class="nb-label" style="padding-left:0">Recent audit</div>'+gov.audit.slice(0,20).map(x=>`<div class="nb-cap"><span>${esc(x.username)} · ${esc(x.action)}<br><small>${new Date(x.occurred_at).toLocaleString()}</small></span></div>`).join('')+'<div class="nb-label" style="padding-left:0">Active engine</div>'+data.active.map(item=>`<div class="nb-cap"><span class="nb-cap-dot"></span><span>${esc(item)}</span></div>`).join('')}catch(error){panel.innerHTML=`<div class="nb-empty-small">${esc(error.message)}</div>`}}
async function saveGovernance(){const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/governance`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({retention_days:+document.getElementById('retention-days').value,document_expiry_days:+document.getElementById('expiry-days').value})});const data=await res.json();if(!res.ok)notify(data.detail||'Governance update failed',true);else notify('Retention and document expiry controls saved')}
async function createPermissionGroup(){const name=document.getElementById('group-name').value,members=document.getElementById('group-members').value.split(',').map(x=>x.trim()).filter(Boolean);const res=await fetch(`${NOTEBOOK_API}/groups`,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,members})});const data=await res.json();if(!res.ok)notify(data.detail||'Group creation failed',true);else{notify('Permission group created');loadCapabilities()}}
async function editPermissionGroup(id,name,members){const nextName=prompt('Group name',name);if(!nextName)return;const nextMembers=(prompt('Usernames, comma-separated',members)||'').split(',').map(x=>x.trim()).filter(Boolean);const res=await fetch(`${NOTEBOOK_API}/groups/${id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:nextName,members:nextMembers})});if(res.ok){notify('Permission group updated');loadCapabilities()}else notify((await res.json()).detail||'Update failed',true)}
async function grantPermissionGroup(id){const res=await fetch(`${NOTEBOOK_API}/collections/${selectedCollection.id}/groups/${id}`,{method:'POST',credentials:'include'});if(res.ok)notify('Group granted access to this collection');else notify((await res.json()).detail||'Grant failed',true)}
async function deletePermissionGroup(id){if(!confirm('Delete this permission group?'))return;const res=await fetch(`${NOTEBOOK_API}/groups/${id}`,{method:'DELETE',credentials:'include'});if(res.ok){notify('Permission group deleted');loadCapabilities()}else notify((await res.json()).detail||'Delete failed',true)}
async function enforceRetention(){const res=await fetch(`${NOTEBOOK_API}/governance/enforce`,{method:'POST',credentials:'include'});const data=await res.json();if(res.ok){notify(`Retention enforced: ${data.documents_archived} document(s) archived`);loadDocuments()}else notify(data.detail||'Retention enforcement failed',true)}
async function loadModels(){try{const res=await fetch(`${API}/models/list`,{credentials:'include'});const data=await res.json();const raw=data.models||[];const models=raw.map(m=>typeof m==='string'?m:(m.name||m.model)).filter(Boolean);const select=document.getElementById('model-select');select.innerHTML=models.length?models.map(m=>`<option value="${esc(m)}">${esc(m)}</option>`).join(''):'<option value="llama3">llama3</option>'}catch{document.getElementById('model-select').innerHTML='<option value="llama3">llama3</option>'}}
async function refreshWorkspace(){await loadCollections();if(selectedCollection)await loadDocuments();notify('Notebook refreshed')}
document.getElementById('question').addEventListener('keydown',event=>{if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();askNotebook()}});
document.addEventListener('cezenAuthReady',()=>{if(selectedCollection)loadConversations()});
loadCollections();loadModels();loadCapabilities();
</script>
<script src="auth.js?v=20260721-consolidated"></script>
<script src="branding.js?v=20260721-consolidated"></script>
</body>
</html>