aipackage/cezen-portal/chat-multi.html
2026-06-30 10:51:41 +05:30

1193 lines
56 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multimodal Chat — Nexus One AI</title>
<link rel="stylesheet" href="style.css?v=4">
<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%);
font-size: 13px; color: var(--lt); pointer-events: none;
}
.mm-search-clear {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: var(--lt); cursor: pointer; font-size: 14px;
display: none; padding: 0; line-height: 1;
}
.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; font-size: 14px; }
.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; font-size: 13px; color: var(--lt); transition: .1s;
}
.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 { font-size: 52px; margin-bottom: 14px; }
.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 { margin-right: 5px; }
/* ── 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;
font-size: 13px; flex-shrink: 0; margin-top: 2px;
}
.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; font-size: 13px; flex-shrink: 0; }
.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; font-size: 10px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.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; font-size: 17px; transition: .15s;
flex-shrink: 0; height: 40px; display: flex; align-items: center;
}
.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; font-size: 17px;
flex-shrink: 0; transition: .15s;
}
.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">Audit Log</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 Manager</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">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">Multimodal Chat</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 Builder</a>
<a href="schedules.html">Scheduled Jobs</a>
<a href="workflows.html">Workflow Automation</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" style="position:relative">🔔</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()"> New</button>
</div>
<div class="mm-search-wrap">
<span class="mm-search-icon">🔍</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"></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">
📌 Pin
</button>
<button class="mm-toolbar-btn" id="rename-btn" onclick="openRenameModal()" title="Rename" style="display:none">
✏️ Rename
</button>
<button class="mm-toolbar-btn" id="clear-btn" onclick="clearCurrentChat()" title="Clear chat" style="display:none">
🗑️ Clear
</button>
</div>
<!-- Messages -->
<div class="mm-messages" id="messages">
<div class="mm-empty-state" id="empty-state">
<div class="mm-empty-icon">💬</div>
<div class="mm-empty-title">Multimodal Chat</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">🖼️</span>Analyse this circuit diagram</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon">📸</span>Describe the equipment in this photo</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon">📄</span>Extract text from this image</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon">🔬</span>What does this satellite image show?</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon">📊</span>Summarise this chart</div>
<div class="mm-tip" onclick="useTip(this)"><span class="mm-tip-icon">🧠</span>Compare these two diagrams</div>
</div>
</div>
<!-- Typing indicator -->
<div class="mm-typing" id="typing">
<div class="mm-typing-avatar">🤖</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"></button>
</div>
<div class="mm-input-row">
<label class="mm-attach-btn" title="Attach image (or paste Ctrl+V)">
🖼️
<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()"></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 = '';
/* ─────────────────────────────────────────────────────────────────────────────
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 → <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 ? '📌' : '💬'}</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?'📍':'📌'}</button>
<button class="mm-chat-action-btn" title="Rename" onclick="event.stopPropagation();openRenameModalFor('${c.id}')">✏️</button>
<button class="mm-chat-action-btn" title="Delete" onclick="event.stopPropagation();deleteChat('${c.id}')">🗑️</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').textContent = chat.pinned ? '📌 Pinned' : '📌 Pin';
// 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').textContent = chat.pinned ? '📌 Pinned' : '📌 Pin';
}
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' ? '👤' : '🤖';
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||'')}">📋 Copy</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.textContent = '✅ Copied';
setTimeout(() => { btn.innerHTML = '📋 Copy'; }, 1800);
}).catch(() => toast('Copy failed'));
}
function copyCode(btn) {
const code = btn.previousElementSibling?.querySelector('code')?.textContent || '';
navigator.clipboard.writeText(code).then(() => {
btn.textContent = '✅';
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"></script>
</body>
</html>