|
@@ -29,12 +29,15 @@
|
|
|
background:var(--bg); color:var(--text); overflow:hidden;
|
|
background:var(--bg); color:var(--text); overflow:hidden;
|
|
|
-webkit-font-smoothing:antialiased;
|
|
-webkit-font-smoothing:antialiased;
|
|
|
}
|
|
}
|
|
|
- .app{display:grid; grid-template-columns:268px 1fr; height:100vh; height:100dvh;}
|
|
|
|
|
|
|
+ /* grid-template-rows: clamp the single row to the viewport (minmax(0,1fr))
|
|
|
|
|
+ so the columns' inner scroll areas shrink instead of the whole app
|
|
|
|
|
+ growing past 100dvh and showing a page scrollbar. */
|
|
|
|
|
+ .app{display:grid; grid-template-columns:268px 1fr; grid-template-rows:minmax(0,1fr); height:100vh; height:100dvh;}
|
|
|
|
|
|
|
|
/* ── Sidebar ───────────────────────────── */
|
|
/* ── Sidebar ───────────────────────────── */
|
|
|
.sidebar{
|
|
.sidebar{
|
|
|
background:var(--panel); border-right:1px solid var(--border);
|
|
background:var(--panel); border-right:1px solid var(--border);
|
|
|
- display:flex; flex-direction:column; min-width:0;
|
|
|
|
|
|
|
+ display:flex; flex-direction:column; min-width:0; min-height:0;
|
|
|
}
|
|
}
|
|
|
.sb-head{display:flex; align-items:center; gap:10px; padding:16px 16px 12px;}
|
|
.sb-head{display:flex; align-items:center; gap:10px; padding:16px 16px 12px;}
|
|
|
.sb-head img{width:30px;height:30px;border-radius:8px;}
|
|
.sb-head img{width:30px;height:30px;border-radius:8px;}
|
|
@@ -48,7 +51,7 @@
|
|
|
}
|
|
}
|
|
|
.new-chat:hover{background:var(--panel-3); border-color:var(--accent);}
|
|
.new-chat:hover{background:var(--panel-3); border-color:var(--accent);}
|
|
|
.new-chat svg{width:16px;height:16px;}
|
|
.new-chat svg{width:16px;height:16px;}
|
|
|
- .chat-list{flex:1; overflow-y:auto; padding:4px 8px 8px;}
|
|
|
|
|
|
|
+ .chat-list{flex:1; min-height:0; overflow-y:auto; padding:4px 8px 8px;}
|
|
|
.chat-item{
|
|
.chat-item{
|
|
|
position:relative; padding:9px 30px 9px 11px; border-radius:9px; cursor:pointer;
|
|
position:relative; padding:9px 30px 9px 11px; border-radius:9px; cursor:pointer;
|
|
|
color:var(--text-dim); font-size:13px; margin-bottom:2px; white-space:nowrap;
|
|
color:var(--text-dim); font-size:13px; margin-bottom:2px; white-space:nowrap;
|
|
@@ -186,6 +189,55 @@
|
|
|
border-radius:8px;padding:4px 9px;font-size:11.5px;color:var(--text-dim);}
|
|
border-radius:8px;padding:4px 9px;font-size:11.5px;color:var(--text-dim);}
|
|
|
.msg-files .mf svg{width:13px;height:13px;color:var(--accent);}
|
|
.msg-files .mf svg{width:13px;height:13px;color:var(--accent);}
|
|
|
|
|
|
|
|
|
|
+ /* Thinking / reasoning disclosure (collapsed by default) */
|
|
|
|
|
+ .thinking{margin:0 0 10px;border:1px solid var(--border);border-radius:10px;background:var(--panel-2);overflow:hidden;}
|
|
|
|
|
+ .thinking-toggle{display:flex;align-items:center;gap:8px;width:100%;background:transparent;border:none;cursor:pointer;
|
|
|
|
|
+ color:var(--text-dim);font-size:12.5px;font-weight:600;font-family:inherit;padding:9px 12px;text-align:left;transition:.12s;}
|
|
|
|
|
+ .thinking-toggle:hover{color:var(--text);background:var(--panel-3);}
|
|
|
|
|
+ .thinking-toggle .tk-spark{width:15px;height:15px;flex:none;color:var(--accent);}
|
|
|
|
|
+ .thinking-toggle .chev{width:14px;height:14px;flex:none;margin-left:auto;transition:transform .18s;}
|
|
|
|
|
+ .thinking.open .thinking-toggle .chev{transform:rotate(90deg);}
|
|
|
|
|
+ .thinking-body{display:none;padding:4px 13px 12px;font-size:13px;line-height:1.62;color:var(--text-dim);
|
|
|
|
|
+ border-top:1px solid var(--border);}
|
|
|
|
|
+ .thinking.open .thinking-body{display:block;}
|
|
|
|
|
+ .thinking-body p{margin:8px 0;} .thinking-body code.inline{background:var(--panel-3);}
|
|
|
|
|
+ .thinking-body pre{white-space:pre-wrap;}
|
|
|
|
|
+
|
|
|
|
|
+ /* Attach menu — pick from ArozOS files or upload from this computer */
|
|
|
|
|
+ .attach-wrap{position:relative;flex:none;display:flex;}
|
|
|
|
|
+ .attach-menu{position:absolute;bottom:46px;left:0;z-index:15;min-width:224px;background:var(--panel);
|
|
|
|
|
+ border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:6px;display:none;}
|
|
|
|
|
+ .attach-menu.open{display:block;}
|
|
|
|
|
+ .attach-menu button{display:flex;align-items:center;gap:11px;width:100%;background:transparent;border:none;cursor:pointer;
|
|
|
|
|
+ color:var(--text);font-size:13px;font-weight:600;font-family:inherit;padding:9px 10px;border-radius:8px;text-align:left;transition:.12s;}
|
|
|
|
|
+ .attach-menu button:hover{background:var(--panel-2);}
|
|
|
|
|
+ .attach-menu button svg{width:18px;height:18px;flex:none;color:var(--accent);}
|
|
|
|
|
+ .attach-menu button small{display:block;color:var(--text-faint);font-size:11px;font-weight:400;margin-top:1px;}
|
|
|
|
|
+
|
|
|
|
|
+ /* Live streaming affordances */
|
|
|
|
|
+ .thinking.live .thinking-toggle .tk-label{color:var(--accent);}
|
|
|
|
|
+ .thinking.live .tk-spark{animation:tk-pulse 1.4s ease-in-out infinite;}
|
|
|
|
|
+ @keyframes tk-pulse{0%,100%{opacity:1;}50%{opacity:.35;}}
|
|
|
|
|
+ .bubble .caret{display:inline-block;width:7px;height:14px;margin-left:2px;background:var(--accent);
|
|
|
|
|
+ vertical-align:-2px;animation:caret-blink .9s step-end infinite;}
|
|
|
|
|
+ @keyframes caret-blink{0%,100%{opacity:1;}50%{opacity:0;}}
|
|
|
|
|
+ .thinking-body.streaming{white-space:pre-wrap;}
|
|
|
|
|
+
|
|
|
|
|
+ /* Attachment chip upload state */
|
|
|
|
|
+ .attach-chip.uploading{opacity:.85;}
|
|
|
|
|
+ .attach-chip.failed{border-color:var(--danger);color:var(--danger);}
|
|
|
|
|
+ .attach-chip .spin{width:13px;height:13px;flex:none;border:2px solid var(--border);border-top-color:var(--accent);
|
|
|
|
|
+ border-radius:50%;animation:aichat-spin .7s linear infinite;}
|
|
|
|
|
+ @keyframes aichat-spin{to{transform:rotate(360deg);}}
|
|
|
|
|
+
|
|
|
|
|
+ /* Self-contained toast (does not depend on the desktop shell) */
|
|
|
|
|
+ .toast-wrap{position:absolute;left:50%;bottom:96px;transform:translateX(-50%);z-index:60;display:flex;flex-direction:column;
|
|
|
|
|
+ gap:8px;align-items:center;pointer-events:none;}
|
|
|
|
|
+ .toast{background:var(--panel-3);color:var(--text);border:1px solid var(--border);border-radius:10px;padding:10px 15px;
|
|
|
|
|
+ font-size:13px;box-shadow:var(--shadow);max-width:80vw;opacity:0;transform:translateY(8px);transition:.2s;}
|
|
|
|
|
+ .toast.show{opacity:1;transform:translateY(0);}
|
|
|
|
|
+ .toast.err{border-color:var(--danger);color:var(--danger);}
|
|
|
|
|
+
|
|
|
/* Settings drawer */
|
|
/* Settings drawer */
|
|
|
.scrim{position:absolute;inset:0;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:.2s;z-index:20;}
|
|
.scrim{position:absolute;inset:0;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:.2s;z-index:20;}
|
|
|
.scrim.open{opacity:1;pointer-events:auto;}
|
|
.scrim.open{opacity:1;pointer-events:auto;}
|
|
@@ -303,9 +355,22 @@
|
|
|
<div class="attachments" id="attachments" style="display:none;"></div>
|
|
<div class="attachments" id="attachments" style="display:none;"></div>
|
|
|
<div class="composer">
|
|
<div class="composer">
|
|
|
<div class="composer-box">
|
|
<div class="composer-box">
|
|
|
- <button class="attach-btn" id="attachBtn" onclick="pickFiles()" title="Attach file (image or text document)">
|
|
|
|
|
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <div class="attach-wrap">
|
|
|
|
|
+ <button class="attach-btn" id="attachBtn" onclick="toggleAttachMenu(event)" title="Attach an image or text document">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div class="attach-menu" id="attachMenu">
|
|
|
|
|
+ <button type="button" onclick="pickFiles()">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h5l2 3h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/></svg>
|
|
|
|
|
+ <span>ArozOS files<small>Pick from your cloud drive</small></span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button type="button" onclick="pickLocalFiles()">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M7 9l5-5 5 5M4 20h16"/></svg>
|
|
|
|
|
+ <span>Upload from computer<small>Send a local image or document</small></span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <input type="file" id="localFileInput" multiple style="display:none" onchange="onLocalFilesChosen(this)">
|
|
|
|
|
+ </div>
|
|
|
<textarea id="composer" rows="1" placeholder="Send a message…"></textarea>
|
|
<textarea id="composer" rows="1" placeholder="Send a message…"></textarea>
|
|
|
<button class="stop-btn" id="stopBtn" onclick="stopGenerating()">
|
|
<button class="stop-btn" id="stopBtn" onclick="stopGenerating()">
|
|
|
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Stop
|
|
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Stop
|
|
@@ -315,7 +380,7 @@
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="hint">Enter to send · Shift+Enter for a new line · Attach images for vision models or text documents · Configure the endpoint in System Settings → AI Integration → AI Model</div>
|
|
|
|
|
|
|
+ <div class="hint">Enter to send · Shift+Enter for a new line · Attach images, PDFs or text documents from your ArozOS drive or your computer · Configure the endpoint in System Settings → AI Integration → AI Model</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Settings drawer -->
|
|
<!-- Settings drawer -->
|
|
@@ -354,6 +419,9 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Transient notifications (self-contained, no desktop dependency) -->
|
|
|
|
|
+ <div class="toast-wrap" id="toastWrap"></div>
|
|
|
</section>
|
|
</section>
|
|
|
|
|
|
|
|
<!-- Backdrop for the mobile off-canvas sidebar -->
|
|
<!-- Backdrop for the mobile off-canvas sidebar -->
|
|
@@ -370,7 +438,18 @@ var state = { conversations: [], currentId: null,
|
|
|
|
|
|
|
|
var generating = false, revealTimer = null, cancelReveal = false;
|
|
var generating = false, revealTimer = null, cancelReveal = false;
|
|
|
var availableModels = []; // models known from the backend
|
|
var availableModels = []; // models known from the backend
|
|
|
-var pendingFiles = []; // [{filepath, filename}] attached to the next message
|
|
|
|
|
|
|
+// Files staged for the next message. Each entry is
|
|
|
|
|
+// {filename, filepath, paths[], uploading?, failed?, note?} — `paths` holds the
|
|
|
|
|
+// virtual path(s) actually sent to the model (a PDF expands to one path per
|
|
|
|
|
+// rendered page); uploading/failed flag transient conversion/upload state.
|
|
|
|
|
+var pendingFiles = [];
|
|
|
|
|
+var LOCAL_UPLOAD_DIR = "tmp:/AIChat/uploads"; // scratch dir for uploads + rendered PDF pages (tmp:/ is auto-cleared)
|
|
|
|
|
+var PDF_MAX_PAGES = 10; // cap pages per PDF — vision tokens add up fast
|
|
|
|
|
+var PDF_RENDER_SCALE = 2.0; // render scale; 2x keeps small print legible
|
|
|
|
|
+var PDF_JPEG_QUALITY = 0.85;
|
|
|
|
|
+
|
|
|
|
|
+var activeSocket = null; // live streaming WebSocket, if any
|
|
|
|
|
+var streamState = null; // handles for the in-flight streaming reply
|
|
|
|
|
|
|
|
function uid(){ return Date.now().toString(36) + Math.random().toString(36).slice(2,7); }
|
|
function uid(){ return Date.now().toString(36) + Math.random().toString(36).slice(2,7); }
|
|
|
function persist(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(state)); }catch(e){} }
|
|
function persist(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(state)); }catch(e){} }
|
|
@@ -438,7 +517,7 @@ function renderModelOptions(){
|
|
|
if(list.length === 0){
|
|
if(list.length === 0){
|
|
|
html = '<option value="" disabled'+(cur?'':' selected')+'>No models — pick Custom…</option>';
|
|
html = '<option value="" disabled'+(cur?'':' selected')+'>No models — pick Custom…</option>';
|
|
|
}
|
|
}
|
|
|
- html += '<option value="__custom__">✏️ Custom model…</option>';
|
|
|
|
|
|
|
+ html += '<option value="__custom__">+ Custom model…</option>';
|
|
|
sel.innerHTML = html;
|
|
sel.innerHTML = html;
|
|
|
sel.value = cur || (list[0] || "__custom__");
|
|
sel.value = cur || (list[0] || "__custom__");
|
|
|
}
|
|
}
|
|
@@ -523,13 +602,25 @@ function buildMessage(m, index){
|
|
|
if(isUser && m.files && m.files.length){
|
|
if(isUser && m.files && m.files.length){
|
|
|
var fb = $('<div class="msg-files"></div>').appendTo(main);
|
|
var fb = $('<div class="msg-files"></div>').appendTo(main);
|
|
|
m.files.forEach(function(f){
|
|
m.files.forEach(function(f){
|
|
|
- $('<span class="mf"></span>').html(fileIconSvg()).append($('<span></span>').text(f.filename)).attr("title", f.filepath).appendTo(fb);
|
|
|
|
|
|
|
+ // A PDF was sent as several rendered page images — say so.
|
|
|
|
|
+ var n = (f.paths && f.paths.length) ? f.paths.length : 1;
|
|
|
|
|
+ var label = f.filename + (n > 1 ? (" · " + n + " pages") : "");
|
|
|
|
|
+ $('<span class="mf"></span>').html(fileIconSvg()).append($('<span></span>').text(label)).attr("title", f.filepath).appendTo(fb);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
+ // For assistant messages, separate the model's "thinking" from the answer.
|
|
|
|
|
+ // The thinking goes in a collapsed disclosure above the reply.
|
|
|
|
|
+ var answer = isUser ? m.content : "";
|
|
|
|
|
+ if(!isUser && !m.error){
|
|
|
|
|
+ var parts = computeThinking(m);
|
|
|
|
|
+ answer = parts.answer;
|
|
|
|
|
+ if(parts.thinkingText.trim() !== ""){ main.append(buildThinking(parts.thinkingText)); }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var bubble = $('<div class="bubble"></div>').appendTo(main);
|
|
var bubble = $('<div class="bubble"></div>').appendTo(main);
|
|
|
if(m.error){ bubble.html('<div class="err">'+esc(m.content)+'</div>'); }
|
|
if(m.error){ bubble.html('<div class="err">'+esc(m.content)+'</div>'); }
|
|
|
else if(isUser){ bubble.text(m.content); }
|
|
else if(isUser){ bubble.text(m.content); }
|
|
|
- else { bubble.html(renderMarkdown(m.content)); }
|
|
|
|
|
|
|
+ else { bubble.html(renderMarkdown(answer)); }
|
|
|
|
|
|
|
|
if(!isUser && !m.error){
|
|
if(!isUser && !m.error){
|
|
|
var foot = $('<div class="msg-footer" style="display:flex;"></div>').appendTo(main);
|
|
var foot = $('<div class="msg-footer" style="display:flex;"></div>').appendTo(main);
|
|
@@ -539,7 +630,7 @@ function buildMessage(m, index){
|
|
|
if(m.usage.tokens_per_second > 0){ u += " · " + m.usage.tokens_per_second.toFixed(1) + " tok/s"; }
|
|
if(m.usage.tokens_per_second > 0){ u += " · " + m.usage.tokens_per_second.toFixed(1) + " tok/s"; }
|
|
|
$('<span class="usage"></span>').text(u).appendTo(foot);
|
|
$('<span class="usage"></span>').text(u).appendTo(foot);
|
|
|
}
|
|
}
|
|
|
- $('<button class="mini-btn">⧉ Copy</button>').on("click", function(){ copyText(m.content, this); }).appendTo(foot);
|
|
|
|
|
|
|
+ $('<button class="mini-btn">⧉ Copy</button>').on("click", function(){ copyText(answer, this); }).appendTo(foot);
|
|
|
$('<button class="mini-btn">↻ Regenerate</button>').on("click", function(){ regenerateFrom(index); }).appendTo(foot);
|
|
$('<button class="mini-btn">↻ Regenerate</button>').on("click", function(){ regenerateFrom(index); }).appendTo(foot);
|
|
|
}
|
|
}
|
|
|
row.data("index", index);
|
|
row.data("index", index);
|
|
@@ -567,14 +658,82 @@ function emptyState(){
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/* ─────────────────────────────────────────────────────────────
|
|
/* ─────────────────────────────────────────────────────────────
|
|
|
- File attachments (via the ArozOS file selector)
|
|
|
|
|
|
|
+ Thinking / reasoning
|
|
|
|
|
+ Reasoning models expose their chain-of-thought either inline as
|
|
|
|
|
+ <think>…</think> tags (many local models) or via a separate field the
|
|
|
|
|
+ backend forwards as `reasoning` (DeepSeek reasoning_content, OpenRouter
|
|
|
|
|
+ reasoning, Anthropic thinking blocks). Both are pulled out of the reply and
|
|
|
|
|
+ shown in a disclosure that stays collapsed until the user opens it.
|
|
|
|
|
+ ───────────────────────────────────────────────────────────── */
|
|
|
|
|
+// splitThinking pulls inline <think>/<thinking> blocks out of the content,
|
|
|
|
|
+// returning { thinking, answer }.
|
|
|
|
|
+function splitThinking(content){
|
|
|
|
|
+ content = String(content == null ? "" : content);
|
|
|
|
|
+ var thinking = [];
|
|
|
|
|
+ var answer = content.replace(/<(think|thinking)>([\s\S]*?)<\/\1>/gi, function(_, tag, inner){
|
|
|
|
|
+ thinking.push(inner.trim());
|
|
|
|
|
+ return "";
|
|
|
|
|
+ });
|
|
|
|
|
+ // A dangling opening tag (reply cut off mid-thought): treat the rest as thinking.
|
|
|
|
|
+ var open = answer.match(/<(think|thinking)>([\s\S]*)$/i);
|
|
|
|
|
+ if(open){
|
|
|
|
|
+ thinking.push(open[2].trim());
|
|
|
|
|
+ answer = answer.slice(0, open.index);
|
|
|
|
|
+ }
|
|
|
|
|
+ return { thinking: thinking.join("\n\n").trim(), answer: answer.trim() };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// computeThinking combines the backend `reasoning` field with any inline
|
|
|
|
|
+// think-tag reasoning and returns { thinkingText, answer } for a message.
|
|
|
|
|
+function computeThinking(m){
|
|
|
|
|
+ var parts = splitThinking(m.content || "");
|
|
|
|
|
+ var pieces = [];
|
|
|
|
|
+ if(m.reasoning && String(m.reasoning).trim() !== ""){ pieces.push(String(m.reasoning).trim()); }
|
|
|
|
|
+ if(parts.thinking !== ""){ pieces.push(parts.thinking); }
|
|
|
|
|
+ return { thinkingText: pieces.join("\n\n"), answer: parts.answer };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// buildThinking renders the collapsible "thinking" disclosure (collapsed by
|
|
|
|
|
+// default; the header button toggles it open).
|
|
|
|
|
+function buildThinking(text){
|
|
|
|
|
+ var wrap = $('<div class="thinking"></div>');
|
|
|
|
|
+ var head = $('<button class="thinking-toggle" type="button"></button>');
|
|
|
|
|
+ head.html(
|
|
|
|
|
+ '<svg class="tk-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
|
|
|
+ '<path d="M9 18h6"/><path d="M10 22h4"/>' +
|
|
|
|
|
+ '<path d="M12 2a7 7 0 0 0-4 12.7c.6.5.9 1.1 1 1.8l.1.5h5.8l.1-.5c.1-.7.4-1.3 1-1.8A7 7 0 0 0 12 2z"/>' +
|
|
|
|
|
+ '</svg>' +
|
|
|
|
|
+ '<span class="tk-label">Show thinking</span>' +
|
|
|
|
|
+ '<svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'
|
|
|
|
|
+ );
|
|
|
|
|
+ var body = $('<div class="thinking-body"></div>').html(renderMarkdown(text));
|
|
|
|
|
+ head.on("click", function(){
|
|
|
|
|
+ var open = wrap.toggleClass("open").hasClass("open");
|
|
|
|
|
+ head.find(".tk-label").text(open ? "Hide thinking" : "Show thinking");
|
|
|
|
|
+ });
|
|
|
|
|
+ wrap.append(head).append(body);
|
|
|
|
|
+ return wrap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ─────────────────────────────────────────────────────────────
|
|
|
|
|
+ File attachments (via the ArozOS file selector or a local upload)
|
|
|
───────────────────────────────────────────────────────────── */
|
|
───────────────────────────────────────────────────────────── */
|
|
|
function fileIconSvg(){
|
|
function fileIconSvg(){
|
|
|
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>';
|
|
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>';
|
|
|
}
|
|
}
|
|
|
|
|
+// Toggle / close the attach menu (ArozOS files vs. upload from computer).
|
|
|
|
|
+function toggleAttachMenu(ev){ if(ev){ ev.stopPropagation(); } $("#attachMenu").toggleClass("open"); }
|
|
|
|
|
+function closeAttachMenu(){ $("#attachMenu").removeClass("open"); }
|
|
|
|
|
+// Dismiss the menu on any outside click.
|
|
|
|
|
+$(document).on("click", function(ev){
|
|
|
|
|
+ if($(ev.target).closest(".attach-wrap").length === 0){ closeAttachMenu(); }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Attach files already living in the user's ArozOS file system.
|
|
|
function pickFiles(){
|
|
function pickFiles(){
|
|
|
|
|
+ closeAttachMenu();
|
|
|
if(typeof ao_module_openFileSelector !== "function"){
|
|
if(typeof ao_module_openFileSelector !== "function"){
|
|
|
- showToast("File selector is only available inside the ArozOS desktop.", "red");
|
|
|
|
|
|
|
+ notify('The ArozOS file picker is only available on the ArozOS desktop. Use "Upload from computer" instead.', true);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
// The callback is referenced by name in virtual-desktop mode, so it must be
|
|
// The callback is referenced by name in virtual-desktop mode, so it must be
|
|
@@ -587,12 +746,163 @@ function aichatFileSelected(files){
|
|
|
files.forEach(function(f){
|
|
files.forEach(function(f){
|
|
|
var fp = f.filepath || f.path || "";
|
|
var fp = f.filepath || f.path || "";
|
|
|
var fn = f.filename || (fp ? fp.split("/").pop() : "file");
|
|
var fn = f.filename || (fp ? fp.split("/").pop() : "file");
|
|
|
- if(fp && !pendingFiles.some(function(p){ return p.filepath === fp; })){
|
|
|
|
|
- pendingFiles.push({ filepath: fp, filename: fn });
|
|
|
|
|
|
|
+ if(!fp || pendingFiles.some(function(p){ return p.srcPath === fp || p.filepath === fp; })) return;
|
|
|
|
|
+ if(isPdfName(fn)){
|
|
|
|
|
+ // Rasterise the PDF (read through /media) into attachable JPEG pages.
|
|
|
|
|
+ var entry = { filename: fn, filepath: "", srcPath: fp, paths: [], uploading: true, failed: false, note: "reading…" };
|
|
|
|
|
+ pendingFiles.push(entry); renderAttachments();
|
|
|
|
|
+ stagePdf("/media?file=" + encodeURIComponent(fp), fn, entry);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ pendingFiles.push({ filepath: fp, filename: fn, srcPath: fp, paths: [fp] });
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
renderAttachments();
|
|
renderAttachments();
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/* ── PDF support ───────────────────────────────────────────────────────────
|
|
|
|
|
+ Vision models take images, not PDFs, so a PDF is rasterised to JPEG pages in
|
|
|
|
|
+ the browser with the pdf.js already bundled with the PDF Viewer app, and the
|
|
|
|
|
+ pages are uploaded and attached in its place. No server-side or system
|
|
|
|
|
+ dependency (no poppler/ghostscript) is involved. */
|
|
|
|
|
+var pdfJsReady = false;
|
|
|
|
|
+function isPdfName(name){ return /\.pdf$/i.test(name || ""); }
|
|
|
|
|
+
|
|
|
|
|
+function loadPdfJs(onReady, onError){
|
|
|
|
|
+ if(pdfJsReady){ onReady(); return; }
|
|
|
|
|
+ if(typeof pdfjsLib !== "undefined"){
|
|
|
|
|
+ pdfjsLib.GlobalWorkerOptions.workerSrc = "../PDF Viewer/js/pdf.worker.js";
|
|
|
|
|
+ pdfJsReady = true; onReady(); return;
|
|
|
|
|
+ }
|
|
|
|
|
+ var s = document.createElement("script");
|
|
|
|
|
+ s.src = "../PDF Viewer/js/pdf.js";
|
|
|
|
|
+ s.onload = function(){
|
|
|
|
|
+ pdfjsLib.GlobalWorkerOptions.workerSrc = "../PDF Viewer/js/pdf.worker.js";
|
|
|
|
|
+ pdfJsReady = true; onReady();
|
|
|
|
|
+ };
|
|
|
|
|
+ s.onerror = function(){ onError(new Error("Could not load the PDF library")); };
|
|
|
|
|
+ document.head.appendChild(s);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// renderPdfToImages loads a PDF (from an ArrayBuffer or a /media URL), renders
|
|
|
|
|
+// up to PDF_MAX_PAGES pages to JPEG, uploads them and reports the vpaths.
|
|
|
|
|
+function renderPdfToImages(source, baseName, entry, onDone, onError){
|
|
|
|
|
+ loadPdfJs(function(){
|
|
|
|
|
+ var task;
|
|
|
|
|
+ try { task = pdfjsLib.getDocument(source); }
|
|
|
|
|
+ catch(e){ onError(e); return; }
|
|
|
|
|
+ task.promise.then(function(pdf){
|
|
|
|
|
+ var total = pdf.numPages;
|
|
|
|
|
+ var pages = Math.min(total, PDF_MAX_PAGES);
|
|
|
|
|
+ var paths = [];
|
|
|
|
|
+ var stamp = Date.now().toString(36) + Math.random().toString(36).slice(2,5);
|
|
|
|
|
+
|
|
|
|
|
+ function next(i){
|
|
|
|
|
+ if(i > pages){ onDone(paths, total, pages); return; }
|
|
|
|
|
+ entry.note = "page " + i + "/" + pages;
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+ pdf.getPage(i).then(function(page){
|
|
|
|
|
+ var vp = page.getViewport({ scale: PDF_RENDER_SCALE });
|
|
|
|
|
+ var canvas = document.createElement("canvas");
|
|
|
|
|
+ canvas.width = vp.width; canvas.height = vp.height;
|
|
|
|
|
+ var ctx = canvas.getContext("2d");
|
|
|
|
|
+ // JPEG has no alpha — paint white so text stays readable.
|
|
|
|
|
+ ctx.fillStyle = "#FFF"; ctx.fillRect(0, 0, vp.width, vp.height);
|
|
|
|
|
+ page.render({ canvasContext: ctx, viewport: vp }).promise.then(function(){
|
|
|
|
|
+ canvas.toBlob(function(blob){
|
|
|
|
|
+ if(!blob){ onError(new Error("Could not encode page " + i)); return; }
|
|
|
|
|
+ var fname = stamp + "_" + baseName + "_p" + i + ".jpg";
|
|
|
|
|
+ ao_module_uploadFile(new File([blob], fname, { type:"image/jpeg" }), LOCAL_UPLOAD_DIR, function(){
|
|
|
|
|
+ paths.push(LOCAL_UPLOAD_DIR + "/" + fname);
|
|
|
|
|
+ next(i + 1);
|
|
|
|
|
+ }, undefined, function(status){
|
|
|
|
|
+ onError(new Error("Upload failed for page " + i + (status ? " (HTTP " + status + ")" : "")));
|
|
|
|
|
+ });
|
|
|
|
|
+ }, "image/jpeg", PDF_JPEG_QUALITY);
|
|
|
|
|
+ }, onError);
|
|
|
|
|
+ }, onError);
|
|
|
|
|
+ }
|
|
|
|
|
+ next(1);
|
|
|
|
|
+ }, onError);
|
|
|
|
|
+ }, onError);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// stagePdf converts a PDF into image pages and finishes the pending entry.
|
|
|
|
|
+// `source` is an ArrayBuffer (local file) or a /media URL (ArozOS file).
|
|
|
|
|
+function stagePdf(source, displayName, entry){
|
|
|
|
|
+ var base = displayName.replace(/\.pdf$/i, "").replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 40) || "document";
|
|
|
|
|
+ renderPdfToImages(source, base, entry, function(paths, total, rendered){
|
|
|
|
|
+ entry.uploading = false;
|
|
|
|
|
+ entry.paths = paths;
|
|
|
|
|
+ entry.filepath = paths[0] || "";
|
|
|
|
|
+ entry.note = rendered + (rendered === 1 ? " page" : " pages");
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+ if(total > rendered){
|
|
|
|
|
+ notify('"' + displayName + '" has ' + total + " pages — only the first " + rendered + " were attached.", false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, function(err){
|
|
|
|
|
+ entry.uploading = false; entry.failed = true; entry.note = "";
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+ notify('Could not read "' + displayName + '": ' + ((err && err.message) ? err.message : err), true);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Attach files from the user's own computer: open the OS file dialog, upload
|
|
|
|
|
+// each pick into a scratch folder in the user's ArozOS storage, then stage the
|
|
|
|
|
+// resulting virtual path just like an ArozOS-picked file.
|
|
|
|
|
+function pickLocalFiles(){
|
|
|
|
|
+ closeAttachMenu();
|
|
|
|
|
+ var input = document.getElementById("localFileInput");
|
|
|
|
|
+ input.value = ""; // allow re-picking the same file
|
|
|
|
|
+ input.click();
|
|
|
|
|
+}
|
|
|
|
|
+function onLocalFilesChosen(input){
|
|
|
|
|
+ var files = input.files;
|
|
|
|
|
+ if(!files || !files.length) return;
|
|
|
|
|
+ for(var i = 0; i < files.length; i++){ uploadLocalFile(files[i]); }
|
|
|
|
|
+ input.value = "";
|
|
|
|
|
+}
|
|
|
|
|
+function uploadLocalFile(file){
|
|
|
|
|
+ if(typeof ao_module_uploadFile !== "function"){
|
|
|
|
|
+ notify("File upload is not available in this context.", true);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Stage a chip in the uploading state right away for immediate feedback.
|
|
|
|
|
+ var entry = { filepath: "", filename: file.name, paths: [], uploading: true, failed: false, note: "" };
|
|
|
|
|
+ pendingFiles.push(entry);
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+
|
|
|
|
|
+ // A PDF is converted to JPEG pages in the browser instead of being sent
|
|
|
|
|
+ // as-is (models only accept images / text documents).
|
|
|
|
|
+ if(isPdfName(file.name)){
|
|
|
|
|
+ entry.note = "reading…"; renderAttachments();
|
|
|
|
|
+ var reader = new FileReader();
|
|
|
|
|
+ reader.onload = function(){ stagePdf({ data: new Uint8Array(reader.result) }, file.name, entry); };
|
|
|
|
|
+ reader.onerror = function(){
|
|
|
|
|
+ entry.uploading = false; entry.failed = true; entry.note = "";
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+ notify('Could not read "' + file.name + '".', true);
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.readAsArrayBuffer(file);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Store under a unique name so same-named uploads don't clobber each other.
|
|
|
|
|
+ var safe = (file.name || "file").replace(/[\/\\]/g, "_");
|
|
|
|
|
+ var storeName = Date.now().toString(36) + Math.random().toString(36).slice(2,5) + "_" + safe;
|
|
|
|
|
+ var toUpload = new File([file], storeName, { type: file.type });
|
|
|
|
|
+
|
|
|
|
|
+ ao_module_uploadFile(toUpload, LOCAL_UPLOAD_DIR, function(){
|
|
|
|
|
+ entry.uploading = false;
|
|
|
|
|
+ entry.filepath = LOCAL_UPLOAD_DIR + "/" + storeName;
|
|
|
|
|
+ entry.paths = [entry.filepath];
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+ }, undefined, function(status){
|
|
|
|
|
+ entry.uploading = false; entry.failed = true;
|
|
|
|
|
+ renderAttachments();
|
|
|
|
|
+ notify('Upload failed for "' + file.name + '"' + (status ? " (HTTP " + status + ")" : "") + ".", true);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function removeAttachment(i){ pendingFiles.splice(i,1); renderAttachments(); }
|
|
function removeAttachment(i){ pendingFiles.splice(i,1); renderAttachments(); }
|
|
|
function renderAttachments(){
|
|
function renderAttachments(){
|
|
|
var box = $("#attachments");
|
|
var box = $("#attachments");
|
|
@@ -600,13 +910,35 @@ function renderAttachments(){
|
|
|
box.empty().show();
|
|
box.empty().show();
|
|
|
pendingFiles.forEach(function(f, i){
|
|
pendingFiles.forEach(function(f, i){
|
|
|
var chip = $('<div class="attach-chip"></div>');
|
|
var chip = $('<div class="attach-chip"></div>');
|
|
|
- chip.append(fileIconSvg());
|
|
|
|
|
- chip.append($('<span class="fn"></span>').text(f.filename).attr("title", f.filepath));
|
|
|
|
|
|
|
+ if(f.uploading){ chip.addClass("uploading"); }
|
|
|
|
|
+ if(f.failed){ chip.addClass("failed"); }
|
|
|
|
|
+ chip.append(f.uploading ? '<span class="spin"></span>' : fileIconSvg());
|
|
|
|
|
+ var suffix = "";
|
|
|
|
|
+ if(f.uploading){ suffix = " · " + (f.note || "uploading…"); }
|
|
|
|
|
+ else if(f.failed){ suffix = " · failed"; }
|
|
|
|
|
+ else if(f.note){ suffix = " · " + f.note; }
|
|
|
|
|
+ chip.append($('<span class="fn"></span>').text(f.filename + suffix).attr("title", f.filepath || f.filename));
|
|
|
$('<span class="rm">✕</span>').on("click", function(){ removeAttachment(i); }).appendTo(chip);
|
|
$('<span class="rm">✕</span>').on("click", function(){ removeAttachment(i); }).appendTo(chip);
|
|
|
box.append(chip);
|
|
box.append(chip);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Lightweight, self-contained toast (the desktop shell's showToast is not
|
|
|
|
|
+// reachable from inside this iframe).
|
|
|
|
|
+function notify(msg, isError){
|
|
|
|
|
+ var wrap = document.getElementById("toastWrap");
|
|
|
|
|
+ if(!wrap){ return; }
|
|
|
|
|
+ var el = document.createElement("div");
|
|
|
|
|
+ el.className = "toast" + (isError ? " err" : "");
|
|
|
|
|
+ el.textContent = msg;
|
|
|
|
|
+ wrap.appendChild(el);
|
|
|
|
|
+ requestAnimationFrame(function(){ el.classList.add("show"); });
|
|
|
|
|
+ setTimeout(function(){
|
|
|
|
|
+ el.classList.remove("show");
|
|
|
|
|
+ setTimeout(function(){ if(el.parentNode){ el.parentNode.removeChild(el); } }, 250);
|
|
|
|
|
+ }, 3400);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/* ─────────────────────────────────────────────────────────────
|
|
/* ─────────────────────────────────────────────────────────────
|
|
|
Sending & generation
|
|
Sending & generation
|
|
|
───────────────────────────────────────────────────────────── */
|
|
───────────────────────────────────────────────────────────── */
|
|
@@ -620,11 +952,22 @@ function buildOptions(){
|
|
|
|
|
|
|
|
function sendMessage(){
|
|
function sendMessage(){
|
|
|
if(generating) return;
|
|
if(generating) return;
|
|
|
|
|
+ closeAttachMenu();
|
|
|
var conv = currentConv(); if(!conv) return;
|
|
var conv = currentConv(); if(!conv) return;
|
|
|
|
|
+ if(pendingFiles.some(function(f){ return f.uploading; })){
|
|
|
|
|
+ notify("Please wait for the file upload to finish.", true);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
var text = $("#composer").val().replace(/\s+$/,"");
|
|
var text = $("#composer").val().replace(/\s+$/,"");
|
|
|
- if(!text.trim() && pendingFiles.length === 0) return;
|
|
|
|
|
|
|
+ // Only attach files that finished uploading / converting (skip failed ones).
|
|
|
|
|
+ var ready = pendingFiles.filter(function(f){ return !f.failed && !f.uploading && (f.paths && f.paths.length); });
|
|
|
|
|
+ if(!text.trim() && ready.length === 0) return;
|
|
|
var msg = { role:"user", content:text };
|
|
var msg = { role:"user", content:text };
|
|
|
- if(pendingFiles.length > 0){ msg.files = pendingFiles.slice(); }
|
|
|
|
|
|
|
+ if(ready.length > 0){
|
|
|
|
|
+ msg.files = ready.map(function(f){
|
|
|
|
|
+ return { filepath:f.filepath, filename:f.filename, paths:f.paths.slice() };
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
conv.messages.push(msg);
|
|
conv.messages.push(msg);
|
|
|
if(!conv.titleSet && text.trim()){ conv.title = text.slice(0,42); conv.titleSet = true; renderSidebar(); }
|
|
if(!conv.titleSet && text.trim()){ conv.title = text.slice(0,42); conv.titleSet = true; renderSidebar(); }
|
|
|
pendingFiles = []; renderAttachments();
|
|
pendingFiles = []; renderAttachments();
|
|
@@ -636,27 +979,231 @@ function sendMessage(){
|
|
|
function generate(conv){
|
|
function generate(conv){
|
|
|
generating = true; updateComposerState();
|
|
generating = true; updateComposerState();
|
|
|
var typing = showTyping();
|
|
var typing = showTyping();
|
|
|
- var payload = conv.messages.map(function(m){ return { role:m.role, content:m.content }; });
|
|
|
|
|
|
|
+ // Send only the visible answer for prior assistant turns — don't feed the
|
|
|
|
|
+ // model's earlier thinking back to it.
|
|
|
|
|
+ var payload = conv.messages.map(function(m){
|
|
|
|
|
+ var content = (m.role === "assistant" && !m.error) ? computeThinking(m).answer : m.content;
|
|
|
|
|
+ return { role:m.role, content:content };
|
|
|
|
|
+ });
|
|
|
var opts = buildOptions();
|
|
var opts = buildOptions();
|
|
|
- // Attachments belong to the latest user message (chat.agi merges them in).
|
|
|
|
|
|
|
+ // Attachments belong to the latest user message. A single attachment may
|
|
|
|
|
+ // expand to several paths (one per rendered PDF page).
|
|
|
var files = [];
|
|
var files = [];
|
|
|
for(var i = conv.messages.length - 1; i >= 0; i--){
|
|
for(var i = conv.messages.length - 1; i >= 0; i--){
|
|
|
- if(conv.messages[i].role === "user"){ files = (conv.messages[i].files || []).map(function(f){ return f.filepath; }); break; }
|
|
|
|
|
|
|
+ if(conv.messages[i].role === "user"){ files = flattenFilePaths(conv.messages[i].files); break; }
|
|
|
|
|
+ }
|
|
|
|
|
+ // Prefer the streaming WebSocket backend; fall back to the one-shot HTTP
|
|
|
|
|
+ // backend when the socket cannot be established.
|
|
|
|
|
+ if(window.WebSocket){ streamGenerate(conv, payload, opts, files, typing); }
|
|
|
|
|
+ else { blockingGenerate(conv, payload, opts, files, typing); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// flattenFilePaths turns stored attachment entries into the flat vpath list the
|
|
|
|
|
+// backend expects (each entry contributes its `paths`, or its single filepath).
|
|
|
|
|
+function flattenFilePaths(entries){
|
|
|
|
|
+ var out = [];
|
|
|
|
|
+ (entries || []).forEach(function(f){
|
|
|
|
|
+ if(f.paths && f.paths.length){ f.paths.forEach(function(p){ out.push(p); }); }
|
|
|
|
|
+ else if(f.filepath){ out.push(f.filepath); }
|
|
|
|
|
+ });
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ── Streaming generation (WebSocket) ─────────────────────────────────────
|
|
|
|
|
+ Connects to backend/chat_stream.agi, which relays llm.streamRequest()
|
|
|
|
|
+ deltas frame by frame, so the answer and the model's thinking render as
|
|
|
|
|
+ they are produced rather than in one go. */
|
|
|
|
|
+function wsEndpoint(){
|
|
|
|
|
+ if(typeof ao_module_utils !== "undefined" && ao_module_utils.getWebSocketEndpoint){
|
|
|
|
|
+ return ao_module_utils.getWebSocketEndpoint();
|
|
|
|
|
+ }
|
|
|
|
|
+ var proto = (location.protocol === "https:") ? "wss://" : "ws://";
|
|
|
|
|
+ return proto + location.hostname + (location.port ? (":" + location.port) : "");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function streamGenerate(conv, payload, opts, files, typing){
|
|
|
|
|
+ var url = wsEndpoint() + "/system/ajgi/interface?script=AIChat/backend/chat_stream.agi";
|
|
|
|
|
+ var ws;
|
|
|
|
|
+ try { ws = new WebSocket(url); }
|
|
|
|
|
+ catch(e){ blockingGenerate(conv, payload, opts, files, typing); return; }
|
|
|
|
|
+
|
|
|
|
|
+ activeSocket = ws;
|
|
|
|
|
+ var st = streamState = {
|
|
|
|
|
+ conv: conv, opts: opts, ws: ws, row: null, bubble: null,
|
|
|
|
|
+ thinking: null, thinkBody: null, userToggled: false,
|
|
|
|
|
+ content: "", reasoning: "", started: false, gotAny: false,
|
|
|
|
|
+ finished: false, fellBack: false, typing: typing
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ ws.onopen = function(){
|
|
|
|
|
+ ws.send(JSON.stringify({ messages: payload, options: opts, files: files || [] }));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ ws.onmessage = function(ev){
|
|
|
|
|
+ var msg; try { msg = JSON.parse(ev.data); } catch(e){ return; }
|
|
|
|
|
+ if(msg.type === "start"){ ensureStreamRow(st); return; }
|
|
|
|
|
+ if(msg.type === "reasoning"){ st.gotAny = true; appendReasoning(st, msg.content || ""); return; }
|
|
|
|
|
+ if(msg.type === "delta"){ st.gotAny = true; appendDelta(st, msg.content || ""); return; }
|
|
|
|
|
+ if(msg.type === "done"){
|
|
|
|
|
+ st.finished = true;
|
|
|
|
|
+ // Prefer the server's assembled text (authoritative) over our accumulation.
|
|
|
|
|
+ finishStream(st, {
|
|
|
|
|
+ content: (typeof msg.content === "string" && msg.content !== "") ? msg.content : st.content,
|
|
|
|
|
+ reasoning: (typeof msg.reasoning === "string" && msg.reasoning !== "") ? msg.reasoning : st.reasoning,
|
|
|
|
|
+ usage: msg.usage || null,
|
|
|
|
|
+ model: msg.model || opts.model
|
|
|
|
|
+ });
|
|
|
|
|
+ try { ws.close(); } catch(e){}
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if(msg.type === "error"){
|
|
|
|
|
+ st.finished = true;
|
|
|
|
|
+ clearStreamRow(st);
|
|
|
|
|
+ pushAssistant(conv, { role:"assistant", content: msg.error || "Request failed.", error:true });
|
|
|
|
|
+ endGenerating();
|
|
|
|
|
+ try { ws.close(); } catch(e){}
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ ws.onerror = function(){
|
|
|
|
|
+ // Nothing streamed yet — quietly fall back to the HTTP backend.
|
|
|
|
|
+ if(!st.gotAny && !st.finished && !st.fellBack){
|
|
|
|
|
+ st.fellBack = true;
|
|
|
|
|
+ blockingGenerate(conv, payload, opts, files, typing);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ ws.onclose = function(){
|
|
|
|
|
+ if(st.finished || st.fellBack) return;
|
|
|
|
|
+ if(!st.gotAny){
|
|
|
|
|
+ st.fellBack = true;
|
|
|
|
|
+ blockingGenerate(conv, payload, opts, files, typing);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Socket dropped mid-reply — keep whatever was received.
|
|
|
|
|
+ st.finished = true;
|
|
|
|
|
+ finishStream(st, { content: st.content, reasoning: st.reasoning, usage: null, model: opts.model });
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ensureStreamRow swaps the typing indicator for a live assistant row.
|
|
|
|
|
+function ensureStreamRow(st){
|
|
|
|
|
+ if(st.row) return;
|
|
|
|
|
+ if(st.typing){ st.typing.remove(); st.typing = null; }
|
|
|
|
|
+ var wrap = ensureWrap();
|
|
|
|
|
+ var row = $('<div class="msg assistant"></div>');
|
|
|
|
|
+ $('<div class="avatar"></div>').text("AI").appendTo(row);
|
|
|
|
|
+ var main = $('<div class="msg-main"></div>').appendTo(row);
|
|
|
|
|
+ $('<div class="msg-role"></div>').text(st.opts.model ? ("Assistant · " + st.opts.model) : "Assistant").appendTo(main);
|
|
|
|
|
+ st.bubble = $('<div class="bubble"></div>').appendTo(main);
|
|
|
|
|
+ st.bubble.append('<span class="caret"></span>');
|
|
|
|
|
+ st.main = main;
|
|
|
|
|
+ st.row = row;
|
|
|
|
|
+ wrap.append(row);
|
|
|
|
|
+ scrollBottom();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// appendReasoning grows the live thinking section. It auto-opens while the
|
|
|
|
|
+// model is thinking so the process is visible in real time, then collapses
|
|
|
|
|
+// once the answer starts (unless the user toggled it themselves).
|
|
|
|
|
+function appendReasoning(st, chunk){
|
|
|
|
|
+ if(chunk === "") return;
|
|
|
|
|
+ ensureStreamRow(st);
|
|
|
|
|
+ st.reasoning += chunk;
|
|
|
|
|
+ if(!st.thinking){
|
|
|
|
|
+ st.thinking = buildThinking("");
|
|
|
|
|
+ st.thinking.addClass("open live");
|
|
|
|
|
+ st.thinking.find(".tk-label").text("Thinking…");
|
|
|
|
|
+ st.thinkBody = st.thinking.find(".thinking-body").addClass("streaming");
|
|
|
|
|
+ // Remember a manual toggle so auto-collapse does not fight the user.
|
|
|
|
|
+ st.thinking.find(".thinking-toggle").on("click", function(){ st.userToggled = true; });
|
|
|
|
|
+ st.main.prepend(st.thinking);
|
|
|
|
|
+ // Keep the role label above the thinking block.
|
|
|
|
|
+ st.main.find(".msg-role").prependTo(st.main);
|
|
|
|
|
+ }
|
|
|
|
|
+ st.thinkBody.text(st.reasoning);
|
|
|
|
|
+ maybeScroll();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// appendDelta grows the live answer text.
|
|
|
|
|
+function appendDelta(st, chunk){
|
|
|
|
|
+ if(chunk === "") return;
|
|
|
|
|
+ ensureStreamRow(st);
|
|
|
|
|
+ // First answer token: thinking is done — collapse it back to the default.
|
|
|
|
|
+ if(st.content === "" && st.thinking){
|
|
|
|
|
+ st.thinking.removeClass("live");
|
|
|
|
|
+ if(!st.userToggled){
|
|
|
|
|
+ st.thinking.removeClass("open");
|
|
|
|
|
+ st.thinking.find(".tk-label").text("Show thinking");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ st.thinking.find(".tk-label").text("Hide thinking");
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ st.content += chunk;
|
|
|
|
|
+ // Plain text while streaming (cheap, no reflow churn); markdown on finish.
|
|
|
|
|
+ st.bubble.text(st.content);
|
|
|
|
|
+ st.bubble.append('<span class="caret"></span>');
|
|
|
|
|
+ maybeScroll();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// finishStream commits the streamed reply to the conversation and re-renders
|
|
|
|
|
+// it through the normal path (markdown, footer, collapsed thinking).
|
|
|
|
|
+function finishStream(st, result){
|
|
|
|
|
+ var conv = st.conv;
|
|
|
|
|
+ var text = result.content || "";
|
|
|
|
|
+ var reasoning = result.reasoning || "";
|
|
|
|
|
+ if(text.trim() === "" && reasoning.trim() === ""){
|
|
|
|
|
+ clearStreamRow(st);
|
|
|
|
|
+ pushAssistant(conv, { role:"assistant", content:"The model returned an empty reply.", error:true });
|
|
|
|
|
+ endGenerating();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ conv.messages.push({
|
|
|
|
|
+ role:"assistant", content:text, reasoning:reasoning,
|
|
|
|
|
+ usage:result.usage || null, model:result.model || ""
|
|
|
|
|
+ });
|
|
|
|
|
+ persist();
|
|
|
|
|
+ clearStreamRow(st);
|
|
|
|
|
+ renderMessages();
|
|
|
|
|
+ endGenerating();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearStreamRow(st){
|
|
|
|
|
+ if(st.typing){ st.typing.remove(); st.typing = null; }
|
|
|
|
|
+ if(st.row){ st.row.remove(); st.row = null; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function endGenerating(){
|
|
|
|
|
+ generating = false;
|
|
|
|
|
+ activeSocket = null; streamState = null;
|
|
|
|
|
+ $("#stopBtn").hide();
|
|
|
|
|
+ updateComposerState(); updateTokenBadge();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Only auto-scroll when the user is already near the bottom, so scrolling back
|
|
|
|
|
+// through a long reply is not yanked away mid-stream.
|
|
|
|
|
+function isNearBottom(){
|
|
|
|
|
+ var m = document.getElementById("messages");
|
|
|
|
|
+ return (m.scrollHeight - m.scrollTop - m.clientHeight) < 120;
|
|
|
|
|
+}
|
|
|
|
|
+function maybeScroll(){ if(isNearBottom()) scrollBottom(); }
|
|
|
|
|
+
|
|
|
|
|
+/* ── Non-streaming fallback (one-shot HTTP) ───────────────────────────── */
|
|
|
|
|
+function blockingGenerate(conv, payload, opts, files, typing){
|
|
|
callBackend(payload, opts, files, function(resp){
|
|
callBackend(payload, opts, files, function(resp){
|
|
|
- typing.remove();
|
|
|
|
|
|
|
+ if(typing) typing.remove();
|
|
|
if(!resp || !resp.ok){
|
|
if(!resp || !resp.ok){
|
|
|
pushAssistant(conv, { role:"assistant", content:(resp && resp.error) ? resp.error : "Request failed.", error:true });
|
|
pushAssistant(conv, { role:"assistant", content:(resp && resp.error) ? resp.error : "Request failed.", error:true });
|
|
|
- generating = false; updateComposerState(); return;
|
|
|
|
|
|
|
+ endGenerating(); return;
|
|
|
}
|
|
}
|
|
|
- var msg = { role:"assistant", content:resp.content || "", usage:resp.usage || null, model:resp.model || opts.model };
|
|
|
|
|
|
|
+ var msg = { role:"assistant", content:resp.content || "", reasoning:resp.reasoning || "", usage:resp.usage || null, model:resp.model || opts.model };
|
|
|
conv.messages.push(msg); persist();
|
|
conv.messages.push(msg); persist();
|
|
|
var row = appendLiveAssistant(msg);
|
|
var row = appendLiveAssistant(msg);
|
|
|
- typewriter(row, msg.content, function(){ generating = false; updateComposerState(); updateTokenBadge(); });
|
|
|
|
|
|
|
+ typewriter(row, computeThinking(msg).answer, function(){ endGenerating(); });
|
|
|
}, function(){
|
|
}, function(){
|
|
|
- typing.remove();
|
|
|
|
|
|
|
+ if(typing) typing.remove();
|
|
|
pushAssistant(conv, { role:"assistant", content:"Network error — could not reach the server.", error:true });
|
|
pushAssistant(conv, { role:"assistant", content:"Network error — could not reach the server.", error:true });
|
|
|
- generating = false; updateComposerState();
|
|
|
|
|
|
|
+ endGenerating();
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -718,6 +1265,19 @@ function typewriter(row, fullText, done){
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function stopGenerating(){
|
|
function stopGenerating(){
|
|
|
|
|
+ // Streaming: close the socket and keep whatever has arrived so far.
|
|
|
|
|
+ if(activeSocket){
|
|
|
|
|
+ var st = streamState;
|
|
|
|
|
+ try { activeSocket.close(); } catch(e){}
|
|
|
|
|
+ activeSocket = null;
|
|
|
|
|
+ if(st && !st.finished){
|
|
|
|
|
+ st.finished = true;
|
|
|
|
|
+ if(st.gotAny){ finishStream(st, { content: st.content, reasoning: st.reasoning, usage: null, model: st.opts.model }); }
|
|
|
|
|
+ else { clearStreamRow(st); endGenerating(); }
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Fallback path: stop the typewriter reveal.
|
|
|
cancelReveal = true;
|
|
cancelReveal = true;
|
|
|
if(revealTimer){ clearTimeout(revealTimer); revealTimer = null; }
|
|
if(revealTimer){ clearTimeout(revealTimer); revealTimer = null; }
|
|
|
$("#stopBtn").hide();
|
|
$("#stopBtn").hide();
|
|
@@ -761,6 +1321,8 @@ function autoGrow(){
|
|
|
}
|
|
}
|
|
|
function updateComposerState(){
|
|
function updateComposerState(){
|
|
|
$("#sendBtn").prop("disabled", generating);
|
|
$("#sendBtn").prop("disabled", generating);
|
|
|
|
|
+ // Stop is offered for the whole generation, streaming included.
|
|
|
|
|
+ $("#stopBtn").css("display", generating ? "inline-flex" : "none");
|
|
|
document.getElementById("composer").disabled = false;
|
|
document.getElementById("composer").disabled = false;
|
|
|
}
|
|
}
|
|
|
|
|
|