1193 lines
56 KiB
HTML
1193 lines
56 KiB
HTML
<!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 & 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(/^> (.+)$/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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────────────────────
|
||
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>
|