html • Lines: 1785<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Local AI</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0
}
body {
font-family: 'Inter', 'SF Pro', '-apple-system', sans-serif;
background: #0f0f1a;
color: #e0e0e0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden
}
::-webkit-scrollbar {
width: 6px
}
::-webkit-scrollbar-track {
background: transparent
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px
}
::-webkit-scrollbar-thumb:hover {
background: #555
}
#sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 99;
display: none;
backdrop-filter: blur(4px)
}
#sidebar-overlay.open {
display: block
}
#sidebar {
position: fixed;
top: 0;
left: 0;
width: 280px;
height: 100%;
background: rgba(22, 22, 42, 0.95);
backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
z-index: 100;
transform: translateX(-100%);
transition: transform .35s cubic-bezier(.4, 0, .2, 1);
display: flex;
flex-direction: column;
overflow: hidden
}
#sidebar.open {
transform: translateX(0)
}
#sidebar-header {
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06)
}
#new-chat-btn {
width: 100%;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all .2s
}
#new-chat-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2)
}
#session-list {
flex: 1;
overflow-y: auto;
padding: 8px
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 2px;
transition: all .2s
}
.session-item:hover {
background: rgba(255, 255, 255, 0.04)
}
.session-item.active {
background: rgba(74, 108, 247, 0.15);
border-left: 2px solid #4a6cf7
}
.session-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 450
}
.session-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity .2s
}
.session-item:hover .session-actions {
opacity: 1
}
.session-actions button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
font-size: 14px;
border-radius: 6px;
color: #888;
transition: all .2s
}
.session-actions button:hover {
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0
}
#sidebar-toggle {
background: none;
border: none;
color: #888;
font-size: 22px;
cursor: pointer;
padding: 0 6px 0 0;
line-height: 1;
transition: color .2s;
-webkit-app-region: no-drag
}
#sidebar-toggle:hover {
color: #fff
}
#model-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
background: rgba(15, 15, 26, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
-webkit-app-region: drag;
flex-shrink: 0
}
#model-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0
}
#model-dot.chat {
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4)
}
#model-dot.image {
background: #f472b6;
box-shadow: 0 0 8px rgba(244, 114, 182, 0.4)
}
#model-dot.loading {
background: #fbbf24;
animation: pulse .8s ease-in-out infinite
}
#model-dot.unloaded {
background: #6b7280
}
@keyframes pulse {
0%,
100% {
opacity: 1
}
50% {
opacity: 0.3
}
}
#chat {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth
}
.msg {
max-width: 78%;
padding: 14px 18px;
border-radius: 14px;
line-height: 1.6;
position: relative;
animation: fadeIn .25s ease
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.msg:hover .copy-btn {
opacity: 1
}
.user {
background: linear-gradient(135deg, #2d3a5e, #3a4a7a);
align-self: flex-end;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 12px rgba(45, 58, 94, 0.3)
}
.bot {
background: rgba(42, 42, 62, 0.8);
backdrop-filter: blur(8px);
align-self: flex-start;
border-bottom-left-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.04);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2)
}
.bot img {
max-width: 100%;
border-radius: 10px;
margin-top: 10px;
cursor: pointer;
transition: transform .2s
}
.bot img:hover {
transform: scale(1.02)
}
.msg-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap
}
.tool-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
font-weight: 500;
letter-spacing: .3px;
position: relative;
cursor: default
}
.tool-badge.search {
background: rgba(30, 58, 95, 0.6);
color: #60a5fa;
cursor: pointer
}
.tool-badge.image {
background: rgba(59, 31, 59, 0.6);
color: #f472b6
}
.tool-badge.edit {
background: rgba(20, 83, 45, 0.6);
color: #34d399
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status-box {
border: 1px solid #2a2a3e;
border-radius: 8px;
overflow: hidden;
transition: border-color .2s
}
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 13px;
color: #94a3b8;
font-weight: 500
}
.status-header .left-group {
display: flex;
align-items: center;
gap: 8px
}
.status-box .spinner {
width: 12px;
height: 12px;
border: 2px solid #334155;
border-top-color: currentColor;
border-radius: 50%;
animation: spin .8s linear infinite;
flex-shrink: 0
}
.status-box .status-text {
color: #e2e8f0
}
.status-box[data-state="thinking"] { border-color: #334155; }
.status-box[data-state="thinking"] .spinner { color: #60a5fa; }
.status-box[data-state="thinking"] .status-icon { color: #60a5fa; }
.status-box[data-state="search"] { border-color: #1e3a5f; }
.status-box[data-state="search"] .spinner { color: #60a5fa; }
.status-box[data-state="generate-image"] { border-color: #4a1a4a; }
.status-box[data-state="generate-image"] .spinner { color: #c084fc; }
.status-box[data-state="edit-image"] { border-color: #1a4a2a; }
.status-box[data-state="edit-image"] .spinner { color: #34d399; }
.status-box .status-content {
display: none;
padding: 8px 12px;
border-top: 1px solid #1e1e30;
font-size: 12px;
color: #94a3b8;
background: #0a0a18;
font-family: monospace
}
.status-box.open .status-content {
display: block
}
.search-popup {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 50;
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 12px;
min-width: 300px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px)
}
.copy-btn {
opacity: 0;
transition: opacity .2s;
background: none;
border: none;
cursor: pointer;
color: #666;
font-size: 12px;
padding: 2px 8px;
border-radius: 6px;
margin-left: auto;
transition: all .2s
}
.copy-btn:hover {
color: #e0e0e0;
background: rgba(255, 255, 255, 0.06)
}
.msg-content p {
margin: 4px 0
}
.msg-content pre {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid rgba(255, 255, 255, 0.04)
}
.msg-content code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px
}
.msg-content p code {
background: rgba(255, 255, 255, 0.06);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px
}
.msg-content pre code {
background: none;
padding: 0
}
.msg-content ul,
.msg-content ol {
padding-left: 20px;
margin: 6px 0
}
.msg-content blockquote {
border-left: 3px solid #4a6cf7;
padding-left: 12px;
margin: 8px 0;
color: #999
}
.msg-content table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
font-size: 13px
}
.msg-content th,
.msg-content td {
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: left
}
.msg-content th {
background: rgba(255, 255, 255, 0.04);
font-weight: 600
}
.msg-content a {
color: #60a5fa;
text-decoration: none
}
.msg-content a:hover {
text-decoration: underline
}
.msg-content h1,
.msg-content h2,
.msg-content h3,
.msg-content h4 {
margin: 10px 0 4px;
font-weight: 600
}
.msg-content h1 {
font-size: 1.3em
}
.msg-content h2 {
font-size: 1.15em
}
.msg-content h3 {
font-size: 1.05em
}
.msg-content hr {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin: 12px 0
}
.status {
color: #666;
font-style: italic;
padding: 4px 0;
font-size: 13px;
align-self: center;
animation: fadeIn .2s ease
}
#input-bar {
display: flex;
gap: 8px;
padding: 14px 20px 18px;
background: rgba(15, 15, 26, 0.8);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
align-items: flex-end;
flex-shrink: 0
}
#msg-input {
flex: 1;
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
font-size: 14px;
resize: none;
outline: none;
font-family: inherit;
line-height: 1.5;
max-height: 120px;
transition: border-color .2s
}
#msg-input:focus {
border-color: rgba(74, 108, 247, 0.5)
}
#input-bar button {
padding: 10px 18px;
border-radius: 10px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all .2s;
flex-shrink: 0
}
#send-btn {
background: linear-gradient(135deg, #4a6cf7, #6a4cf7);
color: #fff
}
#send-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(74, 108, 247, 0.35)
}
#send-btn:active {
transform: translateY(0)
}
#send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none
}
#attach-btn {
background: rgba(255, 255, 255, 0.04);
color: #888;
font-size: 20px;
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.06)
}
#attach-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0
}
#image-preview {
max-width: 100px;
max-height: 70px;
border-radius: 8px;
display: none;
object-fit: cover
}
input[type=file] {
display: none
}
#file-badge {
display: none;
background: rgba(74, 108, 247, 0.15);
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
align-items: center;
gap: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(74, 108, 247, 0.2)
}
#image-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 200;
display: none;
backdrop-filter: blur(8px);
animation: fadeIn .2s ease;
overflow: hidden;
cursor: grab
}
#image-overlay.open {
display: block
}
#image-overlay .img-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none
}
#image-overlay .img-wrap img {
max-width: 92vw;
max-height: 92vh;
border-radius: 8px;
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
animation: fadeIn .25s ease;
transition: transform .1s ease;
transform-origin: center center;
pointer-events: auto;
user-select: none;
-webkit-user-drag: none
}
#image-overlay .zoom-label {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
background: rgba(0, 0, 0, 0.5);
padding: 4px 12px;
border-radius: 6px;
pointer-events: none
}
#login-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #0f0f1a;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn .3s ease
}
#login-overlay.hidden {
display: none
}
.login-box {
background: rgba(22, 22, 42, 0.95);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 40px;
width: 340px;
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4)
}
.login-box h2 {
margin-bottom: 24px;
text-align: center;
font-weight: 600;
font-size: 22px
}
.login-box input {
width: 100%;
padding: 12px 16px;
margin-bottom: 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box
}
.login-box input:focus {
border-color: rgba(74, 108, 247, 0.5)
}
.login-box button {
width: 100%;
padding: 12px;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, #4a6cf7, #6a4cf7);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
margin-top: 4px
}
.login-box button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(74, 108, 247, 0.35)
}
.login-box .login-error {
color: #f87171;
font-size: 13px;
text-align: center;
margin-top: 10px;
display: none
}
.login-box .login-error.show {
display: block
}
#logout-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.1);
color: #f87171;
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
transition: all .2s;
margin-left: auto
}
#logout-btn:hover {
background: rgba(248, 113, 113, 0.1);
border-color: #f87171
}
#mic-btn {
background: rgba(255, 255, 255, 0.04);
color: #888;
font-size: 18px;
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all .2s
}
#mic-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0
}
#mic-btn.recording {
background: rgba(248, 113, 113, 0.2);
border-color: #f87171;
color: #f87171;
animation: pulseMic 1s ease-in-out infinite
}
@keyframes pulseMic {
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0); }
}
#overload-warn {
animation: pulseWarn 1.5s ease-in-out infinite alternate
}
@keyframes pulseWarn {
from {
opacity: .8
}
to {
opacity: 1
}
}
</style>
</head>
<body>
<div id="sidebar-overlay" onclick="toggleSidebar()"></div>
<div id="sidebar">
<div id="sidebar-header">
<button id="new-chat-btn" onclick="newChat()">+ New Chat</button>
</div>
<div id="session-list"></div>
</div>
<div id="model-bar">
<button id="sidebar-toggle" onclick="toggleSidebar()">☰</button>
<span id="model-dot"></span>
<span id="model-label"></span>
<span id="model-tps" style="margin-left:12px;font-size:12px;color:#888"></span>
<span id="token-indicator" style="margin-left:auto;font-size:12px;color:#666"></span>
<button id="logout-btn" onclick="doLogout()">Logout</button>
</div>
<div id="overload-warn"
style="display:none;background:#991b1b;color:#fff;text-align:center;padding:6px 12px;font-size:13px;font-weight:500">
</div>
<div id="chat"></div>
<div id="input-bar">
<button id="attach-btn" onclick="document.getElementById('file-input').click()">+</button>
<input type="file" id="file-input" onchange="handleFile(event)">
<img id="image-preview">
<span id="file-badge"></span>
<textarea id="msg-input" placeholder="Type a message..." rows="1" autofocus></textarea>
<button id="mic-btn" onclick="toggleMic()">🎤</button>
<button id="send-btn" onclick="send()">Send</button>
</div>
<div id="image-overlay">
<div class="img-wrap"><img id="fullscreen-img"></div>
<div class="zoom-label" id="zoom-label">100%</div>
</div>
<div id="login-overlay">
<div class="login-box">
<h2>Local AI</h2>
<input type="text" id="login-username" placeholder="Username" autocomplete="username">
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password">
<button onclick="doLogin()">Sign In</button>
<div class="login-error" id="login-error">Invalid username or password</div>
</div>
</div>
<script>
marked.setOptions({ breaks: true, gfm: true });
let currentSid = null;
let sessionsList = [];
const chat = document.getElementById('chat');
const input = document.getElementById('msg-input');
let attachedImage = null;
let attachedFileName = null;
let attachedFileText = null;
let loadingSessions = {};
var _pendingMsgs = {};
let authToken = localStorage.getItem('auth_token') || '';
function authFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (authToken) {
options.headers['X-Auth-Token'] = authToken;
}
return fetch(url, options);
}
async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) return;
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await r.json();
if (data.token) {
authToken = data.token;
localStorage.setItem('auth_token', authToken);
document.getElementById('login-overlay').classList.add('hidden');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-error').classList.remove('show');
initChat();
} else {
document.getElementById('login-error').classList.add('show');
}
} catch (e) {
document.getElementById('login-error').classList.add('show');
}
}
function doLogout() {
authFetch('/api/logout', { method: 'POST' }).catch(function(){});
authToken = '';
localStorage.removeItem('auth_token');
document.getElementById('login-overlay').classList.remove('hidden');
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('open');
currentSid = null;
sessionsList = [];
chat.innerHTML = '';
}
document.getElementById('login-password').addEventListener('keydown', function (e) {
if (e.key === 'Enter') doLogin();
});
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
async function toggleMic() {
if (isRecording) {
stopRecording();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
audioChunks = [];
isRecording = true;
document.getElementById('mic-btn').classList.add('recording');
mediaRecorder.ondataavailable = function (e) {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.onstop = function () {
isRecording = false;
document.getElementById('mic-btn').classList.remove('recording');
stream.getTracks().forEach(function (t) { t.stop(); });
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const reader = new FileReader();
reader.onloadend = function () {
const b64 = reader.result.split(',')[1];
sendWithAudio(b64);
};
reader.readAsDataURL(blob);
};
mediaRecorder.start();
} catch (e) {
alert('Microphone access denied: ' + e.message);
}
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
}
async function sendWithAudio(audioB64) {
if (!currentSid) return;
const text = input.value.trim();
input.value = '';
input.style.height = 'auto';
const taskSid = currentSid;
loadingSessions[taskSid] = (loadingSessions[taskSid] || 0) + 1;
updateLoadingUI();
addMsg('user', text || '🎤 Audio message', null);
let taskId = null;
try {
const r = await authFetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: currentSid, message: text, audio: audioB64 })
});
if (r.status === 503) {
const errData = await r.json();
addMsg('bot', errData.error || 'Server is busy.');
loadingSessions[taskSid]--;
if (!loadingSessions[taskSid]) delete loadingSessions[taskSid];
updateLoadingUI();
return;
}
const data = await r.json();
taskId = data.task_id;
var placeholderEl = addMsg('bot', statusBoxHTML('thinking', 'Thinking...'), null);
_pendingMsgs[taskId] = { el: placeholderEl, sid: taskSid };
try { sessionStorage.setItem('opencode_pending', JSON.stringify(Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}))); } catch(e) {}
let done = false;
while (!done) {
const s = await fetch('/api/status/' + taskId);
const st = await s.json();
if (taskSid === currentSid) {
var sc = placeholderEl.querySelector('.msg-content');
if (sc) sc.innerHTML = statusBoxHTML(statusState(st.message), st.message || 'Working...');
}
if (st.status === 'done') {
if (taskSid === currentSid) {
var el = _pendingMsgs[taskId];
if (el) {
addMsg('bot', st.response, null, st.image, st.gen_prompt, st.tools_used, st._image_model, st._search_details, el.el);
delete _pendingMsgs[taskId];
} else {
addMsg('bot', st.response, null, st.image, st.gen_prompt, st.tools_used, st._image_model, st._search_details);
}
try { var remaining = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(remaining)); } catch(e) {}
updateTokenIndicator(st.token_estimate || 0);
if (st.predicted_per_second != null) {
var tpsEl = document.getElementById('model-tps');
if (st.predicted_per_second < 5) {
tpsEl.innerHTML = '<span style="color:#f87171">⚠ ' + st.predicted_per_second.toFixed(1) + ' t/s</span>';
} else {
tpsEl.textContent = st.predicted_per_second.toFixed(1) + ' t/s';
}
}
}
done = true;
} else if (st.status === 'error') {
if (taskSid === currentSid) {
var el = _pendingMsgs[taskId];
if (el) {
addMsg('bot', 'Error: ' + st.error, null, null, null, null, null, null, el.el);
delete _pendingMsgs[taskId];
} else {
addMsg('bot', 'Error: ' + st.error);
}
try { var remaining = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(remaining)); } catch(e) {}
}
done = true;
} else {
await new Promise(r => setTimeout(r, 1000));
}
}
} catch (e) {
if (taskSid === currentSid) {
var el = _pendingMsgs[taskId];
if (el) {
addMsg('bot', 'Error: ' + e.message, null, null, null, null, null, null, el.el);
delete _pendingMsgs[taskId];
} else {
addMsg('bot', 'Error: ' + e.message);
}
}
try { var remaining = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(remaining)); } catch(e2) {}
}
loadingSessions[taskSid]--;
if (!loadingSessions[taskSid]) delete loadingSessions[taskSid];
updateLoadingUI();
sessionsList = await fetchSessions();
renderSessions();
}
const CODE_EXTS = new Set([".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h", ".hpp", ".cs", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".dart", ".sh", ".bash", ".pl", ".pm", ".lua", ".r", ".sql", ".html", ".css", ".scss", ".sass", ".less", ".vue", ".svelte", ".yaml", ".yml", ".json", ".xml", ".toml", ".ini", ".cfg", ".md", ".tex", ".dockerfile", ".tf", ".zig", ".nim", ".hs", ".ml", ".fs", ".erl", ".elm", ".purs", ".nix", ".ps1", ".bat", ".cmake", ".proto", ".gradle", ".tex", ".cls", ".sty", ".tex", ".bib"]);
const MAX_FILE_SIZE = 10 * 1024 * 1024;
function clearAttachments() {
attachedImage = null;
attachedFileName = null;
attachedFileText = null;
document.getElementById('image-preview').style.display = 'none';
document.getElementById('file-badge').style.display = 'none';
document.getElementById('file-input').value = '';
}
function showFileBadge(name) {
const badge = document.getElementById('file-badge');
badge.style.display = 'inline-flex';
badge.textContent = '';
const span = document.createElement('span');
span.textContent = name.length > 25 ? name.slice(0, 22) + '...' : name;
span.style.overflow = 'hidden';
span.style.textOverflow = 'ellipsis';
const close = document.createElement('span');
close.textContent = '\u00d7';
close.style.cursor = 'pointer';
close.style.color = '#f87171';
close.style.fontWeight = 'bold';
close.style.marginLeft = '4px';
close.onclick = clearAttachments;
badge.appendChild(span);
badge.appendChild(close);
}
async function handleFile(e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > MAX_FILE_SIZE) {
alert('File too large (max 10MB): ' + file.name);
e.target.value = '';
return;
}
clearAttachments();
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)) {
const reader = new FileReader();
reader.onload = function (ev) {
attachedImage = ev.target.result.split(',')[1];
document.getElementById('image-preview').src = ev.target.result;
document.getElementById('image-preview').style.display = 'block';
};
reader.readAsDataURL(file);
return;
}
if (CODE_EXTS.has(ext)) {
const text = await file.text();
if (text.length > 100000) { alert('File too large (text exceeds 100KB): ' + file.name); return; }
attachedFileName = file.name;
attachedFileText = text;
showFileBadge(file.name);
return;
}
if (['.pdf', '.xls', '.xlsx', '.doc', '.docx'].includes(ext)) {
showFileBadge(file.name + ' (extracting...)');
const reader = new FileReader();
reader.onload = async function (ev) {
const b64 = ev.target.result.split(',')[1];
try {
const r = await authFetch('/api/extract-file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name, data: b64 }) });
const data = await r.json();
if (data.text) {
attachedFileName = file.name;
attachedFileText = data.text;
showFileBadge(file.name);
} else {
clearAttachments();
alert('Could not extract text from ' + file.name + (data.error ? ': ' + data.error : ''));
}
} catch (e) {
clearAttachments();
alert('Error processing file: ' + e.message);
}
};
reader.readAsDataURL(file);
return;
}
const isCode = confirm('Unknown file type: ' + file.name + '. Is this a code/text file?');
if (isCode) {
const text = await file.text();
attachedFileName = file.name;
attachedFileText = text;
showFileBadge(file.name);
} else {
clearAttachments();
}
}
let _fsZoom = 1, _fsPanX = 0, _fsPanY = 0, _fsDragging = false, _fsStartX, _fsStartY;
function openFullscreen(src) {
_fsZoom = 1; _fsPanX = 0; _fsPanY = 0;
const img = document.getElementById('fullscreen-img');
img.src = src;
img.style.transform = '';
document.getElementById('zoom-label').textContent = '100%';
document.getElementById('image-overlay').classList.add('open');
}
function closeFullscreen() {
document.getElementById('image-overlay').classList.remove('open');
document.getElementById('fullscreen-img').src = '';
}
function updateZoom() {
const img = document.getElementById('fullscreen-img');
img.style.transform = 'scale(' + _fsZoom + ') translate(' + _fsPanX + 'px, ' + _fsPanY + 'px)';
document.getElementById('zoom-label').textContent = Math.round(_fsZoom * 100) + '%';
}
(function () {
var overlay = document.getElementById('image-overlay');
var img = document.getElementById('fullscreen-img');
if (!overlay || !img) return;
var _fsPinchDist = 0;
// --- Desktop mouse wheel zoom ---
overlay.addEventListener('wheel', function (e) {
if (!overlay.classList.contains('open')) return;
e.preventDefault();
var delta = e.deltaY > 0 ? -0.1 : 0.1;
_fsZoom = Math.max(0.25, Math.min(10, _fsZoom + delta));
updateZoom();
}, { passive: false });
// --- Desktop mouse drag ---
overlay.addEventListener('mousedown', function (e) {
if (!overlay.classList.contains('open')) return;
if (e.target === img) {
_fsDragging = true;
_fsStartX = e.clientX - _fsPanX;
_fsStartY = e.clientY - _fsPanY;
overlay.style.cursor = 'grabbing';
e.preventDefault();
} else {
closeFullscreen();
}
});
document.addEventListener('mousemove', function (e) {
if (!_fsDragging) return;
_fsPanX = e.clientX - _fsStartX;
_fsPanY = e.clientY - _fsStartY;
updateZoom();
});
document.addEventListener('mouseup', function () {
if (_fsDragging) {
_fsDragging = false;
overlay.style.cursor = 'grab';
}
});
// --- Mobile touch support ---
overlay.addEventListener('touchstart', function (e) {
if (!overlay.classList.contains('open')) return;
if (e.touches.length === 2) {
// Pinch start
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
_fsPinchDist = Math.sqrt(dx * dx + dy * dy);
_fsDragging = false;
e.preventDefault();
} else if (e.touches.length === 1 && e.target === img) {
// Single finger drag start on image
_fsDragging = true;
_fsStartX = e.touches[0].clientX - _fsPanX;
_fsStartY = e.touches[0].clientY - _fsPanY;
e.preventDefault();
}
}, { passive: false });
overlay.addEventListener('touchmove', function (e) {
if (!overlay.classList.contains('open')) return;
if (e.touches.length === 2) {
// Pinch zoom
var dx = e.touches[0].clientX - e.touches[1].clientX;
var dy = e.touches[0].clientY - e.touches[1].clientY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (_fsPinchDist > 0) {
_fsZoom = Math.max(0.25, Math.min(10, _fsZoom * (dist / _fsPinchDist)));
_fsPinchDist = dist;
updateZoom();
}
e.preventDefault();
} else if (e.touches.length === 1 && _fsDragging) {
// Single finger pan
_fsPanX = e.touches[0].clientX - _fsStartX;
_fsPanY = e.touches[0].clientY - _fsStartY;
updateZoom();
e.preventDefault();
}
}, { passive: false });
overlay.addEventListener('touchend', function (e) {
if (!overlay.classList.contains('open')) return;
if (_fsDragging && e.touches.length === 0) {
_fsDragging = false;
}
// Close on single tap on background (not on image)
if (e.changedTouches.length === 1 && e.target !== img) {
closeFullscreen();
}
});
})();
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeFullscreen();
});
function statusState(msg) {
if (!msg) return 'thinking';
if (/^Searching/.test(msg)) return 'search';
if (/^Generating image/.test(msg)) return 'generate-image';
if (/^Editing image/.test(msg)) return 'edit-image';
return 'thinking';
}
function statusIcon(state) {
return state === 'search' ? '🔍' : state === 'generate-image' ? '🎨' : state === 'edit-image' ? '✏️' : '💭';
}
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function statusBoxHTML(state, text) {
var icon = statusIcon(state);
var label = escHtml(text || 'Thinking...');
return '<div class="status-box open" data-state="' + state + '">'
+ '<div class="status-header"><div class="left-group">'
+ '<span class="status-icon">' + icon + '</span>'
+ '<span class="status-text">' + label + '</span>'
+ '<div class="spinner"></div>'
+ '</div></div></div>';
}
function addMsg(role, text, img, imgUrl, genPrompt, toolsUsed, imageModel, searchDetails, existingEl) {
var div = existingEl || document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = '';
var header = document.createElement('div');
header.className = 'msg-header';
if (role === 'bot' && toolsUsed && toolsUsed.length) {
toolsUsed.forEach(function (t) {
var badge = document.createElement('span');
badge.className = 'tool-badge ' + (t === 'web_search' ? 'search' : t === 'generate_image' ? 'image' : t === 'edit_image' ? 'edit' : '');
var label = t === 'web_search' ? 'Web Search' : t === 'generate_image' ? 'Image Gen' : t === 'edit_image' ? 'Edit Image' : t;
if (t === 'generate_image' && imageModel) label += ' (' + imageModel + ')';
badge.textContent = label;
if (t === 'web_search' && searchDetails && searchDetails.length) {
var popup = document.createElement('div');
popup.className = 'search-popup';
var details = searchDetails;
var queryDiv = document.createElement('div');
queryDiv.style.marginBottom = '6px';
queryDiv.innerHTML = '<span style="color:#888;font-size:11px">Query:</span> <span style="color:#e0e0e0">' + details.map(function (sd) { return sd.query }).join('; ') + '</span>';
popup.appendChild(queryDiv);
details.forEach(function (sd) {
if (sd.search_url) {
var urlDiv = document.createElement('div');
urlDiv.style.marginBottom = '6px';
urlDiv.style.fontSize = '11px';
urlDiv.style.wordBreak = 'break-all';
urlDiv.innerHTML = '<span style="color:#888">SearXNG:</span> <a href="' + sd.search_url + '" target="_blank" style="color:#60a5fa">' + sd.search_url + '</a>';
popup.appendChild(urlDiv);
}
if (sd.results && sd.results.length) {
sd.results.forEach(function (r) {
var link = document.createElement('a');
link.href = r.url;
link.target = '_blank';
link.textContent = r.title || r.url;
link.style.display = 'block';
link.style.color = '#60a5fa';
link.style.fontSize = '12px';
link.style.padding = '2px 0';
link.style.textDecoration = 'none';
link.title = r.url;
popup.appendChild(link);
});
}
});
badge.appendChild(popup);
var hideTimer = null;
function showPopup() { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } popup.style.display = 'block'; }
function hidePopup() { hideTimer = setTimeout(function () { popup.style.display = 'none'; }, 300); }
badge.addEventListener('mouseenter', showPopup);
badge.addEventListener('mouseleave', hidePopup);
popup.addEventListener('mouseenter', showPopup);
popup.addEventListener('mouseleave', hidePopup);
}
header.appendChild(badge);
});
}
var copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy';
copyBtn.onclick = function () {
var copyText = text || '';
if (genPrompt) copyText = 'Prompt: ' + genPrompt + '\n\n' + copyText;
navigator.clipboard.writeText(copyText).then(function () {
copyBtn.textContent = 'Copied!';
setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
});
};
header.appendChild(copyBtn);
div.appendChild(header);
if (imgUrl) {
var el = document.createElement('img');
el.src = imgUrl;
el.style.maxWidth = '100%';
el.style.borderRadius = '10px';
el.style.cursor = 'pointer';
el.onclick = function () { openFullscreen(imgUrl); };
div.appendChild(el);
}
if (img) {
const el = document.createElement('img');
el.src = 'data:image/jpeg;base64,' + img;
el.style.maxWidth = '100%';
el.style.borderRadius = '10px';
el.style.cursor = 'pointer';
el.onclick = function () { openFullscreen('data:image/jpeg;base64,' + img); };
div.appendChild(el);
}
if (genPrompt) {
var p = document.createElement('div');
p.style.whiteSpace = 'pre-wrap';
p.style.fontSize = '12px';
p.style.color = '#888';
p.style.marginBottom = '6px';
p.style.fontStyle = 'italic';
p.textContent = 'Prompt: ' + genPrompt;
div.appendChild(p);
}
if (text) {
var content = document.createElement('div');
content.className = 'msg-content';
content.innerHTML = text.indexOf('class="status-box"') !== -1 ? text : marked.parse(text);
div.appendChild(content);
}
if (!existingEl) chat.appendChild(div);
if (chat.scrollTop + chat.clientHeight >= chat.scrollHeight - 60) chat.scrollTop = chat.scrollHeight;
return div;
}
function updateLoadingUI() {
const hasPending = !!loadingSessions[currentSid];
document.getElementById('send-btn').textContent = hasPending ? 'Queue' : 'Send';
}
function updateTokenIndicator(count) {
const el = document.getElementById('token-indicator');
if (!el) return;
if (count > 1000) {
el.textContent = '~' + (count / 1000).toFixed(1) + 'k tokens';
} else {
el.textContent = '~' + count + ' tokens';
}
}
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) { e.preventDefault(); send(); }
});
input.addEventListener('input', function () {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
});
async function pollModelStatus() {
try {
const r = await fetch('/api/model-status');
const data = await r.json();
const dot = document.getElementById('model-dot');
const label = document.getElementById('model-label');
dot.className = data.model;
const labels = {
chat_loaded: 'Chat model ready',
image_active: 'Image generation active',
loading: 'Loading model...',
unloading: 'Unloading model...',
unloaded: 'No model loaded'
};
label.textContent = labels[data.model] || data.model;
const warn = document.getElementById('overload-warn');
if (data.overheated) {
var tempStr = data.gpu_temp != null ? data.gpu_temp + '°C' : '';
warn.textContent = 'Server overloaded. Your queued messages will be processed once the GPU cools down.' + (tempStr ? ' (GPU: ' + tempStr + ')' : '');
warn.style.display = 'block';
} else {
warn.style.display = 'none';
}
const tpsEl = document.getElementById('model-tps');
if (data.predicted_per_second != null) {
if (data.predicted_per_second < 5) {
tpsEl.innerHTML = '<span style="color:#f87171">⚠ ' + data.predicted_per_second.toFixed(1) + ' t/s</span>';
} else {
tpsEl.textContent = data.predicted_per_second.toFixed(1) + ' t/s';
}
} else {
tpsEl.textContent = '';
}
} catch (e) { }
}
setInterval(pollModelStatus, 2000);
pollModelStatus();
function renderMessage(msg) {
if (msg.role === 'system' || msg.role === 'tool') return;
if (msg.role === 'user') {
if (typeof msg.content === 'string') {
addMsg('user', msg.content);
} else if (Array.isArray(msg.content)) {
let text = '', img = null;
msg.content.forEach(function (part) {
if (part.type === 'text') text += part.text;
else if (part.type === 'image_url') {
const url = part.image_url.url;
if (url.startsWith('data:')) img = url.split(',')[1];
}
});
addMsg('user', text, img);
}
} else if (msg.role === 'assistant') {
if (typeof msg.content === 'string' && msg.content) {
addMsg('bot', msg.content, null, msg._image_url, msg._gen_prompt, msg._tools_used, msg._image_model, msg._search_details);
}
}
}
function renderSessions() {
const list = document.getElementById('session-list');
list.innerHTML = '';
sessionsList.forEach(function (s) {
const item = document.createElement('div');
item.className = 'session-item' + (s.session_id === currentSid ? ' active' : '');
const nameSpan = document.createElement('span');
nameSpan.className = 'session-name';
nameSpan.textContent = s.name.length > 30 ? s.name.slice(0, 30) + '...' : s.name;
const actions = document.createElement('span');
actions.className = 'session-actions';
const renameBtn = document.createElement('button');
renameBtn.innerHTML = '✎';
renameBtn.title = 'Rename';
renameBtn.onclick = function (e) { e.stopPropagation(); renameSession(s.session_id); };
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '🗑';
deleteBtn.title = 'Delete';
deleteBtn.onclick = function (e) { e.stopPropagation(); deleteSession(s.session_id); };
actions.appendChild(renameBtn);
actions.appendChild(deleteBtn);
item.appendChild(nameSpan);
item.appendChild(actions);
item.onclick = function () { switchSession(s.session_id); closeSidebar(); };
list.appendChild(item);
});
}
async function fetchSessions() {
const r = await authFetch('/api/sessions');
return await r.json();
}
async function switchSession(sid) {
currentSid = sid;
localStorage.setItem('last_sid', sid);
updateLoadingUI();
try {
const r = await authFetch('/api/sessions/' + sid + '/messages');
const data = await r.json();
chat.innerHTML = '';
data.messages.forEach(renderMessage);
updateTokenIndicator(data.token_estimate || 0);
} catch (e) {
chat.innerHTML = '';
updateTokenIndicator(0);
}
renderSessions();
}
async function newChat() {
const r = await authFetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'New Chat' }) });
const data = await r.json();
currentSid = data.session_id;
localStorage.setItem('last_sid', currentSid);
chat.innerHTML = '';
updateTokenIndicator(0);
updateLoadingUI();
sessionsList = await fetchSessions();
renderSessions();
closeSidebar();
}
async function deleteSession(sid) {
if (!confirm('Delete this session?')) return;
await authFetch('/api/sessions/' + sid, { method: 'DELETE' });
sessionsList = await fetchSessions();
if (sid === currentSid) {
if (sessionsList.length > 0) {
await switchSession(sessionsList[0].session_id);
} else {
await newChat();
}
} else {
renderSessions();
}
}
async function renameSession(sid) {
const s = sessionsList.find(function (x) { return x.session_id === sid; });
const newName = prompt('Session name:', s ? s.name : '');
if (!newName || newName.trim() === '') return;
const prevSid = currentSid;
await authFetch('/api/sessions/' + sid, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName.trim() }) });
sessionsList = await fetchSessions();
currentSid = prevSid;
renderSessions();
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebar-overlay').classList.toggle('open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('open');
}
async function resumePendingTasks() {
var stored = [];
try { stored = JSON.parse(sessionStorage.getItem('opencode_pending')) || []; } catch(e) {}
stored.forEach(function(item) {
var taskId = item.task_id;
var sid = item.sid;
if (_pendingMsgs[taskId]) return;
var placeholderEl = addMsg('bot', statusBoxHTML('thinking', 'Thinking...'), null);
_pendingMsgs[taskId] = { el: placeholderEl, sid: sid };
(function(id, s) {
var poll = async function() {
while (true) {
try {
var r = await fetch('/api/status/' + id);
var st = await r.json();
if (s === currentSid && _pendingMsgs[id]) {
var sc = _pendingMsgs[id].el.querySelector('.msg-content');
if (sc) sc.innerHTML = statusBoxHTML(statusState(st.message), st.message || 'Working...');
}
if (st.status === 'done' || st.status === 'error') {
if (s === currentSid && _pendingMsgs[id]) {
addMsg('bot', st.status === 'done' ? st.response : 'Error: ' + (st.error || ''), null, st.image, st.gen_prompt, st.tools_used, st._image_model, st._search_details, _pendingMsgs[id].el);
}
delete _pendingMsgs[id];
try { var rem = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(rem)); } catch(e) {}
return;
}
} catch(e) { return; }
await new Promise(r => setTimeout(r, 1000));
}
};
poll();
})(taskId, sid);
});
}
async function initChat() {
sessionsList = await fetchSessions();
const lastSid = localStorage.getItem('last_sid');
if (lastSid && sessionsList.some(function (s) { return s.session_id === lastSid; })) {
await switchSession(lastSid);
} else if (sessionsList.length > 0) {
await switchSession(sessionsList[0].session_id);
} else {
await newChat();
}
resumePendingTasks();
}
async function init() {
if (authToken) {
try {
const r = await authFetch('/api/check-auth');
const data = await r.json();
if (data.authenticated) {
document.getElementById('login-overlay').classList.add('hidden');
initChat();
return;
}
} catch (e) {}
}
localStorage.removeItem('auth_token');
authToken = '';
}
async function send() {
const text = input.value.trim();
if (!text && !attachedImage && !attachedFileText) return;
let finalText = text;
if (attachedFileText) finalText = '[FILE: ' + attachedFileName + ']\n```\n' + attachedFileText + '\n```\n[END FILE]\n\n' + (text || 'See attached file above.');
const img = attachedImage;
addMsg('user', text || ('\ud83d\udcc4 ' + (attachedFileName || 'file')), img);
input.value = '';
input.style.height = 'auto';
clearAttachments();
const taskSid = currentSid;
loadingSessions[taskSid] = (loadingSessions[taskSid] || 0) + 1;
updateLoadingUI();
let taskId = null;
try {
const r = await authFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: currentSid, message: finalText, image: img }) });
if (r.status === 503) {
const errData = await r.json();
addMsg('bot', errData.error || 'Server is busy. Please try again.');
loadingSessions[taskSid]--;
if (!loadingSessions[taskSid]) delete loadingSessions[taskSid];
updateLoadingUI();
return;
}
const data = await r.json();
taskId = data.task_id;
var placeholderEl = addMsg('bot', statusBoxHTML('thinking', 'Thinking...'), null);
_pendingMsgs[taskId] = { el: placeholderEl, sid: taskSid };
// Persist across refresh
try { sessionStorage.setItem('opencode_pending', JSON.stringify(Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}))); } catch(e) {}
let done = false;
while (!done) {
const s = await fetch('/api/status/' + taskId);
const st = await s.json();
if (taskSid === currentSid) {
var sc = placeholderEl.querySelector('.msg-content');
if (sc) sc.innerHTML = statusBoxHTML(statusState(st.message), st.message || 'Working...');
}
if (st.status === 'done') {
if (taskSid === currentSid) {
var el = _pendingMsgs[taskId];
if (el) {
addMsg('bot', st.response, null, st.image, st.gen_prompt, st.tools_used, st._image_model, st._search_details, el.el);
delete _pendingMsgs[taskId];
} else {
addMsg('bot', st.response, null, st.image, st.gen_prompt, st.tools_used, st._image_model, st._search_details);
}
try { var remaining = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(remaining)); } catch(e) {}
updateTokenIndicator(st.token_estimate || 0);
const tpsEl = document.getElementById('model-tps');
if (st.predicted_per_second != null) {
if (st.predicted_per_second < 5) {
tpsEl.innerHTML = '<span style="color:#f87171">⚠ ' + st.predicted_per_second.toFixed(1) + ' t/s</span>';
} else {
tpsEl.textContent = st.predicted_per_second.toFixed(1) + ' t/s';
}
}
}
done = true;
} else if (st.status === 'error') {
if (taskSid === currentSid) {
var el = _pendingMsgs[taskId];
if (el) {
addMsg('bot', 'Error: ' + st.error, null, null, null, null, null, null, el.el);
delete _pendingMsgs[taskId];
} else {
addMsg('bot', 'Error: ' + st.error);
}
try { var remaining = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(remaining)); } catch(e) {}
}
done = true;
} else {
await new Promise(r => setTimeout(r, 1000));
}
}
} catch (e) {
if (taskSid === currentSid) {
var el = _pendingMsgs[taskId];
if (el) {
addMsg('bot', 'Error: ' + e.message, null, null, null, null, null, null, el.el);
delete _pendingMsgs[taskId];
} else {
addMsg('bot', 'Error: ' + e.message);
}
}
try { var remaining = Object.keys(_pendingMsgs).map(function(k){return {task_id:k, sid:_pendingMsgs[k].sid}}); sessionStorage.setItem('opencode_pending', JSON.stringify(remaining)); } catch(e2) {}
}
loadingSessions[taskSid]--;
if (!loadingSessions[taskSid]) delete loadingSessions[taskSid];
updateLoadingUI();
sessionsList = await fetchSessions();
renderSessions();
}
init();
</script>
</body>
</html>