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