Spaces:
Sleeping
Sleeping
| <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; | |
| } | |
| .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> | |
| ; | |
| // ββ 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> |