vscode / Shared /FastChatUI.html
justest's picture
Moved files to root
e8ba22d
Raw
History Blame Contribute Delete
43.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portable AI β€” Fast Chat</title>
<meta name="description" content="Portable AI Chat β€” run local LLMs from a USB drive">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Markdown + Syntax Highlighting -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github-dark.min.css">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/highlight.min.js"></script>
<style>
/* ── Reset ─────────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
/* ── Design tokens ─────────────────────────────────────── */
:root{
--bg: #0d1117;
--bg-sidebar:#0d1117;
--bg-panel: #161b22;
--bg-input: #1c2128;
--bg-msg-ai: #161b22;
--bg-hover: #1c2128;
--border: #30363d;
--border-faint:#21262d;
--accent: #6366f1;
--accent-dim:#4f46e5;
--accent-hi: #818cf8;
--green: #3fb950;
--red: #f85149;
--orange: #e3b341;
--text: #e6edf3;
--text-m: #8b949e;
--text-d: #484f58;
--radius: 8px;
--radius-lg: 12px;
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
--trans: 0.15s ease;
}
/* ── Base ──────────────────────────────────────────────── */
html,body{height:100%;overflow:hidden}
body{
font-family:'Inter',-apple-system,sans-serif;
background:var(--bg);
color:var(--text);
display:flex;
flex-direction:column;
font-size:14px;
line-height:1.6;
}
/* ── Top Header Bar ────────────────────────────────────── */
#top-bar{
display:flex;
align-items:center;
gap:12px;
padding:0 16px;
height:44px;
min-height:44px;
background:var(--bg-panel);
border-bottom:1px solid var(--border);
flex-shrink:0;
z-index:10;
}
#top-bar .brand{
font-weight:700;
font-size:14px;
color:var(--text);
display:flex;align-items:center;gap:6px;
white-space:nowrap;
min-width:120px;
}
#top-bar .brand span{color:var(--accent);}
.top-divider{width:1px;height:20px;background:var(--border);flex-shrink:0;}
.top-label{font-size:11px;color:var(--text-d);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;}
#model-select{
background:var(--bg-input);
border:1px solid var(--border);
color:var(--accent-hi);
font-size:12px;
font-weight:600;
padding:3px 8px;
border-radius:6px;
cursor:pointer;
max-width:180px;
}
#model-select:focus{outline:none;border-color:var(--accent);}
#model-locked{display:none;font-size:12px;font-weight:600;color:var(--accent-hi);}
#temp-input{
background:var(--bg-input);
border:1px solid var(--border);
color:var(--text);
font-size:12px;
padding:3px 6px;
border-radius:6px;
width:52px;
text-align:center;
}
#temp-input:focus{outline:none;border-color:var(--accent);}
/* Hardware stats in header */
#hw-bar{
margin-left:auto;
display:flex;
align-items:center;
gap:10px;
flex-shrink:0;
}
.hw-stat{
display:flex;
align-items:center;
gap:6px;
font-size:11px;
color:var(--text-m);
}
.hw-stat-label{color:var(--text-d);font-weight:500;}
.hw-mini-track{
width:44px;height:4px;
background:rgba(255,255,255,0.07);
border-radius:2px;overflow:hidden;
}
.hw-mini-bar{
height:100%;border-radius:2px;
transform:scaleX(0);
transform-origin:left;
transition:transform 0.6s ease;
will-change:transform;
}
.hw-mini-bar.cpu{background:var(--accent);}
.hw-mini-bar.ram{background:#c084fc;}
.hw-pct{font-weight:600;font-size:11px;color:var(--text-m);min-width:30px;}
.hw-pct.warn{color:var(--orange);}
.hw-pct.danger{color:var(--red);}
#hw-offline{font-size:11px;color:var(--text-d);}
#sys-prompt-btn{
background:none;border:1px solid var(--border);
color:var(--text-m);font-size:11px;padding:3px 9px;
border-radius:6px;cursor:pointer;white-space:nowrap;
transition:var(--trans);position:relative;
}
#sys-prompt-btn:hover{border-color:var(--accent);color:var(--accent-hi);}
#sys-prompt-btn.has-global{border-color:var(--accent);color:var(--accent-hi);}
#sys-global-dot{
position:absolute;top:-3px;right:-3px;
width:7px;height:7px;border-radius:50%;
background:var(--accent);display:none;
box-shadow:0 0 4px var(--accent);
}
/* ── App layout ────────────────────────────────────────── */
#app{
display:flex;
flex:1;
overflow:hidden;
}
/* ── Sidebar ───────────────────────────────────────────── */
#sidebar{
width:260px;min-width:260px;
background:var(--bg-sidebar);
border-right:1px solid var(--border);
display:flex;flex-direction:column;
overflow:hidden;
}
.sidebar-head{
padding:12px;
border-bottom:1px solid var(--border-faint);
}
#new-chat-btn{
width:100%;
background:var(--accent);
color:#fff;
border:none;
border-radius:var(--radius);
padding:8px 12px;
font-size:13px;
font-weight:600;
cursor:pointer;
transition:background var(--trans);
}
#new-chat-btn:hover{background:var(--accent-dim);}
#chat-list{
flex:1;overflow-y:auto;padding:6px;
}
#chat-list::-webkit-scrollbar{width:4px;}
#chat-list::-webkit-scrollbar-track{background:transparent;}
#chat-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px;}
.chat-item{
display:flex;align-items:center;gap:8px;
padding:8px 10px;border-radius:var(--radius);
cursor:pointer;transition:background var(--trans);
font-size:13px;color:var(--text-m);
white-space:nowrap;overflow:hidden;
}
.chat-item:hover{background:var(--bg-hover);}
.chat-item.active{background:var(--bg-input);color:var(--text);}
.chat-icon{font-size:12px;flex-shrink:0;}
.chat-title{flex:1;overflow:hidden;text-overflow:ellipsis;}
.chat-del{
background:none;border:none;color:var(--text-d);
font-size:13px;cursor:pointer;padding:0 2px;
opacity:0;transition:opacity var(--trans),color var(--trans);
flex-shrink:0;
}
.chat-item:hover .chat-del{opacity:1;}
.chat-del:hover{color:var(--red);}
.sidebar-footer{
padding:10px 12px;
border-top:1px solid var(--border-faint);
}
.status-row{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-d);}
#status-dot{width:7px;height:7px;border-radius:50%;background:var(--red);flex-shrink:0;}
#status-dot.online{background:var(--green);}
/* ── Main area ─────────────────────────────────────────── */
#main{
flex:1;display:flex;flex-direction:column;overflow:hidden;
position:relative;
}
/* System prompt panel */
#sys-panel{
border-bottom:1px solid var(--border);
background:var(--bg-panel);
padding:10px 16px;
display:none;
flex-direction:column;
gap:7px;
}
#sys-panel.open{display:flex;}
#sys-panel .sys-row{
display:flex;align-items:flex-start;gap:10px;
}
#sys-panel label{font-size:12px;color:var(--text-d);padding-top:6px;white-space:nowrap;}
#sys-ta{
flex:1;background:var(--bg-input);
border:1px solid var(--border);
color:var(--text);font-size:13px;
border-radius:var(--radius);padding:6px 10px;
resize:none;height:54px;font-family:inherit;
line-height:1.5;
}
#sys-ta:focus{outline:none;border-color:var(--accent);}
#sys-actions{
display:flex;align-items:center;gap:8px;
padding-left:72px;
}
#sys-actions button{
font-size:11px;padding:4px 12px;
border-radius:5px;cursor:pointer;
white-space:nowrap;transition:var(--trans);
font-family:inherit;
}
#set-global-btn{
border:1px solid var(--accent);background:none;
color:var(--accent-hi);
}
#set-global-btn:hover{background:rgba(99,102,241,0.12);}
#clear-global-btn{
border:1px solid var(--border);background:none;
color:var(--text-d);
}
#clear-global-btn:hover{border-color:var(--red);color:var(--red);}
#global-status{
font-size:11px;color:var(--text-d);margin-left:4px;
}
/* Loading overlay */
#load-overlay{
display:none;position:absolute;inset:0;
background:var(--bg);
z-index:20;
align-items:center;justify-content:center;
flex-direction:column;gap:12px;
}
#load-overlay.active{display:flex;}
.spinner{
width:32px;height:32px;
border:3px solid var(--border);
border-top-color:var(--accent);
border-radius:50%;
animation:spin .8s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
#load-text{font-size:13px;color:var(--text-m);text-align:center;}
/* Chat container */
#chat-box{
flex:1;overflow-y:auto;
padding:24px 0;
contain:layout style paint;
}
#chat-box::-webkit-scrollbar{width:5px;}
#chat-box::-webkit-scrollbar-track{background:transparent;}
#chat-box::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px;}
.welcome{
display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100%;gap:8px;
color:var(--text-d);text-align:center;
padding:40px;
}
.welcome h2{font-size:22px;font-weight:600;color:var(--text-m);}
.welcome p{font-size:13px;line-height:1.7;}
/* Messages */
.msg-wrap{
display:flex;padding:4px 20px 4px 20px;
max-width:100%;
}
.msg-wrap.user{justify-content:flex-end;}
.msg-wrap.ai{justify-content:flex-start;}
.bubble{
max-width:72%;padding:10px 14px;
border-radius:var(--radius-lg);
font-size:14px;line-height:1.7;
word-break:break-word;
}
.bubble.user{
background:var(--accent);color:#fff;
border-bottom-right-radius:4px;
}
.bubble.ai{
background:var(--bg-msg-ai);
border:1px solid var(--border-faint);
border-bottom-left-radius:4px;
color:var(--text);
}
/* Typing dots */
.typing-dots{display:flex;gap:4px;padding:2px 0;}
.typing-dots span{
width:6px;height:6px;border-radius:50%;
background:var(--text-d);
animation:dot .9s infinite ease-in-out;
}
.typing-dots span:nth-child(2){animation-delay:.15s;}
.typing-dots span:nth-child(3){animation-delay:.3s;}
@keyframes dot{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-5px)}}
/* Image in bubble */
.msg-img{max-width:240px;border-radius:var(--radius);display:block;margin-bottom:6px;border:1px solid var(--border);}
/* ── Markdown styles in AI bubbles ────────────────────── */
.bubble.ai h1,.bubble.ai h2,.bubble.ai h3{
font-weight:700;margin:14px 0 6px;line-height:1.3;
}
.bubble.ai h1{font-size:1.2em;}
.bubble.ai h2{font-size:1.1em;}
.bubble.ai h3{font-size:1em;}
.bubble.ai p{margin:6px 0;}
.bubble.ai ul,.bubble.ai ol{padding-left:20px;margin:6px 0;}
.bubble.ai li{margin:3px 0;}
.bubble.ai blockquote{
border-left:3px solid var(--accent);
padding:4px 12px;margin:8px 0;
color:var(--text-m);background:rgba(99,102,241,0.06);
border-radius:0 4px 4px 0;
}
.bubble.ai code:not(pre code){
background:rgba(255,255,255,0.08);
border:1px solid var(--border);
border-radius:4px;padding:1px 5px;
font-family:var(--font-mono);font-size:.85em;
}
.bubble.ai pre{
background:#0d1117;
border:1px solid var(--border);
border-radius:var(--radius);
overflow:auto;margin:10px 0;
contain:layout style;
}
.code-header{
display:flex;justify-content:space-between;align-items:center;
padding:6px 12px;
background:#161b22;
border-bottom:1px solid var(--border);
border-radius:var(--radius) var(--radius) 0 0;
}
.code-header span{font-size:11px;color:var(--text-d);font-family:var(--font-mono);}
.copy-btn{
background:none;border:1px solid var(--border);
color:var(--text-m);font-size:11px;padding:2px 8px;
border-radius:4px;cursor:pointer;transition:var(--trans);font-family:inherit;
}
.copy-btn:hover{border-color:var(--accent);color:var(--accent-hi);}
.copy-btn.copied{border-color:var(--green);color:var(--green);}
.bubble.ai pre code{
display:block;padding:12px;
font-family:var(--font-mono);font-size:13px;
line-height:1.6;background:none!important;
}
.bubble.ai table{width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;}
.bubble.ai th,.bubble.ai td{padding:6px 12px;border:1px solid var(--border);text-align:left;}
.bubble.ai th{background:var(--bg-input);font-weight:600;}
.bubble.ai a{color:var(--accent-hi);}
.bubble.ai hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
/* ── Input zone ────────────────────────────────────────── */
#input-zone{
border-top:1px solid var(--border);
background:var(--bg-panel);
padding:10px 16px 12px;
flex-shrink:0;
}
/* Attachment previews */
#img-preview{
display:none;align-items:center;gap:10px;
margin-bottom:8px;
padding:8px 10px;
background:var(--bg-input);
border:1px solid var(--border);
border-radius:var(--radius);
}
#img-preview.show{display:flex;}
#img-thumb{width:40px;height:40px;object-fit:cover;border-radius:6px;border:1px solid var(--border);}
#img-name{font-size:12px;color:var(--text-m);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#pdf-preview{
display:none;align-items:center;gap:10px;
margin-bottom:8px;
padding:8px 12px;
background:rgba(99,102,241,0.07);
border:1px solid rgba(99,102,241,0.2);
border-radius:var(--radius);
}
#pdf-preview.show{display:flex;}
#pdf-icon{font-size:20px;}
#pdf-info strong{display:block;font-size:12px;color:var(--text);}
#pdf-pages{font-size:11px;color:var(--text-m);}
.remove-btn{
background:none;border:none;color:var(--text-d);
font-size:16px;cursor:pointer;padding:0 4px;margin-left:auto;
transition:color var(--trans);
}
.remove-btn:hover{color:var(--red);}
/* Vision warning */
#vision-warn{
display:none;
align-items:center;gap:8px;
margin-bottom:8px;
padding:7px 12px;
background:rgba(227,179,65,0.08);
border:1px solid rgba(227,179,65,0.25);
border-radius:var(--radius);
font-size:12px;color:var(--orange);
}
#vision-warn.show{display:flex;}
/* Input row */
.input-row{display:flex;gap:8px;align-items:flex-end;}
#attach-btn{
background:var(--bg-input);
border:1px solid var(--border);
color:var(--text-m);
border-radius:var(--radius);
padding:0 12px;height:42px;
font-size:16px;cursor:pointer;
transition:var(--trans);flex-shrink:0;
}
#attach-btn:hover{border-color:var(--accent);color:var(--accent-hi);}
#attach-file{display:none;}
#msg-input{
flex:1;min-height:42px;max-height:140px;
background:var(--bg-input);
border:1px solid var(--border);
border-radius:var(--radius);
color:var(--text);font-size:14px;
font-family:inherit;line-height:1.5;
padding:10px 14px;resize:none;
transition:border-color var(--trans);
overflow-y:auto;
}
#msg-input:focus{outline:none;border-color:var(--accent);}
#msg-input::placeholder{color:var(--text-d);}
#send-btn,#stop-btn{
height:42px;padding:0 16px;
border:none;border-radius:var(--radius);
font-size:13px;font-weight:600;cursor:pointer;
flex-shrink:0;transition:var(--trans);
}
#send-btn{background:var(--accent);color:#fff;}
#send-btn:hover{background:var(--accent-dim);}
#stop-btn{display:none;background:var(--red);color:#fff;}
#stop-btn:hover{opacity:.85;}
/* Drag-over overlay */
#main.drag-over::after{
content:'Drop image or PDF';
position:absolute;inset:0;
background:rgba(99,102,241,0.12);
border:2px dashed var(--accent);
border-radius:4px;z-index:30;
display:flex;align-items:center;justify-content:center;
font-size:18px;font-weight:600;color:var(--accent-hi);
pointer-events:none;
}
/* ── Scrollbar global ──────────────────────────────────── */
::-webkit-scrollbar{width:5px;height:5px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px;}
</style>
</head>
<body>
<!-- ── Top bar ─────────────────────────────────────────── -->
<div id="top-bar">
<div class="brand">⚑ <span>Portable AI</span></div>
<div class="top-divider"></div>
<span class="top-label">Model</span>
<select id="model-select"><option>Loading…</option></select>
<span id="model-locked"></span>
<div class="top-divider"></div>
<span class="top-label">Temp</span>
<input type="number" id="temp-input" value="0.7" min="0" max="2" step="0.1">
<div class="top-divider"></div>
<button id="sys-prompt-btn" onclick="toggleSysPanel()">βš™ System Prompt<span id="sys-global-dot"></span></button>
<!-- Hardware stats -->
<div id="hw-bar">
<div class="hw-stat">
<span class="hw-stat-label">CPU</span>
<div class="hw-mini-track"><div class="hw-mini-bar cpu" id="cpu-bar"></div></div>
<span class="hw-pct" id="cpu-pct">--%</span>
</div>
<div class="hw-stat">
<span class="hw-stat-label">RAM</span>
<div class="hw-mini-track"><div class="hw-mini-bar ram" id="ram-bar"></div></div>
<span class="hw-pct" id="ram-pct">--%</span>
</div>
<span id="hw-offline" style="display:none"></span>
</div>
</div>
<!-- ── App body ─────────────────────────────────────────── -->
<div id="app">
<!-- Sidebar -->
<aside id="sidebar">
<div class="sidebar-head">
<button id="new-chat-btn" onclick="newChat()">οΌ‹ New Chat</button>
</div>
<div id="chat-list"></div>
<div class="sidebar-footer">
<div class="status-row">
<div id="status-dot"></div>
<span id="status-text">Connecting…</span>
</div>
<div class="status-row" style="margin-top:4px;">
<span style="font-size:11px;color:var(--text-d);">πŸ’Ύ Chats auto-save to USB</span>
</div>
</div>
</aside>
<!-- Main -->
<div id="main">
<!-- System prompt panel -->
<div id="sys-panel">
<div class="sys-row">
<label>System Prompt</label>
<textarea id="sys-ta" placeholder="Type a system prompt… applies to this chat"></textarea>
</div>
<div id="sys-actions">
<button id="set-global-btn" onclick="saveGlobalPrompt()" title="Apply to ALL future new chats">Set Global</button>
<button id="clear-global-btn" onclick="clearGlobalPrompt()" title="Remove global default">Clear Global</button>
<span id="global-status"></span>
</div>
</div>
<!-- Loading overlay -->
<div id="load-overlay">
<div class="spinner"></div>
<div id="load-text">Loading model…</div>
</div>
<!-- Chat messages -->
<div id="chat-box">
<div class="welcome" id="welcome">
<h2>Ready to Chat</h2>
<p>Select a model and start typing.<br>Conversations auto-save to the USB drive.</p>
</div>
</div>
<!-- Input zone -->
<div id="input-zone">
<!-- Image preview -->
<div id="img-preview">
<img id="img-thumb" src="" alt="">
<span id="img-name"></span>
<button class="remove-btn" onclick="clearImage()" title="Remove">βœ•</button>
</div>
<!-- PDF preview -->
<div id="pdf-preview">
<span id="pdf-icon">πŸ“„</span>
<div id="pdf-info">
<strong id="pdf-name">document.pdf</strong>
<span id="pdf-pages">Reading…</span>
</div>
<button class="remove-btn" onclick="clearPdf()" title="Remove">βœ•</button>
</div>
<!-- Vision warning -->
<div id="vision-warn">
<span>⚠</span>
<span><strong id="warn-model"></strong> is a text-only model β€” it cannot see images.
Pull a vision model: <code>ollama pull llava</code></span>
</div>
<div class="input-row">
<button id="attach-btn" title="Attach image or PDF" onclick="document.getElementById('attach-file').click()">πŸ“Ž</button>
<input type="file" id="attach-file" accept="image/*,.pdf,application/pdf" onchange="handleAttach(this.files[0])">
<textarea id="msg-input" rows="1" placeholder="Ask anything… drag & drop an image or PDF"></textarea>
<button id="send-btn" onclick="sendMessage()">Send ➀</button>
<button id="stop-btn" onclick="stopGen()">β–  Stop</button>
</div>
</div>
</div>
</div>
<script>
'use strict';
// ── Config ──────────────────────────────────────────────
const IS_SERVED = location.protocol === 'http:' || location.protocol === 'https:';
const OLLAMA = IS_SERVED ? '/ollama' : 'http://127.0.0.1:11434';
// ── State ────────────────────────────────────────────────
let allChats = [];
let activeChatId = null;
let isGenerating = false;
let abortCtrl = null;
let saveTimer = null;
let resizeRaf = null;
let attachImgB64 = null;
let attachImgMime = null;
let attachPdfText = null;
let attachPdfName = null;
let globalSystemPrompt = '';
// ── Persistence ──────────────────────────────────────────
async function loadChats() {
if (!IS_SERVED) return;
try {
const r = await fetch('/api/chats');
if (r.ok) allChats = await r.json();
} catch {}
}
function saveChats() {
if (!IS_SERVED) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(() =>
fetch('/api/chats', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(allChats) }).catch(() => {})
, 800);
}
// ── Chat list ─────────────────────────────────────────────
function newChat() {
const id = Date.now().toString();
// New chats inherit the global system prompt automatically
allChats.unshift({ id, title:'New Chat', model:'', systemPrompt: globalSystemPrompt, messages:[] });
saveChats();
renderList();
switchChat(id);
}
function getChat(id) { return allChats.find(c => c.id === id); }
function getActive() { return getChat(activeChatId); }
function renderList() {
const el = document.getElementById('chat-list');
if (!allChats.length) { el.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-d);text-align:center;">No chats yet</div>'; return; }
el.innerHTML = allChats.map(c => `
<div class="chat-item ${c.id === activeChatId ? 'active' : ''}" onclick="switchChat('${c.id}')" id="ci-${c.id}">
<span class="chat-icon">πŸ’¬</span>
<span class="chat-title" title="${esc(c.title)}">${esc(c.title)}</span>
<button class="chat-del" onclick="event.stopPropagation();deleteChat('${c.id}')" title="Delete">βœ•</button>
</div>`).join('');
}
function switchChat(id) {
activeChatId = id;
const chat = getChat(id);
if (!chat) return;
document.getElementById('sys-ta').value = chat.systemPrompt || '';
const box = document.getElementById('chat-box');
box.innerHTML = '';
if (!chat.messages.length) {
showWelcome();
} else {
chat.messages.forEach(m => {
appendBubble(m.content, m.role === 'user' ? 'user' : 'ai', m._imgB64, m._imgMime);
});
}
// Lock model display if chat has messages
const ms = document.getElementById('model-select');
const ml = document.getElementById('model-locked');
if (chat.messages.length && chat.model) {
ms.style.display = 'none';
ml.style.display = 'inline';
ml.textContent = chat.model;
} else {
ms.style.display = '';
ml.style.display = 'none';
}
renderList();
}
function deleteChat(id) {
allChats = allChats.filter(c => c.id !== id);
saveChats();
if (activeChatId === id) {
activeChatId = null;
if (allChats.length) switchChat(allChats[0].id);
else { document.getElementById('chat-box').innerHTML = ''; showWelcome(); renderList(); }
} else renderList();
}
function showWelcome() {
document.getElementById('chat-box').innerHTML = `
<div class="welcome">
<h2>Ready to Chat</h2>
<p>Select a model and start typing.<br>Conversations auto-save to the USB drive.</p>
</div>`;
}
// ── Bubble rendering ─────────────────────────────────────
function appendBubble(text, side, imgB64, imgMime) {
const box = document.getElementById('chat-box');
// Remove welcome if present
const w = box.querySelector('.welcome');
if (w) w.remove();
const wrap = document.createElement('div');
wrap.className = `msg-wrap ${side}`;
const bub = document.createElement('div');
bub.className = `bubble ${side}`;
if (side === 'user') {
if (imgB64) {
const img = document.createElement('img');
img.className = 'msg-img';
img.src = `data:${imgMime||'image/png'};base64,${imgB64}`;
bub.appendChild(img);
}
bub.appendChild(document.createTextNode(text));
} else {
bub.innerHTML = renderMd(text);
bub.querySelectorAll('pre code').forEach(addCopy);
}
wrap.appendChild(bub);
box.appendChild(wrap);
box.scrollTop = box.scrollHeight;
return bub;
}
// ── Markdown ─────────────────────────────────────────────
function setupMarked() {
if (typeof marked === 'undefined') return;
const hljsOk = typeof hljs !== 'undefined';
const rdr = new marked.Renderer();
rdr.code = (code, lang) => {
const sl = esc(lang || '');
const dl = sl || 'code';
let hi = esc(code);
if (hljsOk) {
try { hi = (lang && hljs.getLanguage(lang)) ? hljs.highlight(code,{language:lang}).value : hljs.highlightAuto(code).value; } catch{}
}
return `<pre><div class="code-header"><span>${dl}</span><button class="copy-btn" onclick="(function(b){navigator.clipboard.writeText(b.closest('pre').querySelector('code').innerText).then(()=>{b.textContent='Copied!';b.classList.add('copied');setTimeout(()=>{b.textContent='Copy';b.classList.remove('copied')},2000)})})(this)">Copy</button></div><code class="hljs${sl?' language-'+sl:''}">${hi}</code></pre>`;
};
marked.setOptions({ gfm:true, breaks:true, renderer:rdr });
}
function renderMd(text) {
if (typeof marked === 'undefined') return esc(text).replace(/\n/g,'<br>');
return marked.parse(text, { breaks:true });
}
function finalizeMsg(el, text) {
el.innerHTML = renderMd(text);
if (typeof hljs !== 'undefined') el.querySelectorAll('pre code:not(.hljs)').forEach(b => { hljs.highlightElement(b); addCopy(b); });
document.getElementById('chat-box').scrollTop = document.getElementById('chat-box').scrollHeight;
}
function addCopy(codeEl) {
const pre = codeEl.parentElement;
if (pre.querySelector('.code-header')) return;
const lang = (codeEl.className.match(/language-(\S+)/)||[])[1]||'code';
const hdr = document.createElement('div');
hdr.className = 'code-header';
hdr.innerHTML = `<span>${esc(lang)}</span><button class="copy-btn">Copy</button>`;
hdr.querySelector('.copy-btn').addEventListener('click', function(){
navigator.clipboard.writeText(codeEl.innerText).then(()=>{
this.textContent='Copied!';this.classList.add('copied');
setTimeout(()=>{this.textContent='Copy';this.classList.remove('copied')},2000);
});
});
pre.insertBefore(hdr, pre.firstChild);
}
function esc(s) {
const d = document.createElement('div'); d.textContent = s||''; return d.innerHTML;
}
// ── Model fetching ───────────────────────────────────────
async function fetchModels() {
const sel = document.getElementById('model-select');
const dot = document.getElementById('status-dot');
const txt = document.getElementById('status-text');
try {
const r = await fetch(OLLAMA + '/api/tags');
if (!r.ok) throw new Error();
const d = await r.json();
const prev = sel.value;
sel.innerHTML = '';
d.models.forEach(m => {
const o = document.createElement('option');
o.value = m.name; o.textContent = m.name;
if (m.name === prev) o.selected = true;
sel.appendChild(o);
});
dot.className = 'online';
txt.textContent = `Engine Online β€” ${d.models.length} model(s)`;
} catch {
dot.className = '';
txt.textContent = 'Engine Offline';
}
}
// ── Hardware stats ───────────────────────────────────────
async function pollHW() {
if (!IS_SERVED) { document.getElementById('hw-offline').textContent = 'Run via server for stats'; document.getElementById('hw-offline').style.display='inline'; return; }
try {
const r = await fetch('/api/stats');
if (!r.ok) throw new Error();
const d = await r.json();
if (d.ram_percent === -1) { document.getElementById('hw-offline').textContent = 'Stats unavailable'; document.getElementById('hw-offline').style.display='inline'; return; }
document.getElementById('hw-offline').style.display = 'none';
setBar('cpu', d.cpu_percent);
setBar('ram', d.ram_percent);
} catch { document.getElementById('hw-offline').textContent = 'No stats'; document.getElementById('hw-offline').style.display='inline'; }
}
function setBar(type, pct) {
// Use transform:scaleX instead of width β€” GPU only, no layout
const bar = document.getElementById(`${type}-bar`);
const lbl = document.getElementById(`${type}-pct`);
if (!bar) return;
bar.style.transform = `scaleX(${Math.max(0,Math.min(100,pct))/100})`;
lbl.textContent = pct + '%';
lbl.className = 'hw-pct' + (pct>=90?' danger':pct>=70?' warn':'');
}
// ── Global system prompt ─────────────────────────────────
// ── Global system prompt (saved to USB via /api/settings) ───────
async function loadGlobalPrompt() {
if (IS_SERVED) {
try {
const r = await fetch('/api/settings');
if (r.ok) {
const s = await r.json();
globalSystemPrompt = s.globalSystemPrompt || '';
}
} catch {}
} else {
// Fallback: localStorage when opened as a file directly
globalSystemPrompt = localStorage.getItem('globalSystemPrompt') || '';
}
updateGlobalIndicator();
// Also show status if already active
if (globalSystemPrompt) {
const status = document.getElementById('global-status');
if (status) { status.textContent = '(active for all new chats)'; }
}
}
async function saveGlobalPrompt() {
const val = document.getElementById('sys-ta').value.trim();
globalSystemPrompt = val;
updateGlobalIndicator();
const status = document.getElementById('global-status');
try {
if (IS_SERVED) {
// Load existing settings first so we don't overwrite other keys
let existing = {};
try { const r = await fetch('/api/settings'); if (r.ok) existing = await r.json(); } catch {}
existing.globalSystemPrompt = val;
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existing)
});
} else {
localStorage.setItem('globalSystemPrompt', val);
}
status.textContent = val ? 'βœ“ Saved to USB!' : 'βœ“ Cleared';
status.style.color = 'var(--green)';
} catch {
status.textContent = '⚠ Save failed';
status.style.color = 'var(--red)';
}
setTimeout(() => { status.textContent = globalSystemPrompt ? '(active for all new chats)' : ''; status.style.color = ''; }, 2000);
}
async function clearGlobalPrompt() {
globalSystemPrompt = '';
updateGlobalIndicator();
const status = document.getElementById('global-status');
try {
if (IS_SERVED) {
let existing = {};
try { const r = await fetch('/api/settings'); if (r.ok) existing = await r.json(); } catch {}
existing.globalSystemPrompt = '';
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(existing)
});
} else {
localStorage.removeItem('globalSystemPrompt');
}
status.textContent = 'βœ“ Cleared';
status.style.color = 'var(--red)';
} catch {}
setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 2000);
}
function updateGlobalIndicator() {
const btn = document.getElementById('sys-prompt-btn');
const dot = document.getElementById('sys-global-dot');
if (globalSystemPrompt) {
btn.classList.add('has-global');
dot.style.display = 'block';
btn.title = 'Global prompt active: ' + globalSystemPrompt.substring(0, 60) + (globalSystemPrompt.length > 60 ? '…' : '');
} else {
btn.classList.remove('has-global');
dot.style.display = 'none';
btn.title = '';
}
}
// ── System prompt panel ──────────────────────────────────
function toggleSysPanel() {
document.getElementById('sys-panel').classList.toggle('open');
}
// ── Attach files ─────────────────────────────────────────
const VISION_MODELS = ['llava','moondream','bakllava','vision','minicpm-v','cogvlm','qwen-vl','phi-3-vision'];
function isVision(name) { return VISION_MODELS.some(v=>(name||'').toLowerCase().includes(v)); }
function handleAttach(file) {
if (!file) return;
document.getElementById('attach-file').value = '';
if (file.type.startsWith('image/')) handleImg(file);
else if (file.type==='application/pdf'||file.name.toLowerCase().endsWith('.pdf')) handlePdf(file);
}
function handleImg(file) {
clearPdf();
const reader = new FileReader();
reader.onload = e => {
const durl = e.target.result;
attachImgB64 = durl.split(',')[1];
attachImgMime = file.type;
document.getElementById('img-thumb').src = durl;
document.getElementById('img-name').textContent = file.name;
document.getElementById('img-preview').classList.add('show');
checkVisionWarn();
};
reader.readAsDataURL(file);
}
function clearImage() {
attachImgB64 = null; attachImgMime = null;
document.getElementById('img-preview').classList.remove('show');
document.getElementById('img-thumb').src = '';
document.getElementById('vision-warn').classList.remove('show');
}
function checkVisionWarn() {
if (!attachImgB64) { document.getElementById('vision-warn').classList.remove('show'); return; }
const model = document.getElementById('model-select').value || document.getElementById('model-locked').textContent;
if (!isVision(model)) {
document.getElementById('warn-model').textContent = model;
document.getElementById('vision-warn').classList.add('show');
} else document.getElementById('vision-warn').classList.remove('show');
}
document.getElementById('model-select').addEventListener('change', checkVisionWarn);
// PDF β€” lazy-load PDF.js only when needed
let pdfJsLoading = false;
async function ensurePdfJs() {
if (window.pdfjsLib) return true;
if (pdfJsLoading) {
return new Promise(res => { const iv = setInterval(()=>{ if(window.pdfjsLib||window._pdfFailed){clearInterval(iv);res(!!window.pdfjsLib);} },100); });
}
pdfJsLoading = true;
try {
const m = await import('https://cdn.jsdelivr.net/npm/pdfjs-dist@4/build/pdf.min.mjs');
window.pdfjsLib = m;
window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4/build/pdf.worker.min.mjs';
return true;
} catch { window._pdfFailed = true; return false; }
}
async function handlePdf(file) {
clearImage();
attachPdfName = file.name;
const pgEl = document.getElementById('pdf-pages');
document.getElementById('pdf-name').textContent = file.name;
pgEl.textContent = 'Loading PDF.js…';
document.getElementById('pdf-preview').classList.add('show');
const ok = await ensurePdfJs();
if (!ok) { pgEl.textContent = '⚠ PDF.js failed β€” check internet'; return; }
pgEl.textContent = 'Extracting text…';
try {
const buf = await file.arrayBuffer();
const pdf = await window.pdfjsLib.getDocument({ data: buf }).promise;
let text = '';
for (let i = 1; i <= pdf.numPages; i++) {
const pg = await pdf.getPage(i);
const c = await pg.getTextContent();
text += `--- Page ${i} ---\n` + c.items.map(x=>x.str).join(' ') + '\n\n';
}
attachPdfText = text.trim();
const words = text.split(/\s+/).length;
pgEl.textContent = `${pdf.numPages} page${pdf.numPages>1?'s':''} Β· ~${words.toLocaleString()} words`;
} catch(e) { pgEl.textContent = '⚠ ' + e.message; attachPdfText = null; }
}
function clearPdf() {
attachPdfText = null; attachPdfName = null;
document.getElementById('pdf-preview').classList.remove('show');
}
// Drag and drop
const mainEl = document.getElementById('main');
mainEl.addEventListener('dragover', e=>{ e.preventDefault(); mainEl.classList.add('drag-over'); });
mainEl.addEventListener('dragleave', ()=>mainEl.classList.remove('drag-over'));
mainEl.addEventListener('drop', e=>{
e.preventDefault(); mainEl.classList.remove('drag-over');
const f = e.dataTransfer.files[0]; if (!f) return;
if (f.type.startsWith('image/')) handleImg(f);
else if (f.type==='application/pdf'||f.name.toLowerCase().endsWith('.pdf')) handlePdf(f);
});
// ── Textarea auto-resize (rAF-debounced β€” no layout thrash) ─
const msgInput = document.getElementById('msg-input');
msgInput.addEventListener('input', () => {
if (resizeRaf) return;
resizeRaf = requestAnimationFrame(() => {
msgInput.style.height = 'auto';
msgInput.style.height = Math.min(msgInput.scrollHeight, 140) + 'px';
resizeRaf = null;
});
});
msgInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// ── Send ─────────────────────────────────────────────────
async function sendMessage() {
const text = msgInput.value.trim();
if (!text || isGenerating) return;
if (!activeChatId || !getActive()) newChat();
const chat = getActive();
const model = document.getElementById('model-select').value || document.getElementById('model-locked').textContent;
const sys = document.getElementById('sys-ta').value.trim();
const temp = parseFloat(document.getElementById('temp-input').value) || 0.7;
chat.model = model;
chat.systemPrompt = sys;
// Build content β€” inject PDF text as context if attached
let finalText = text;
if (attachPdfText) {
const MAX = 12000;
const snip = attachPdfText.length > MAX ? attachPdfText.slice(0,MAX) + '\n\n[truncated…]' : attachPdfText;
finalText = `PDF attached: "${attachPdfName}"\n\n${snip}\n\n---\nUser: ${text}`;
}
const userMsg = { role:'user', content: finalText };
if (attachImgB64) { userMsg.images=[attachImgB64]; userMsg._imgB64=attachImgB64; userMsg._imgMime=attachImgMime; }
chat.messages.push(userMsg);
// Show bubble with original text (not injected context)
const ub = appendBubble(text, 'user', attachImgB64, attachImgMime);
if (attachPdfText) {
const badge = document.createElement('span');
badge.style.cssText = 'display:block;font-size:11px;opacity:.5;margin-top:4px;';
badge.textContent = 'πŸ“„ ' + attachPdfName;
ub.appendChild(badge);
}
// Snapshot & clear attachments
clearImage(); clearPdf();
msgInput.value = ''; msgInput.style.height = 'auto';
if (chat.title === 'New Chat') {
chat.title = text.substring(0,50) + (text.length>50?'…':'');
renderList();
}
// Lock model
const ms = document.getElementById('model-select');
const ml = document.getElementById('model-locked');
ms.style.display = 'none'; ml.style.display = 'inline'; ml.textContent = model;
// Build api messages
let apiMsgs = [];
if (sys) apiMsgs.push({ role:'system', content:sys });
chat.messages.forEach(m => {
const am = { role:m.role, content:m.content };
if (m.images) am.images = m.images;
apiMsgs.push(am);
});
// Show loading overlay on first message
const overlay = document.getElementById('load-overlay');
if (chat.messages.length === 1) {
overlay.classList.add('active');
document.getElementById('load-text').textContent = `Loading ${model}…`;
}
// AI bubble with typing dots
const box = document.getElementById('chat-box');
const aiWrap = document.createElement('div');
aiWrap.className = 'msg-wrap ai';
const aiBub = document.createElement('div');
aiBub.className = 'bubble ai';
aiBub.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
aiWrap.appendChild(aiBub);
box.appendChild(aiWrap);
box.scrollTop = box.scrollHeight;
isGenerating = true;
document.getElementById('send-btn').style.display = 'none';
document.getElementById('stop-btn').style.display = 'block';
abortCtrl = new AbortController();
let fullResp = '';
try {
const res = await fetch(OLLAMA + '/api/chat', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ model, messages: apiMsgs, stream:true, options:{ temperature:temp } }),
signal: abortCtrl.signal
});
if (!res.ok) throw new Error('Ollama error ' + res.status);
const reader = res.body.getReader();
const dec = new TextDecoder();
aiBub.textContent = '';
overlay.classList.remove('active');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = dec.decode(value, { stream:true });
for (const line of chunk.split('\n')) {
if (!line.trim()) continue;
try {
const p = JSON.parse(line);
if (p.message?.content) {
fullResp += p.message.content;
aiBub.textContent = fullResp; // plain text during stream β€” fast
box.scrollTop = box.scrollHeight;
}
} catch {}
}
}
finalizeMsg(aiBub, fullResp);
chat.messages.push({ role:'assistant', content:fullResp });
saveChats();
} catch(err) {
overlay.classList.remove('active');
if (err.name === 'AbortError') {
if (fullResp) { finalizeMsg(aiBub, fullResp); chat.messages.push({ role:'assistant', content:fullResp }); saveChats(); }
else aiBub.innerHTML = '<span style="color:var(--text-d);font-style:italic;">[Stopped]</span>';
} else {
aiBub.innerHTML = `<span style="color:var(--red);">⚠ ${esc(err.message)}</span>`;
chat.messages.pop();
}
} finally {
isGenerating = false;
document.getElementById('send-btn').style.display = '';
document.getElementById('stop-btn').style.display = 'none';
msgInput.focus();
}
}
function stopGen() { if (abortCtrl) abortCtrl.abort(); }
// ── Init ─────────────────────────────────────────────────
(async () => {
setupMarked();
await loadGlobalPrompt(); // restore global system prompt from USB (settings.json)
await loadChats();
await fetchModels();
renderList();
if (allChats.length) switchChat(allChats[0].id);
else showWelcome();
pollHW();
setInterval(pollHW, 5000); // hardware stats every 5s
setInterval(fetchModels, 15000); // model list refresh
})();
</script>
</body>
</html>