aipackage/cezen-portal/chat-multi.html
Jino Jose b3eec34158 Sync cezen-portal/ from live working copy (V1.0 GA polish + search-icon ordering fix)
Brings the ISO-packaged portal copy current with the live cezen-portal/
working directory, which had drifted across the entire V1.0 GA polish
initiative (Phases 1-10) plus this session's topnav icon-order bugfix.

- 52 existing files updated, 2 new pages added (about.html, health.html)
- cgit/cezen-portal/ is now byte-identical to cezen-portal/
- Backend files (rag_ingest.py, cezen_license.py) already in sync;
  main.py intentionally left as-is (cgit is ahead there with
  workstation-category commercial-fit logic not yet backported to the
  live working copy)

Next: rebuild the ISO (bash autoinstall/build-iso.sh) to bake this in.
2026-07-09 11:11:38 +05:30

1222 lines
61 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Workspace — Nexus One AI</title>
<link rel="stylesheet" href="style.css?v=12">
<style>
/* ── Layout ─────────────────────────────────────────────────────────────── */
.mm-layout { display: grid; grid-template-columns: 260px 1fr; gap: 0; height: calc(100vh - 64px); overflow: hidden; }
@media(max-width:768px){ .mm-layout { grid-template-columns: 1fr; } }
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
.mm-sidebar {
border-right: 1px solid var(--bdr);
background: var(--navy2);
display: flex; flex-direction: column;
overflow: hidden;
}
.mm-sidebar-header {
padding: 14px 12px 10px;
border-bottom: 1px solid var(--bdr);
display: flex; flex-direction: column; gap: 8px;
}
.mm-sidebar-top { display: flex; align-items: center; gap: 8px; }
.mm-sidebar-title { font-size: 12px; font-weight: 700; color: var(--lt); text-transform: uppercase; letter-spacing: .5px; flex: 1; }
.mm-new-btn {
display: flex; align-items: center; gap: 6px;
padding: 6px 11px; border-radius: 7px; border: 1.5px solid var(--purple);
background: none; cursor: pointer; font-family: inherit; font-size: 12px;
font-weight: 600; color: var(--purple); transition: .15s; white-space: nowrap;
}
.mm-new-btn:hover { background: var(--purple); color: white; }
/* Search */
.mm-search-wrap { position: relative; }
.mm-search-wrap input {
width: 100%; box-sizing: border-box;
padding: 7px 10px 7px 30px;
border: 1.5px solid var(--bdr); border-radius: 8px;
font-family: inherit; font-size: 12px; color: var(--ink);
background: var(--bg);
}
.mm-search-wrap input:focus { outline: none; border-color: var(--purple); }
.mm-search-wrap .mm-search-icon {
position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
width: 13px; height: 13px; color: var(--lt); pointer-events: none;
}
.mm-search-wrap .mm-search-icon svg { width: 100%; height: 100%; }
.mm-search-clear {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: var(--lt); cursor: pointer; width: 14px; height: 14px;
display: none; padding: 0; line-height: 1;
}
.mm-search-clear svg { width: 100%; height: 100%; }
.mm-search-clear.show { display: block; }
/* Chat list */
.mm-chat-list { flex: 1; overflow-y: auto; padding: 6px; }
.mm-chat-section-label {
font-size: 10px; font-weight: 700; color: var(--lt); text-transform: uppercase;
letter-spacing: .6px; padding: 8px 6px 4px;
}
.mm-chat-item {
padding: 8px 9px; border-radius: 8px; cursor: pointer;
font-size: 13px; color: var(--ink); transition: .1s;
display: flex; align-items: center; gap: 7px;
position: relative; overflow: hidden;
}
.mm-chat-item:hover { background: var(--bg); }
.mm-chat-item.active { background: rgba(124,58,237,.1); color: var(--purple); font-weight: 600; }
.mm-chat-item-icon { flex-shrink: 0; width: 14px; height: 14px; color: var(--text-secondary); }
.mm-chat-item-icon svg { width: 100%; height: 100%; }
.mm-chat-item-body { flex: 1; min-width: 0; }
.mm-chat-item-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.mm-chat-item-meta { font-size: 10px; color: var(--lt); margin-top: 1px; display: flex; gap: 6px; }
.mm-chat-item-actions { display: flex; gap: 2px; opacity: 0; transition: opacity .1s; flex-shrink: 0; }
.mm-chat-item:hover .mm-chat-item-actions { opacity: 1; }
.mm-chat-action-btn {
background: none; border: none; cursor: pointer; padding: 3px 5px;
border-radius: 5px; color: var(--lt); transition: .1s; display:flex; align-items:center; justify-content:center;
}
.mm-chat-action-btn .icon-svg { width: 13px; height: 13px; }
.mm-chat-action-btn:hover { background: var(--bdr); color: var(--ink); }
.mm-pin-icon { color: var(--purple); font-size: 11px; }
/* ── Main area ───────────────────────────────────────────────────────────── */
.mm-main { display: flex; flex-direction: column; background: var(--bg); overflow: hidden; }
/* Chat toolbar */
.mm-toolbar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; border-bottom: 1px solid var(--bdr);
background: var(--navy2); flex-shrink: 0; min-height: 48px;
}
.mm-toolbar-title { font-size: 14px; font-weight: 600; color: var(--ink); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mm-toolbar-title.empty { color: var(--lt); font-weight: 400; }
.mm-model-pill-wrap { display: flex; gap: 4px; align-items: center; position: relative; }
.mm-model-pill {
display: flex; align-items: center; gap: 6px;
padding: 5px 10px; border-radius: 20px;
border: 1.5px solid var(--bdr); background: var(--bg);
font-size: 12px; font-weight: 600; color: var(--med);
cursor: pointer; transition: .15s; white-space: nowrap;
}
.mm-model-pill:hover, .mm-model-pill.open { border-color: var(--purple); color: var(--purple); background: rgba(124,58,237,.06); }
.mm-model-pill .mm-model-pill-caret { font-size: 10px; opacity: .6; }
.mm-model-dropdown {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200;
background: var(--navy2); border: 1px solid var(--bdr); border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.12); min-width: 220px; overflow: hidden;
display: none;
}
.mm-model-dropdown.open { display: block; }
.mm-model-dropdown-head { padding: 10px 12px 6px; font-size: 11px; font-weight: 700; color: var(--lt); text-transform: uppercase; letter-spacing: .5px; }
.mm-model-option {
padding: 9px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 9px;
transition: .1s; color: var(--ink);
}
.mm-model-option:hover { background: var(--bg); }
.mm-model-option.selected { color: var(--purple); font-weight: 600; }
.mm-model-option .mm-model-tag {
font-size: 10px; padding: 2px 6px; border-radius: 10px;
background: rgba(124,58,237,.1); color: var(--purple); font-weight: 700; margin-left: auto;
}
.mm-toolbar-btn {
background: none; border: 1.5px solid var(--bdr); border-radius: 8px;
padding: 5px 10px; font-size: 12px; color: var(--lt); cursor: pointer; transition: .15s;
display: flex; align-items: center; gap: 5px;
}
.mm-toolbar-btn:hover { border-color: var(--purple); color: var(--purple); }
/* ── Messages ────────────────────────────────────────────────────────────── */
.mm-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.mm-empty-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
flex: 1; text-align: center; color: var(--lt); padding: 40px 20px;
}
.mm-empty-icon { width: 52px; height: 52px; margin-bottom: 14px; color: var(--purple); }
.mm-empty-icon svg { width: 100%; height: 100%; }
.mm-empty-title { font-size: 20px; font-weight: 700; color: var(--ink); margin-bottom: 8px; }
.mm-empty-sub { font-size: 14px; max-width: 380px; line-height: 1.6; color: var(--med); }
.mm-tip-grid { display: flex; gap: 8px; margin-top: 20px; flex-wrap: wrap; justify-content: center; max-width: 560px; }
.mm-tip {
background: var(--navy2); border: 1.5px solid var(--bdr); border-radius: 10px;
padding: 9px 13px; font-size: 12px; color: var(--med); cursor: pointer; transition: .15s;
text-align: left;
}
.mm-tip:hover { border-color: var(--purple); color: var(--purple); background: rgba(124,58,237,.04); }
.mm-tip-icon { display:inline-flex; width:14px; height:14px; margin-right: 5px; vertical-align:middle; color:var(--text-secondary); }
.mm-tip-icon svg { width:100%; height:100%; }
/* ── Message bubbles ─────────────────────────────────────────────────────── */
.mm-msg { display: flex; gap: 10px; align-items: flex-start; position: relative; }
.mm-msg.user { flex-direction: row-reverse; }
.mm-msg-avatar {
width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 2px;
}
.mm-msg-avatar svg { width: 14px; height: 14px; }
.mm-msg.user .mm-msg-avatar { background: var(--purple); color: white; }
.mm-msg.assistant .mm-msg-avatar { background: var(--ink); color: white; }
.mm-msg-content { display: flex; flex-direction: column; gap: 4px; max-width: 78%; }
.mm-msg.user .mm-msg-content { align-items: flex-end; }
.mm-bubble {
padding: 11px 15px; border-radius: 14px;
font-size: 14px; line-height: 1.68; color: var(--ink);
}
.mm-msg.user .mm-bubble { background: var(--purple); color: white; border-radius: 14px 4px 14px 14px; }
.mm-msg.assistant .mm-bubble { background: var(--navy2); border: 1px solid var(--bdr); border-radius: 4px 14px 14px 14px; }
.mm-bubble img { max-width: 240px; border-radius: 8px; margin-bottom: 8px; display: block; }
/* Markdown styles inside bubbles */
.mm-bubble p { margin: 0 0 8px; }
.mm-bubble p:last-child { margin-bottom: 0; }
.mm-bubble h1, .mm-bubble h2, .mm-bubble h3 { font-weight: 700; margin: 10px 0 5px; line-height: 1.3; }
.mm-bubble h1 { font-size: 17px; }
.mm-bubble h2 { font-size: 15px; }
.mm-bubble h3 { font-size: 14px; }
.mm-bubble ul, .mm-bubble ol { margin: 6px 0 6px 18px; padding: 0; }
.mm-bubble li { margin-bottom: 3px; }
.mm-bubble code {
background: rgba(0,0,0,.08); padding: 1px 5px; border-radius: 4px;
font-size: 12.5px; font-family: 'Courier New', monospace;
}
.mm-msg.user .mm-bubble code { background: rgba(255,255,255,.25); }
.mm-bubble pre {
background: rgba(0,0,0,.06); border-radius: 8px; padding: 11px 13px;
overflow-x: auto; font-size: 12px; margin: 8px 0; white-space: pre-wrap;
position: relative;
}
.mm-msg.user .mm-bubble pre { background: rgba(255,255,255,.18); }
.mm-bubble pre code { background: none; padding: 0; font-size: 12px; }
.mm-bubble blockquote {
border-left: 3px solid var(--purple); margin: 8px 0; padding: 4px 10px;
color: var(--med); font-style: italic;
}
.mm-msg.user .mm-bubble blockquote { border-color: rgba(255,255,255,.5); color: rgba(255,255,255,.8); }
.mm-bubble strong { font-weight: 700; }
.mm-bubble em { font-style: italic; }
.mm-bubble a { color: var(--purple); text-decoration: underline; }
.mm-msg.user .mm-bubble a { color: rgba(255,255,255,.9); }
.mm-bubble hr { border: none; border-top: 1px solid var(--bdr); margin: 10px 0; }
.mm-bubble table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
.mm-bubble th, .mm-bubble td { border: 1px solid var(--bdr); padding: 6px 10px; text-align: left; }
.mm-bubble th { background: var(--bg); font-weight: 700; }
/* Copy code button */
.mm-code-block { position: relative; }
.mm-copy-code {
position: absolute; top: 6px; right: 7px; background: var(--bg); border: 1px solid var(--bdr);
border-radius: 5px; padding: 2px 7px; font-size: 11px; color: var(--lt); cursor: pointer; transition: .15s;
}
.mm-copy-code:hover { color: var(--purple); border-color: var(--purple); }
/* Message meta row */
.mm-msg-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--lt); }
.mm-msg.user .mm-msg-meta { flex-direction: row-reverse; }
.mm-msg-time { }
.mm-msg-copy {
background: none; border: none; cursor: pointer; padding: 2px 5px; border-radius: 4px;
font-size: 11px; color: var(--lt); transition: .1s; display: flex; align-items: center; gap: 3px;
}
.mm-msg-copy:hover { color: var(--purple); background: rgba(124,58,237,.08); }
/* ── Typing indicator ────────────────────────────────────────────────────── */
.mm-typing { display: none; align-items: center; gap: 10px; }
.mm-typing.show { display: flex; }
.mm-typing-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--ink); color: white; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mm-typing-avatar svg { width: 14px; height: 14px; }
.mm-dots span {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: var(--lt); animation: bounce 1.2s infinite ease-in-out; margin: 0 2px;
}
.mm-dots span:nth-child(2) { animation-delay: .2s; }
.mm-dots span:nth-child(3) { animation-delay: .4s; }
@keyframes bounce { 0%,60%,100%{transform:translateY(0)} 30%{transform:translateY(-6px)} }
.mm-typing-bubble { background: var(--navy2); border: 1px solid var(--bdr); border-radius: 4px 14px 14px 14px; padding: 11px 16px; }
/* ── Input area ──────────────────────────────────────────────────────────── */
.mm-input-area {
border-top: 1px solid var(--bdr);
background: var(--navy2);
padding: 12px 14px 14px;
}
.mm-image-preview {
display: none; margin-bottom: 10px; position: relative; width: fit-content;
}
.mm-image-preview.show { display: block; }
.mm-image-preview img { max-height: 110px; border-radius: 8px; border: 1px solid var(--bdr); }
.mm-image-preview .mm-img-remove {
position: absolute; top: -6px; right: -6px;
background: var(--ink); color: white; border: none; border-radius: 50%;
width: 18px; height: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.mm-image-preview .mm-img-remove svg { width: 10px; height: 10px; }
.mm-input-row { display: flex; gap: 8px; align-items: flex-end; }
.mm-attach-btn {
background: none; border: 1.5px solid var(--bdr); border-radius: 8px;
padding: 8px 11px; cursor: pointer; transition: .15s;
flex-shrink: 0; height: 40px; display: flex; align-items: center;
}
.mm-attach-btn .icon-svg { width: 18px; height: 18px; }
.mm-attach-btn:hover { border-color: var(--purple); }
#mm-file-input { display: none; }
.mm-text-wrap { flex: 1; position: relative; }
#mm-prompt {
width: 100%; box-sizing: border-box;
padding: 9px 13px; border: 1.5px solid var(--bdr); border-radius: 10px;
font-family: inherit; font-size: 14px; color: var(--ink);
resize: none; min-height: 40px; max-height: 160px; overflow-y: auto; line-height: 1.5;
background: var(--bg);
}
#mm-prompt:focus { outline: none; border-color: var(--purple); }
.mm-send-btn {
background: var(--purple); color: white; border: none; border-radius: 10px;
padding: 0 16px; height: 40px; cursor: pointer;
flex-shrink: 0; transition: .15s;
}
.mm-send-btn .icon-svg { width: 16px; height: 16px; }
.mm-send-btn:hover { filter: brightness(1.08); }
.mm-send-btn:disabled { opacity: .45; cursor: not-allowed; }
.mm-hint { font-size: 11px; color: var(--lt); margin-top: 5px; text-align: center; }
/* ── Rename modal ────────────────────────────────────────────────────────── */
.mm-modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.45);
z-index: 300; align-items: center; justify-content: center;
}
.mm-modal-overlay.open { display: flex; }
.mm-modal {
background: var(--navy2); border-radius: 14px; padding: 24px 26px; width: 340px;
box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.mm-modal h3 { margin: 0 0 14px; font-size: 16px; color: var(--ink); }
.mm-modal input {
width: 100%; box-sizing: border-box; padding: 9px 12px;
border: 1.5px solid var(--bdr); border-radius: 8px;
font-family: inherit; font-size: 14px; color: var(--ink); background: var(--bg);
}
.mm-modal input:focus { outline: none; border-color: var(--purple); }
.mm-modal-btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.mm-modal-cancel {
padding: 8px 16px; border-radius: 8px; border: 1.5px solid var(--bdr);
background: none; font-family: inherit; font-size: 13px; color: var(--med); cursor: pointer;
}
.mm-modal-save {
padding: 8px 18px; border-radius: 8px; border: none;
background: var(--purple); color: white; font-family: inherit; font-size: 13px;
font-weight: 600; cursor: pointer;
}
/* ── Toast ───────────────────────────────────────────────────────────────── */
.mm-toast {
position: fixed; bottom: 24px; right: 24px; z-index: 400;
background: var(--ink); color: white; border-radius: 10px;
padding: 10px 16px; font-size: 13px; font-weight: 500;
opacity: 0; transform: translateY(8px); transition: .25s;
pointer-events: none;
}
.mm-toast.show { opacity: 1; transform: translateY(0); }
</style>
</head>
<body style="overflow:hidden">
<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>
<a href="usecases.html">Use Cases</a>
<span class="nav-sep"></span>
<div class="nav-dropdown">
<button class="nav-drop-btn">Help ▾</button>
<div class="nav-drop-menu">
<span class="nav-drop-cat">LEARN /</span>
<a href="quickstart.html">Quick Start</a>
<a href="models.html">Models</a>
<span class="nav-drop-cat">SUPPORT /</span>
<a href="troubleshooting.html">Troubleshoot</a>
<a href="faq.html">FAQ</a>
<span class="nav-drop-cat">MORE /</span>
<a href="glossary.html">Glossary</a>
<a href="whats-new.html">What's New</a>
</div>
</div>
<div class="nav-dropdown">
<button class="nav-drop-btn">Admin ▾</button>
<div class="nav-drop-menu nav-drop-menu-wide">
<span class="nav-drop-cat">DOCS /</span>
<a href="security.html">Security & Privacy</a>
<a href="admin.html">Admin Guide</a>
<span class="nav-drop-cat">MONITOR /</span>
<a href="dashboard.html">Dashboard</a>
<a href="analytics.html">Usage Analytics</a>
<a href="audit.html">Governance & Audit</a>
<a href="health.html">Health Center</a>
<a href="feedback.html">Feedback &amp; Ratings</a>
<span class="nav-drop-cat">MANAGE /</span>
<a href="users.html">Users</a>
<a href="teams.html">Teams</a>
<a href="models-admin.html">Model Catalog</a>
<a href="training.html">Training</a>
<a href="knowledge.html">Knowledge Base</a>
<span class="nav-drop-cat">TOOLS /</span>
<a href="apikeys.html">API Keys</a>
<a href="benchmark.html">Benchmarking</a>
<a href="model-compare.html">Model Compare</a>
<a href="api-playground.html">API Playground</a>
<a href="guardrails.html">Guardrails</a>
<a href="rag-quality.html">RAG Quality</a>
<a href="router.html">Model Router</a>
<a href="connectors.html">MCP & Enterprise Connectors</a>
<span class="nav-drop-cat">SYSTEM /</span>
<a href="console.html">Console</a>
<a href="settings.html">Settings</a>
</div>
</div>
<div class="nav-dropdown">
<button class="nav-drop-btn active">AI Tools ▾</button>
<div class="nav-drop-menu">
<span class="nav-drop-cat">INTELLIGENCE /</span>
<a href="documents.html">Document Intelligence</a>
<a href="chat-multi.html">AI Workspace</a>
<a href="prompt-studio.html">Prompt Studio</a>
<a href="meeting.html">Meeting Assistant</a>
<span class="nav-drop-cat">AUTOMATION /</span>
<a href="agents.html">Agent Studio</a>
<a href="schedules.html">Scheduled Jobs</a>
<a href="workflows.html">Workflow Designer</a>
<span class="nav-drop-cat">QUALITY /</span>
<a href="evals.html">AI Eval Suite</a>
<a href="chatrooms.html">Chat Rooms</a>
</div>
</div>
</nav>
<a href="notifications.html" class="nav-icon-btn" style="position:relative" aria-label="Notifications">
<span class="icon-svg icon-md" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>
</a>
<span class="badge" data-brand="tier">Basic Tier</span>
<div id="nav-org-logo" class="nav-org-logo"></div>
</header>
<div class="mm-layout">
<!-- ── Sidebar ─────────────────────────────────────────────────────────────── -->
<aside class="mm-sidebar">
<div class="mm-sidebar-header">
<div class="mm-sidebar-top">
<span class="mm-sidebar-title">Chats</span>
<button class="mm-new-btn" onclick="newChat()"><span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></span><span>New</span></span></button>
</div>
<div class="mm-search-wrap">
<span class="mm-search-icon icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.35-4.35"/></svg></span>
<input type="text" id="chat-search" placeholder="Search chats…" oninput="onSearch(this.value)">
<button class="mm-search-clear" id="search-clear" onclick="clearSearch()" title="Clear" aria-label="Clear"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
</div>
<div class="mm-chat-list" id="chat-list">
<div style="padding:14px 8px;font-size:12px;color:var(--lt);text-align:center">No chats yet</div>
</div>
</aside>
<!-- ── Main area ───────────────────────────────────────────────────────────── -->
<main class="mm-main">
<!-- Toolbar -->
<div class="mm-toolbar">
<div class="mm-toolbar-title empty" id="toolbar-title">New Chat</div>
<!-- Inline model picker -->
<div class="mm-model-pill-wrap" id="model-pill-wrap">
<button class="mm-model-pill" id="model-pill-btn" onclick="toggleModelDropdown()">
<span id="model-pill-label">llava</span>
<span class="mm-model-pill-caret"></span>
</button>
<div class="mm-model-dropdown" id="model-dropdown">
<div class="mm-model-dropdown-head">Select Model</div>
<div id="model-option-list">
<!-- populated by loadModels() -->
</div>
</div>
</div>
<button class="mm-toolbar-btn" id="pin-btn" onclick="togglePin()" title="Pin this chat" style="display:none">
<span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 17v5"/><path d="m5 3 14 14"/><path d="m15 3 6 6-4 4-6-6"/></svg></span><span>Pin</span></span>
</button>
<button class="mm-toolbar-btn" id="rename-btn" onclick="openRenameModal()" title="Rename" style="display:none">
<span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span><span>Rename</span></span>
</button>
<button class="mm-toolbar-btn" id="clear-btn" onclick="clearCurrentChat()" title="Clear chat" style="display:none">
<span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg></span><span>Clear</span></span>
</button>
</div>
<!-- Messages -->
<div class="mm-messages" id="messages">
<div class="mm-empty-state" id="empty-state">
<div class="mm-empty-icon icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div>
<div class="mm-empty-title">AI Workspace</div>
<div class="mm-empty-sub">Ask questions, analyse images, and get structured answers — all running on-premises.</div>
<div class="mm-tip-grid">
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon icon-svg"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg></span>Analyse this circuit diagram</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon icon-svg"><svg viewBox="0 0 24 24"><path d="M5 7h14"/><path d="M7 3h10l1 4H6z"/><rect x="4" y="7" width="16" height="14" rx="2"/></svg></span>Describe the equipment in this photo</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon icon-svg"><svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg></span>Extract text from this image</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon icon-svg"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.35-4.35"/></svg></span>What does this satellite image show?</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon icon-svg"><svg viewBox="0 0 24 24"><path d="M4 19h16"/><path d="M7 16V8"/><path d="M12 16V5"/><path d="M17 16v-3"/></svg></span>Summarise this chart</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon icon-svg"><svg viewBox="0 0 24 24"><path d="M9.5 2a3.5 3.5 0 0 0-3.4 2.7A3.5 3.5 0 0 0 3 8.2a3.5 3.5 0 0 0 1.2 2.6A3.5 3.5 0 0 0 4 12a3.5 3.5 0 0 0 2.1 3.2A3.5 3.5 0 0 0 9.5 18H10v4"/><path d="M14.5 2a3.5 3.5 0 0 1 3.4 2.7A3.5 3.5 0 0 1 21 8.2a3.5 3.5 0 0 1-1.2 2.6A3.5 3.5 0 0 1 20 12a3.5 3.5 0 0 1-2.1 3.2A3.5 3.5 0 0 1 14.5 18H14v4"/><path d="M10 10h4"/><path d="M9 14h6"/></svg></span>Compare these two diagrams</div>
</div>
</div>
<!-- Typing indicator -->
<div class="mm-typing" id="typing">
<div class="mm-typing-avatar"><svg viewBox="0 0 24 24"><rect x="7" y="8" width="10" height="8" rx="2"/><path d="M12 4v4M9 16v2M15 16v2M6 10H4M20 10h-2"/><circle cx="10" cy="12" r="1"/><circle cx="14" cy="12" r="1"/></svg></div>
<div class="mm-typing-bubble">
<div class="mm-dots"><span></span><span></span><span></span></div>
</div>
</div>
</div>
<!-- Input area -->
<div class="mm-input-area">
<div class="mm-image-preview" id="img-preview">
<img id="img-preview-img" src="" alt="preview">
<button class="mm-img-remove" onclick="clearImage()" title="Remove" aria-label="Remove"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
</div>
<div class="mm-input-row">
<label class="mm-attach-btn" title="Attach image (or paste Ctrl+V)">
<span class="icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg></span>
<input type="file" id="mm-file-input" accept="image/*">
</label>
<div class="mm-text-wrap">
<textarea id="mm-prompt" placeholder="Message the AI…" rows="1"></textarea>
</div>
<button class="mm-send-btn" id="send-btn" onclick="sendMessage()" aria-label="Send"><span class="icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m3 12 18-9-4 18-4-6-10-3z"/><path d="m13 15 8-12"/></svg></span></button>
</div>
<div class="mm-hint">Ctrl+V to paste image · Shift+Enter for new line · Enter to send</div>
</div>
</main>
</div>
<!-- Rename modal -->
<div class="mm-modal-overlay" id="rename-modal" onclick="if(event.target===this)closeRenameModal()">
<div class="mm-modal">
<h3>Rename Chat</h3>
<input type="text" id="rename-input" placeholder="Chat title…" maxlength="80">
<div class="mm-modal-btns">
<button class="mm-modal-cancel" onclick="closeRenameModal()">Cancel</button>
<button class="mm-modal-save" onclick="saveRename()">Save</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="mm-toast" id="mm-toast"></div>
<script>
/* ─────────────────────────────────────────────────────────────────────────────
State
───────────────────────────────────────────────────────────────────────────── */
const _API = '/api';
const LS_KEY = 'cezen_chats';
let chats = []; // [{id, title, model, pinned, messages:[{role,content,imageB64,ts}], created, updated}]
let currentChatId = null;
let pendingImageB64 = null;
let isStreaming = false;
let availableModels = [];
let searchQuery = '';
function mmIconSvg(name, sizeClass = 'icon-sm') {
const icons = {
pin:'<svg viewBox="0 0 24 24"><path d="M12 17v5"/><path d="m5 3 14 14"/><path d="m15 3 6 6-4 4-6-6"/></svg>',
pinFilled:'<svg viewBox="0 0 24 24"><path d="M12 17v5"/><path d="M7 3h10l-2 6 3 3-3 3H9l3-3-2-6z"/></svg>',
chat:'<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
image:'<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>',
trash:'<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>',
edit:'<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
user:'<svg viewBox="0 0 24 24"><path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/></svg>',
robot:'<svg viewBox="0 0 24 24"><rect x="7" y="8" width="10" height="8" rx="2"/><path d="M12 4v4M9 16v2M15 16v2M6 10H4M20 10h-2"/><circle cx="10" cy="12" r="1"/><circle cx="14" cy="12" r="1"/></svg>',
copy:'<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',
check:'<svg viewBox="0 0 24 24"><path d="m20 6-11 11-5-5"/></svg>'
};
return `<span class="icon-svg ${sizeClass}" aria-hidden="true">${icons[name] || icons.chat}</span>`;
}
/* ─────────────────────────────────────────────────────────────────────────────
Persistence helpers
───────────────────────────────────────────────────────────────────────────── */
function saveChats() {
try { localStorage.setItem(LS_KEY, JSON.stringify(chats)); } catch(e) {}
}
function loadChatsLocal() {
try { chats = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); }
catch(e) { chats = []; }
}
function genId() { return Date.now() + Math.random().toString(36).slice(2,6); }
function fmtTime(ts) {
const d = new Date(ts);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
}
const diff = (now - d) / 86400000;
if (diff < 7) return d.toLocaleDateString([], {weekday:'short'});
return d.toLocaleDateString([], {month:'short',day:'numeric'});
}
function fmtMsgTime(ts) {
return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
}
/* ─────────────────────────────────────────────────────────────────────────────
Markdown renderer
───────────────────────────────────────────────────────────────────────────── */
function renderMarkdown(raw) {
// Protect code blocks first
const blocks = [];
let s = (raw || '').replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const idx = blocks.length;
blocks.push({ lang, code: escHtml(code.trim()) });
return `\x00CODE${idx}\x00`;
});
// Inline code
s = s.replace(/`([^`\n]+)`/g, (_, c) => `<code>${escHtml(c)}</code>`);
// Escape remaining HTML
s = s.split('\x00').map((part, i) => {
if (part.startsWith('CODE') && blocks[parseInt(part.slice(4))]) return '\x00' + part + '\x00';
return escHtml(part);
}).join('');
// Restore markers
s = s.replace(/\x00CODE(\d+)\x00/g, (_, idx) => {
const b = blocks[parseInt(idx)];
const langLabel = b.lang ? `<span style="font-size:10px;color:var(--lt);font-family:monospace;display:block;margin-bottom:4px">${escHtml(b.lang)}</span>` : '';
return `<div class="mm-code-block"><pre>${langLabel}<code>${b.code}</code></pre><button class="mm-copy-code" onclick="copyCode(this)">Copy</button></div>`;
});
// Headers
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Blockquotes
s = s.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
// HR
s = s.replace(/^---+$/gm, '<hr>');
// Bold + italic
s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
// Links
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Tables (simple)
s = s.replace(/((?:^[|].+[|]\n?)+)/gm, tableMatch => {
const rows = tableMatch.trim().split('\n').filter(r => r.trim());
if (rows.length < 2) return tableMatch;
let out = '<table>';
rows.forEach((row, ri) => {
const cells = row.split('|').map(c => c.trim()).filter(c => c);
if (ri === 1 && cells.every(c => /^[-:]+$/.test(c))) return; // separator row
const tag = ri === 0 ? 'th' : 'td';
out += '<tr>' + cells.map(c => `<${tag}>${c}</${tag}>`).join('') + '</tr>';
});
return out + '</table>';
});
// Unordered lists
s = s.replace(/((?:^[*\-+] .+\n?)+)/gm, block => {
const items = block.trim().split('\n').map(l => `<li>${l.replace(/^[*\-+] /, '')}</li>`).join('');
return `<ul>${items}</ul>`;
});
// Ordered lists
s = s.replace(/((?:^\d+\. .+\n?)+)/gm, block => {
const items = block.trim().split('\n').map(l => `<li>${l.replace(/^\d+\. /, '')}</li>`).join('');
return `<ol>${items}</ol>`;
});
// Paragraphs — wrap double-newline separated blocks that aren't already HTML
s = s.split(/\n{2,}/).map(para => {
para = para.trim();
if (!para) return '';
if (/^<(h[1-6]|ul|ol|li|blockquote|hr|pre|div|table)/.test(para)) return para;
// single newlines become <br>
return `<p>${para.replace(/\n/g, '<br>')}</p>`;
}).join('\n');
return s;
}
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ─────────────────────────────────────────────────────────────────────────────
Models
───────────────────────────────────────────────────────────────────────────── */
const DEFAULT_MODELS = [
{ name: 'llava', tag: 'Vision' },
{ name: 'llava:13b', tag: 'Vision' },
{ name: 'mistral', tag: 'Chat' },
{ name: 'mixtral', tag: 'Chat' },
{ name: 'codellama', tag: 'Code' },
{ name: 'llama3', tag: 'Chat' },
{ name: 'qwen2-vl', tag: 'Vision' },
];
function currentModel() {
return document.getElementById('model-pill-label').textContent;
}
function setModel(name) {
document.getElementById('model-pill-label').textContent = name;
// Update active state in dropdown
document.querySelectorAll('.mm-model-option').forEach(o => {
o.classList.toggle('selected', o.dataset.name === name);
});
// Persist on current chat
if (currentChatId) {
const chat = chats.find(c => c.id === currentChatId);
if (chat) { chat.model = name; saveChats(); }
}
closeModelDropdown();
}
function toggleModelDropdown() {
const dd = document.getElementById('model-dropdown');
const btn = document.getElementById('model-pill-btn');
const isOpen = dd.classList.contains('open');
dd.classList.toggle('open', !isOpen);
btn.classList.toggle('open', !isOpen);
}
function closeModelDropdown() {
document.getElementById('model-dropdown').classList.remove('open');
document.getElementById('model-pill-btn').classList.remove('open');
}
document.addEventListener('click', e => {
if (!document.getElementById('model-pill-wrap').contains(e.target)) closeModelDropdown();
});
function renderModelDropdown(models) {
const list = document.getElementById('model-option-list');
list.innerHTML = models.map(m => {
const tag = m.tag || ((/llava|vision|vl|bakllava|minicpm/i.test(m.name)) ? 'Vision' : 'Chat');
return `<div class="mm-model-option ${m.name === currentModel() ? 'selected' : ''}"
data-name="${escHtml(m.name)}" onclick="setModel('${escHtml(m.name)}')">
<span>${escHtml(m.name)}</span>
<span class="mm-model-tag">${escHtml(tag)}</span>
</div>`;
}).join('');
}
async function loadModels() {
try {
const res = await fetch(`${_API}/models/list`, { credentials: 'include' });
if (!res.ok) throw new Error();
const data = await res.json();
const models = (data.models || []);
if (models.length) {
availableModels = models.map(m => ({
name: m.name,
tag: /llava|vision|vl|bakllava|minicpm/i.test(m.name) ? 'Vision' : 'Chat'
}));
renderModelDropdown(availableModels);
return;
}
} catch(e) {}
availableModels = DEFAULT_MODELS;
renderModelDropdown(availableModels);
}
/* ─────────────────────────────────────────────────────────────────────────────
Chat list rendering
───────────────────────────────────────────────────────────────────────────── */
function renderChatList() {
const el = document.getElementById('chat-list');
let filtered = chats;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = chats.filter(c =>
c.title.toLowerCase().includes(q) ||
c.messages.some(m => (m.content||'').toLowerCase().includes(q))
);
}
if (!filtered.length) {
el.innerHTML = `<div style="padding:14px 8px;font-size:12px;color:var(--lt);text-align:center">
${searchQuery ? 'No chats match your search' : 'No chats yet — start a new one!'}</div>`;
return;
}
const pinned = filtered.filter(c => c.pinned).sort((a,b) => b.updated - a.updated);
const unpinned = filtered.filter(c => !c.pinned).sort((a,b) => b.updated - a.updated);
let html = '';
if (pinned.length) {
html += `<div class="mm-chat-section-label">Pinned</div>`;
html += pinned.map(c => chatItemHtml(c)).join('');
}
if (unpinned.length) {
if (pinned.length) html += `<div class="mm-chat-section-label">Recent</div>`;
html += unpinned.map(c => chatItemHtml(c)).join('');
}
el.innerHTML = html;
}
function chatItemHtml(c) {
const lastMsg = c.messages[c.messages.length - 1];
const preview = lastMsg ? (lastMsg.content || (lastMsg.imageB64 ? 'Image' : '')).slice(0,40) : 'Empty chat';
const isActive = c.id === currentChatId;
return `<div class="mm-chat-item ${isActive ? 'active' : ''}" data-id="${c.id}" onclick="loadChat('${c.id}')">
<span class="mm-chat-item-icon">${c.pinned ? mmIconSvg('pinFilled') : mmIconSvg('chat')}</span>
<div class="mm-chat-item-body">
<div class="mm-chat-item-title">${escHtml((c.title||'New Chat').slice(0,38))}</div>
<div class="mm-chat-item-meta">
<span>${fmtTime(c.updated)}</span>
<span>${c.messages.length} msg${c.messages.length!==1?'s':''}</span>
${c.model ? `<span>${escHtml(c.model)}</span>` : ''}
</div>
</div>
<div class="mm-chat-item-actions">
<button class="mm-chat-action-btn" title="${c.pinned?'Unpin':'Pin'}" onclick="event.stopPropagation();toggleChatPin('${c.id}')">${c.pinned?mmIconSvg('pinFilled'):mmIconSvg('pin')}</button>
<button class="mm-chat-action-btn" title="Rename" onclick="event.stopPropagation();openRenameModalFor('${c.id}')">${mmIconSvg('edit')}</button>
<button class="mm-chat-action-btn" title="Delete" onclick="event.stopPropagation();deleteChat('${c.id}')">${mmIconSvg('trash')}</button>
</div>
</div>`;
}
/* ─────────────────────────────────────────────────────────────────────────────
Chat CRUD
───────────────────────────────────────────────────────────────────────────── */
function newChat() {
currentChatId = null;
clearImage();
document.getElementById('mm-prompt').value = '';
document.getElementById('mm-prompt').style.height = 'auto';
const msgs = document.getElementById('messages');
[...msgs.querySelectorAll('.mm-msg')].forEach(m => m.remove());
document.getElementById('empty-state').style.display = '';
document.getElementById('typing').classList.remove('show');
document.getElementById('toolbar-title').textContent = 'New Chat';
document.getElementById('toolbar-title').classList.add('empty');
document.getElementById('pin-btn').style.display = 'none';
document.getElementById('rename-btn').style.display = 'none';
document.getElementById('clear-btn').style.display = 'none';
renderChatList();
}
function loadChat(id) {
const chat = chats.find(c => c.id === id);
if (!chat) return;
currentChatId = id;
// Clear messages
const msgs = document.getElementById('messages');
[...msgs.querySelectorAll('.mm-msg')].forEach(m => m.remove());
document.getElementById('empty-state').style.display = 'none';
document.getElementById('typing').classList.remove('show');
// Toolbar
const title = document.getElementById('toolbar-title');
title.textContent = chat.title || 'New Chat';
title.classList.toggle('empty', !chat.title);
document.getElementById('pin-btn').style.display = '';
document.getElementById('rename-btn').style.display = '';
document.getElementById('clear-btn').style.display = '';
document.getElementById('pin-btn').innerHTML = chat.pinned ? `<span class="icon-label">${mmIconSvg('pinFilled')}<span>Pinned</span></span>` : `<span class="icon-label">${mmIconSvg('pin')}<span>Pin</span></span>`;
// Model
if (chat.model) setModel(chat.model);
// Render messages
chat.messages.forEach(m => appendBubbleDom(m.role, m.content, m.imageB64, m.ts, false));
scrollBottom();
renderChatList();
}
function getOrCreateChat() {
if (currentChatId) return chats.find(c => c.id === currentChatId);
const model = currentModel();
const chat = {
id: genId(), title: '', model,
pinned: false, messages: [],
created: Date.now(), updated: Date.now()
};
chats.unshift(chat);
currentChatId = chat.id;
document.getElementById('pin-btn').style.display = '';
document.getElementById('rename-btn').style.display = '';
document.getElementById('clear-btn').style.display = '';
saveChats();
return chat;
}
function autoTitle(content) {
return content.replace(/[^a-zA-Z0-9 ]/g,'').trim().slice(0,45) || 'New Chat';
}
function toggleChatPin(id) {
const chat = chats.find(c => c.id === id);
if (!chat) return;
chat.pinned = !chat.pinned;
saveChats();
if (id === currentChatId) {
document.getElementById('pin-btn').innerHTML = chat.pinned ? `<span class="icon-label">${mmIconSvg('pinFilled')}<span>Pinned</span></span>` : `<span class="icon-label">${mmIconSvg('pin')}<span>Pin</span></span>`;
}
renderChatList();
toast(chat.pinned ? 'Chat pinned' : 'Chat unpinned');
}
function togglePin() {
if (currentChatId) toggleChatPin(currentChatId);
}
function deleteChat(id) {
if (!confirm('Delete this chat?')) return;
chats = chats.filter(c => c.id !== id);
saveChats();
if (currentChatId === id) newChat();
else renderChatList();
}
function clearCurrentChat() {
if (!currentChatId) return;
if (!confirm('Clear all messages in this chat?')) return;
const chat = chats.find(c => c.id === currentChatId);
if (!chat) return;
chat.messages = [];
chat.updated = Date.now();
saveChats();
const msgs = document.getElementById('messages');
[...msgs.querySelectorAll('.mm-msg')].forEach(m => m.remove());
document.getElementById('empty-state').style.display = '';
toast('Chat cleared');
}
/* ─────────────────────────────────────────────────────────────────────────────
Rename modal
───────────────────────────────────────────────────────────────────────────── */
let _renameTargetId = null;
function openRenameModal() { openRenameModalFor(currentChatId); }
function openRenameModalFor(id) {
const chat = chats.find(c => c.id === id);
if (!chat) return;
_renameTargetId = id;
document.getElementById('rename-input').value = chat.title || '';
document.getElementById('rename-modal').classList.add('open');
setTimeout(() => document.getElementById('rename-input').focus(), 50);
}
function closeRenameModal() {
document.getElementById('rename-modal').classList.remove('open');
_renameTargetId = null;
}
function saveRename() {
const val = document.getElementById('rename-input').value.trim();
const chat = chats.find(c => c.id === _renameTargetId);
if (!chat) return closeRenameModal();
chat.title = val || 'New Chat';
chat.updated = Date.now();
saveChats();
if (_renameTargetId === currentChatId) {
document.getElementById('toolbar-title').textContent = chat.title;
document.getElementById('toolbar-title').classList.toggle('empty', !val);
}
renderChatList();
closeRenameModal();
toast('Renamed');
}
document.getElementById('rename-input').addEventListener('keydown', e => {
if (e.key === 'Enter') saveRename();
if (e.key === 'Escape') closeRenameModal();
});
/* ─────────────────────────────────────────────────────────────────────────────
Search
───────────────────────────────────────────────────────────────────────────── */
function onSearch(val) {
searchQuery = val.trim();
document.getElementById('search-clear').classList.toggle('show', !!searchQuery);
renderChatList();
}
function clearSearch() {
document.getElementById('chat-search').value = '';
onSearch('');
}
/* ─────────────────────────────────────────────────────────────────────────────
Message rendering
───────────────────────────────────────────────────────────────────────────── */
function appendBubbleDom(role, text, imageB64, ts, scroll = true) {
const msgs = document.getElementById('messages');
const typing = document.getElementById('typing');
const wrap = document.createElement('div');
wrap.className = `mm-msg ${role}`;
const avatar = role === 'user' ? mmIconSvg('user') : mmIconSvg('robot');
let imgHtml = '';
if (imageB64) {
imgHtml = `<img src="${imageB64}" alt="attached image">`;
}
const rendered = role === 'assistant' ? renderMarkdown(text) : `<p>${escHtml(text||'').replace(/\n/g,'<br>')}</p>`;
const timeStr = fmtMsgTime(ts || Date.now());
const copyBtn = role === 'assistant'
? `<button class="mm-msg-copy" onclick="copyMsg(this)" data-text="${escHtml(text||'')}"><span class="icon-label">${mmIconSvg('copy')}<span>Copy</span></span></button>`
: '';
wrap.innerHTML = `
<div class="mm-msg-avatar">${avatar}</div>
<div class="mm-msg-content">
<div class="mm-bubble">${imgHtml}${rendered}</div>
<div class="mm-msg-meta">
<span class="mm-msg-time">${timeStr}</span>
${copyBtn}
</div>
</div>`;
msgs.insertBefore(wrap, typing);
if (scroll) scrollBottom();
}
function appendBubble(role, text, imageB64) {
const ts = Date.now();
appendBubbleDom(role, text, imageB64, ts, true);
return ts;
}
function scrollBottom() {
const msgs = document.getElementById('messages');
msgs.scrollTop = msgs.scrollHeight;
}
/* ─────────────────────────────────────────────────────────────────────────────
Copy helpers
───────────────────────────────────────────────────────────────────────────── */
function copyMsg(btn) {
navigator.clipboard.writeText(btn.dataset.text || '').then(() => {
btn.innerHTML = `<span class="icon-label">${mmIconSvg('check')}<span>Copied</span></span>`;
setTimeout(() => { btn.innerHTML = `<span class="icon-label">${mmIconSvg('copy')}<span>Copy</span></span>`; }, 1800);
}).catch(() => toast('Copy failed'));
}
function copyCode(btn) {
const code = btn.previousElementSibling?.querySelector('code')?.textContent || '';
navigator.clipboard.writeText(code).then(() => {
btn.textContent = 'Sent';
setTimeout(() => { btn.textContent = 'Copy'; }, 1800);
}).catch(() => toast('Copy failed'));
}
/* ─────────────────────────────────────────────────────────────────────────────
Image attach
───────────────────────────────────────────────────────────────────────────── */
document.getElementById('mm-file-input').addEventListener('change', e => {
if (e.target.files[0]) attachImage(e.target.files[0]);
});
document.addEventListener('paste', e => {
const items = e.clipboardData?.items || [];
for (const item of items) {
if (item.type.startsWith('image/')) { attachImage(item.getAsFile()); break; }
}
});
function attachImage(file) {
const reader = new FileReader();
reader.onload = ev => {
pendingImageB64 = ev.target.result;
document.getElementById('img-preview-img').src = pendingImageB64;
document.getElementById('img-preview').classList.add('show');
};
reader.readAsDataURL(file);
}
function clearImage() {
pendingImageB64 = null;
document.getElementById('mm-file-input').value = '';
document.getElementById('img-preview').classList.remove('show');
document.getElementById('img-preview-img').src = '';
}
/* ─────────────────────────────────────────────────────────────────────────────
Tip shortcuts
───────────────────────────────────────────────────────────────────────────── */
function useTip(el) {
const text = el.textContent.replace(/^[^\w]+/, '').trim();
document.getElementById('mm-prompt').value = text;
document.getElementById('mm-prompt').focus();
}
/* ─────────────────────────────────────────────────────────────────────────────
Send message
───────────────────────────────────────────────────────────────────────────── */
const promptEl = document.getElementById('mm-prompt');
promptEl.addEventListener('input', () => {
promptEl.style.height = 'auto';
promptEl.style.height = Math.min(promptEl.scrollHeight, 160) + 'px';
});
promptEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
async function sendMessage() {
const prompt = promptEl.value.trim();
if (!prompt && !pendingImageB64) return;
if (isStreaming) return;
const model = currentModel();
const imageB64 = pendingImageB64;
const userTs = Date.now();
// Ensure chat exists
const chat = getOrCreateChat();
if (!chat.title && prompt) {
chat.title = autoTitle(prompt);
document.getElementById('toolbar-title').textContent = chat.title;
document.getElementById('toolbar-title').classList.remove('empty');
}
// Add user message to state
chat.messages.push({ role: 'user', content: prompt, imageB64: imageB64 || null, ts: userTs });
chat.updated = userTs;
saveChats();
appendBubble('user', prompt, imageB64);
promptEl.value = '';
promptEl.style.height = 'auto';
clearImage();
document.getElementById('empty-state').style.display = 'none';
// Show typing
const typing = document.getElementById('typing');
typing.classList.add('show');
scrollBottom();
isStreaming = true;
document.getElementById('send-btn').disabled = true;
try {
const res = await fetch(`${_API}/multimodal/chat`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, image_b64: imageB64 || '', chat_id: chat.id })
});
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || 'Request failed'); }
const data = await res.json();
typing.classList.remove('show');
const aiTs = Date.now();
chat.messages.push({ role: 'assistant', content: data.response, imageB64: null, ts: aiTs });
chat.updated = aiTs;
saveChats();
appendBubble('assistant', data.response, null);
} catch(e) {
typing.classList.remove('show');
// Mock response
const mock = mockResponse(prompt, !!imageB64);
const aiTs = Date.now();
chat.messages.push({ role: 'assistant', content: mock, imageB64: null, ts: aiTs });
chat.updated = aiTs;
saveChats();
appendBubble('assistant', mock, null);
} finally {
isStreaming = false;
document.getElementById('send-btn').disabled = false;
renderChatList();
scrollBottom();
}
}
/* ─────────────────────────────────────────────────────────────────────────────
Mock responses (for offline/demo use)
───────────────────────────────────────────────────────────────────────────── */
const MOCK_RESPONSES = [
`## Summary\n\nThis is a **mock response** from the Nexus One AI portal running in offline mode.\n\nThe real model (\`${currentModel || 'llava'}\`) would process your input on-premises and return a detailed answer here.\n\n### What you can expect:\n- Image analysis and description\n- Code generation with syntax highlighting\n- Document understanding and extraction\n- Multi-turn conversation with context\n\n> Connect your Ollama backend at \`/api\` to get real responses.`,
`I can see you've sent a message! In production, this would be processed by the **on-premises LLM** without any data leaving your infrastructure.\n\nHere's a quick example of what structured output looks like:\n\n\`\`\`python\n# Example extraction result\nresult = {\n "entities": ["Nexus One AI", "on-premises"],\n "sentiment": "positive",\n "confidence": 0.94\n}\n\`\`\`\n\nThe model supports **markdown**, tables, code blocks, and more.`,
`Great question! Here's what I found:\n\n| Feature | Status |\n|---------|--------|\n| On-premises deployment | Ready |\n| Image analysis | Ready |\n| Multi-modal support | Ready |\n| Data privacy | Guaranteed |\n\nAll processing happens **locally** — no data is sent to external servers.\n\n*Note: This is a demo response. Connect the backend to enable real AI responses.*`,
`I've analysed your input. Here are my findings:\n\n1. **First observation** — the content appears to be related to enterprise AI workflows\n2. **Second observation** — on-premises models can handle this type of query effectively\n3. **Third observation** — response quality improves with fine-tuned domain models\n\nWould you like me to go deeper on any of these points?`
];
let _mockIdx = 0;
function mockResponse(prompt, hasImage) {
if (hasImage) {
return `## Image Analysis\n\nI can see **an attached image** in your message. In production mode, the vision model (\`llava\` or similar) would analyse it and describe:\n- Objects and elements present\n- Text visible in the image\n- Layout, colours, and composition\n- Any diagrams, charts, or schematics\n\n> *Backend not connected — this is a demo response.*`;
}
const r = MOCK_RESPONSES[_mockIdx % MOCK_RESPONSES.length];
_mockIdx++;
return r;
}
/* ─────────────────────────────────────────────────────────────────────────────
Toast
───────────────────────────────────────────────────────────────────────────── */
let _toastTimer = null;
function toast(msg, duration = 2200) {
const el = document.getElementById('mm-toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), duration);
}
/* ─────────────────────────────────────────────────────────────────────────────
Init
───────────────────────────────────────────────────────────────────────────── */
loadChatsLocal();
renderChatList();
loadModels();
// Try to also sync from API
(async () => {
try {
const res = await fetch(`${_API}/multimodal/chats`, { credentials: 'include' });
if (!res.ok) return;
const apiChats = await res.json();
if (!apiChats.length) return;
// Merge: API chats take precedence for content, preserve local pins/metadata
apiChats.forEach(ac => {
const local = chats.find(c => c.id == ac.id);
if (local) {
local.title = ac.title || local.title;
local.messages = ac.messages || local.messages;
local.updated = ac.updated || local.updated;
} else {
chats.unshift({
id: ac.id, title: ac.title || '', model: ac.model || 'llava',
pinned: false, messages: ac.messages || [],
created: ac.created || Date.now(), updated: ac.updated || Date.now()
});
}
});
saveChats();
renderChatList();
} catch(e) {}
})();
</script>
<script src="auth.js"></script>
<script src="branding.js?v=3"></script>
</body>
</html>