Quellcode durchsuchen

Rollback AI chat to origin non-broken state

Toby Chui vor 2 Wochen
Ursprung
Commit
7b949ecdb4
1 geänderte Dateien mit 75 neuen und 699 gelöschten Zeilen
  1. 75 699
      src/web/AIChat/index.html

+ 75 - 699
src/web/AIChat/index.html

@@ -29,15 +29,12 @@
         background:var(--bg); color:var(--text); overflow:hidden;
         -webkit-font-smoothing:antialiased;
     }
-    /* 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;}
+    .app{display:grid; grid-template-columns:268px 1fr; height:100vh; height:100dvh;}
 
     /* ── Sidebar ───────────────────────────── */
     .sidebar{
         background:var(--panel); border-right:1px solid var(--border);
-        display:flex; flex-direction:column; min-width:0; min-height:0;
+        display:flex; flex-direction:column; min-width:0;
     }
     .sb-head{display:flex; align-items:center; gap:10px; padding:16px 16px 12px;}
     .sb-head img{width:30px;height:30px;border-radius:8px;}
@@ -51,7 +48,7 @@
     }
     .new-chat:hover{background:var(--panel-3); border-color:var(--accent);}
     .new-chat svg{width:16px;height:16px;}
-    .chat-list{flex:1; min-height:0; overflow-y:auto; padding:4px 8px 8px;}
+    .chat-list{flex:1; overflow-y:auto; padding:4px 8px 8px;}
     .chat-item{
         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;
@@ -118,25 +115,7 @@
     .msg-main{flex:1; min-width:0;}
     .msg-role{font-size:12px;font-weight:700;color:var(--text-dim);margin:3px 0 6px;}
     .bubble{font-size:14.5px; line-height:1.62; color:var(--text); word-wrap:break-word; overflow-wrap:anywhere;}
-    .bubble p{margin:0 0 10px;}
-    /* Headings and rules are shared with the thinking panel, which renders the
-       same markdown. Explicit sizes keep h5/h6 from collapsing to tiny text. */
-    .bubble h2,.bubble h3,.bubble h4,.bubble h5,.bubble h6,
-    .thinking-body h2,.thinking-body h3,.thinking-body h4,.thinking-body h5,.thinking-body h6{
-        margin:14px 0 8px;line-height:1.3;font-weight:700;}
-    .bubble h2,.thinking-body h2{font-size:1.34em;}
-    .bubble h3,.thinking-body h3{font-size:1.2em;}
-    .bubble h4,.thinking-body h4{font-size:1.08em;}
-    .bubble h5,.thinking-body h5{font-size:1em;}
-    .bubble h6,.thinking-body h6{font-size:.94em;color:var(--text-dim);}
-    .bubble hr,.thinking-body hr{border:none;border-top:1px solid var(--border);margin:16px 0;}
-    /* Tables scroll inside their own wrapper so a wide one never widens the chat. */
-    .bubble .tablewrap,.thinking-body .tablewrap{overflow-x:auto;margin:12px 0;}
-    .bubble table,.thinking-body table{border-collapse:collapse;font-size:.94em;min-width:100%;}
-    .bubble th,.bubble td,.thinking-body th,.thinking-body td{
-        border:1px solid var(--border);padding:7px 11px;text-align:left;vertical-align:top;}
-    .bubble th,.thinking-body th{background:var(--panel-2);font-weight:700;white-space:nowrap;}
-    .bubble tbody tr:nth-child(even) td,.thinking-body tbody tr:nth-child(even) td{background:rgba(127,127,140,.07);}
+    .bubble p{margin:0 0 10px;} .bubble h2,.bubble h3,.bubble h4{margin:14px 0 8px;line-height:1.3;}
     .bubble ul{margin:6px 0 10px; padding-left:22px;} .bubble li{margin:3px 0;}
     .bubble a{color:var(--accent);}
     .bubble code.inline{background:var(--panel-3);padding:1.5px 6px;border-radius:5px;font-size:.88em;
@@ -207,54 +186,6 @@
         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);}
 
-    /* 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;}}
-
-    /* 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 */
     .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;}
@@ -372,22 +303,9 @@
             <div class="attachments" id="attachments" style="display:none;"></div>
             <div class="composer">
                 <div class="composer-box">
-                    <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>
+                    <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>
                     <textarea id="composer" rows="1" placeholder="Send a message…"></textarea>
                     <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
@@ -397,7 +315,7 @@
                     </button>
                 </div>
             </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 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>
 
         <!-- Settings drawer -->
@@ -436,9 +354,6 @@
                 </div>
             </div>
         </div>
-
-        <!-- Transient notifications (self-contained, no desktop dependency) -->
-        <div class="toast-wrap" id="toastWrap"></div>
     </section>
 
     <!-- Backdrop for the mobile off-canvas sidebar -->
@@ -455,18 +370,7 @@ var state = { conversations: [], currentId: null,
 
 var generating = false, revealTimer = null, cancelReveal = false;
 var availableModels = [];          // models known from the backend
-// 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
+var pendingFiles = [];             // [{filepath, filename}] attached to the next message
 
 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){} }
@@ -534,7 +438,7 @@ function renderModelOptions(){
     if(list.length === 0){
         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.value = cur || (list[0] || "__custom__");
 }
@@ -619,25 +523,13 @@ function buildMessage(m, index){
     if(isUser && m.files && m.files.length){
         var fb = $('<div class="msg-files"></div>').appendTo(main);
         m.files.forEach(function(f){
-            // 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);
+            $('<span class="mf"></span>').html(fileIconSvg()).append($('<span></span>').text(f.filename)).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);
     if(m.error){ bubble.html('<div class="err">'+esc(m.content)+'</div>'); }
     else if(isUser){ bubble.text(m.content); }
-    else { bubble.html(renderMarkdown(answer)); }
+    else { bubble.html(renderMarkdown(m.content)); }
 
     if(!isUser && !m.error){
         var foot = $('<div class="msg-footer" style="display:flex;"></div>').appendTo(main);
@@ -647,7 +539,7 @@ function buildMessage(m, index){
             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);
         }
-        $('<button class="mini-btn">⧉ Copy</button>').on("click", function(){ copyText(answer, this); }).appendTo(foot);
+        $('<button class="mini-btn">⧉ Copy</button>').on("click", function(){ copyText(m.content, this); }).appendTo(foot);
         $('<button class="mini-btn">↻ Regenerate</button>').on("click", function(){ regenerateFrom(index); }).appendTo(foot);
     }
     row.data("index", index);
@@ -675,82 +567,14 @@ function emptyState(){
 }
 
 /* ─────────────────────────────────────────────────────────────
-   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)
+   File attachments (via the ArozOS file selector)
    ───────────────────────────────────────────────────────────── */
 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>';
 }
-// 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(){
-    closeAttachMenu();
     if(typeof ao_module_openFileSelector !== "function"){
-        notify('The ArozOS file picker is only available on the ArozOS desktop. Use "Upload from computer" instead.', true);
+        showToast("File selector is only available inside the ArozOS desktop.", "red");
         return;
     }
     // The callback is referenced by name in virtual-desktop mode, so it must be
@@ -763,163 +587,12 @@ function aichatFileSelected(files){
     files.forEach(function(f){
         var fp = f.filepath || f.path || "";
         var fn = f.filename || (fp ? fp.split("/").pop() : "file");
-        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] });
+        if(fp && !pendingFiles.some(function(p){ return p.filepath === fp; })){
+            pendingFiles.push({ filepath: fp, filename: fn });
         }
     });
     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 renderAttachments(){
     var box = $("#attachments");
@@ -927,35 +600,13 @@ function renderAttachments(){
     box.empty().show();
     pendingFiles.forEach(function(f, i){
         var chip = $('<div class="attach-chip"></div>');
-        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));
+        chip.append(fileIconSvg());
+        chip.append($('<span class="fn"></span>').text(f.filename).attr("title", f.filepath));
         $('<span class="rm">✕</span>').on("click", function(){ removeAttachment(i); }).appendTo(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
    ───────────────────────────────────────────────────────────── */
@@ -969,22 +620,11 @@ function buildOptions(){
 
 function sendMessage(){
     if(generating) return;
-    closeAttachMenu();
     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+$/,"");
-    // 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;
+    if(!text.trim() && pendingFiles.length === 0) return;
     var msg = { role:"user", content:text };
-    if(ready.length > 0){
-        msg.files = ready.map(function(f){
-            return { filepath:f.filepath, filename:f.filename, paths:f.paths.slice() };
-        });
-    }
+    if(pendingFiles.length > 0){ msg.files = pendingFiles.slice(); }
     conv.messages.push(msg);
     if(!conv.titleSet && text.trim()){ conv.title = text.slice(0,42); conv.titleSet = true; renderSidebar(); }
     pendingFiles = []; renderAttachments();
@@ -996,264 +636,27 @@ function sendMessage(){
 function generate(conv){
     generating = true; updateComposerState();
     var typing = showTyping();
-    // 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 payload = conv.messages.map(function(m){ return { role:m.role, content:m.content }; });
     var opts = buildOptions();
-    // Attachments belong to the latest user message. A single attachment may
-    // expand to several paths (one per rendered PDF page).
+    // Attachments belong to the latest user message (chat.agi merges them in).
     var files = [];
     for(var i = conv.messages.length - 1; i >= 0; i--){
-        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 });
-    };
-}
-
-/* Live markdown while streaming.
-   Half-written markup is normal mid-stream — most visibly an unclosed ```
-   fence, which would otherwise flash as raw backticks until the model closes
-   it. Close such constructs on a copy of the text just for rendering; the
-   stored text is untouched. */
-function closeOpenMarkdown(src){
-    var fences = src.match(/```/g);
-    if(fences && fences.length % 2 === 1){ src += "\n```"; }   // fence still open
-    return src;
-}
-
-// Re-rendering on every token would thrash layout, so paints are coalesced.
-var STREAM_RENDER_MS = 60;
-function scheduleStreamRender(st){
-    if(st.renderTimer) return;
-    st.renderTimer = setTimeout(function(){
-        st.renderTimer = null;
-        paintStream(st);
-    }, STREAM_RENDER_MS);
-}
-function cancelStreamRender(st){
-    if(st && st.renderTimer){ clearTimeout(st.renderTimer); st.renderTimer = null; }
-}
-function paintStream(st){
-    if(!st.row) return;                       // reply already finalised
-    if(st.thinkBody && st.reasoning !== ""){
-        st.thinkBody.html(renderMarkdown(closeOpenMarkdown(st.reasoning)));
+        if(conv.messages[i].role === "user"){ files = (conv.messages[i].files || []).map(function(f){ return f.filepath; }); break; }
     }
-    if(st.bubble){
-        st.bubble.html(renderMarkdown(closeOpenMarkdown(st.content)));
-        enhanceCodeBlocks(st.bubble[0]);
-        st.bubble.append('<span class="caret"></span>');
-    }
-    maybeScroll();
-}
-
-// 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");
-        // 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);
-    }
-    scheduleStreamRender(st);
-}
-
-// 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;
-    scheduleStreamRender(st);
-}
-
-// 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){
-    cancelStreamRender(st);                   // no stray paint after finalising
-    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){
-        if(typing) typing.remove();
+        typing.remove();
         if(!resp || !resp.ok){
             pushAssistant(conv, { role:"assistant", content:(resp && resp.error) ? resp.error : "Request failed.", error:true });
-            endGenerating(); return;
+            generating = false; updateComposerState(); return;
         }
-        var msg = { role:"assistant", content:resp.content || "", reasoning:resp.reasoning || "", usage:resp.usage || null, model:resp.model || opts.model };
+        var msg = { role:"assistant", content:resp.content || "", usage:resp.usage || null, model:resp.model || opts.model };
         conv.messages.push(msg); persist();
         var row = appendLiveAssistant(msg);
-        typewriter(row, computeThinking(msg).answer, function(){ endGenerating(); });
+        typewriter(row, msg.content, function(){ generating = false; updateComposerState(); updateTokenBadge(); });
     }, function(){
-        if(typing) typing.remove();
+        typing.remove();
         pushAssistant(conv, { role:"assistant", content:"Network error — could not reach the server.", error:true });
-        endGenerating();
+        generating = false; updateComposerState();
     });
 }
 
@@ -1315,19 +718,6 @@ function typewriter(row, fullText, done){
 }
 
 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;
     if(revealTimer){ clearTimeout(revealTimer); revealTimer = null; }
     $("#stopBtn").hide();
@@ -1371,8 +761,6 @@ function autoGrow(){
 }
 function updateComposerState(){
     $("#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;
 }
 
@@ -1420,65 +808,53 @@ function closeSidebar(){ $("#sidebar,#sidebarScrim").removeClass("open"); }
    ───────────────────────────────────────────────────────────── */
 function esc(s){ return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
 
-/* GFM tables.
-   Line-based rather than one big regex: a table is a row, a delimiter row
-   (---, :---, ---:, :---:) directly beneath it, then rows until a line that is
-   no longer one. A leading "|" is required to start a row, which is what
-   models emit and keeps prose containing a stray pipe from being swallowed.
-   Runs after the inline rules, so cells arrive with bold/code/links applied. */
-function mdSplitRow(line){
-    var s = line.trim();
-    if(s.charAt(0) === "|"){ s = s.slice(1); }
-    if(s.charAt(s.length-1) === "|"){ s = s.slice(0, -1); }
-    return s.split("|").map(function(c){ return c.trim(); });
+function renderMarkdown(src){
+    src = String(src==null?"":src);
+    var blocks = [];
+    src = src.replace(/```(\w+)?\n?([\s\S]*?)```/g, function(_, lang, code){
+        var idx = blocks.length; blocks.push({ lang:lang||"", code:code.replace(/\n$/,"") });
+        return " CB" + idx + " ";
+    });
+    var html = esc(src);
+    html = html.replace(/`([^`\n]+)`/g, function(_, c){ return '<code class="inline">'+c+'</code>'; });
+    html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
+    html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
+    html = html.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
+    html = html.replace(/^###\s+(.*)$/gm, "<h4>$1</h4>");
+    html = html.replace(/^##\s+(.*)$/gm, "<h3>$1</h3>");
+    html = html.replace(/^#\s+(.*)$/gm, "<h2>$1</h2>");
+    html = html.replace(/(?:^|\n)((?:\s*[-*]\s+.*(?:\n|$))+)/g, function(_, list){
+        var items = list.trim().split(/\n/).map(function(li){ return "<li>"+li.replace(/^\s*[-*]\s+/,"")+"</li>"; }).join("");
+        return "\n<ul>"+items+"</ul>\n";
+    });
+    html = html.replace(/\n/g, "<br>");
+    html = html.replace(/ CB(\d+) /g, function(_, i){
+        var b = blocks[+i];
+        var head = b.lang ? '<div class="code-lang">'+esc(b.lang)+'</div>' : "";
+        return '<div class="codeblock">'+head+'<button class="copy-code" title="Copy">⧉</button><pre><code>'+esc(b.code)+'</code></pre></div>';
+    });
+    html = html.replace(/<br>\s*(<ul>|<h[234]>|<div class="codeblock">)/g, "$1");
+    html = html.replace(/(<\/ul>|<\/h[234]>|<\/div>)\s*<br>/g, "$1");
+    return html;
 }
-function mdIsTableRow(line){ return /^[ \t]*\|/.test(line); }
-function mdIsTableDelimiter(line){
-    if(!mdIsTableRow(line) || line.indexOf("-") === -1) return false;
-    var cells = mdSplitRow(line);
-    if(cells.length === 0) return false;
-    for(var i = 0; i < cells.length; i++){
-        if(!/^:?-+:?$/.test(cells[i])) return false;
-    }
-    return true;
+
+function enhanceCodeBlocks(scope){
+    $(scope).find(".copy-code").off("click").on("click", function(){
+        var code = $(this).siblings("pre").find("code").text();
+        copyText(code, this, "✓");
+    });
 }
-function mdAlignAttr(cell){
-    var left = cell.charAt(0) === ":", right = cell.charAt(cell.length-1) === ":";
-    if(left && right) return ' style="text-align:center"';
-    if(right) return ' style="text-align:right"';
-    return "";
+function copyText(text, btn, okGlyph){
+    var done = function(){ var $b=$(btn); var old=$b.text(); $b.text(okGlyph||"✓ Copied"); setTimeout(function(){ $b.text(old); }, 1200); };
+    if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(done, function(){ legacyCopy(text); done(); }); }
+    else { legacyCopy(text); done(); }
 }
-function renderTables(html){
-    if(html.indexOf("|") === -1) return html;      // fast path: no table possible
-    var lines = html.split("\n"), out = [];
-    for(var i = 0; i < lines.length; i++){
-        if(mdIsTableRow(lines[i]) && i + 1 < lines.length && mdIsTableDelimiter(lines[i + 1])){
-            var head = mdSplitRow(lines[i]);
-            var aligns = mdSplitRow(lines[i + 1]).map(mdAlignAttr);
-            var rows = [], j = i + 2;
-            while(j < lines.length && mdIsTableRow(lines[j]) && !mdIsTableDelimiter(lines[j])){
-                rows.push(mdSplitRow(lines[j])); j++;
-            }
-            var th = head.map(function(c, k){ return "<th" + (aligns[k] || "") + ">" + c + "</th>"; }).join("");
-            var tb = rows.map(function(r){
-                var tds = "";
-                for(var k = 0; k < head.length; k++){
-                    tds += "<td" + (aligns[k] || "") + ">" + (r[k] === undefined ? "" : r[k]) + "</td>";
-                }
-                return "<tr>" + tds + "</tr>";
-            }).join("");
-            out.push('<div class="tablewrap"><table><thead><tr>' + th + "</tr></thead><tbody>" + tb + "</tbody></table></div>");
-            i = j - 1;
-        } else {
-            out.push(lines[i]);
-        }
-    }
-    return out.join("\n");
+function legacyCopy(text){
+    var ta = document.createElement("textarea"); ta.value = text; ta.style.position="fixed"; ta.style.opacity="0";
+    document.body.appendChild(ta); ta.select(); try{ document.execCommand("copy"); }catch(e){} document.body.removeChild(ta);
 }
 
-function renderMarkdown(src){
-    src = String(src==null?"":src).replace(/\r\n?/g, "\n");   // CRLF would trail into headings
-    var blocks = [];
-    src = src.replace(/```(\w+)?\n?([\s\S]*?)```/g, function(_, lang, code){
-        var idx = blocks.length; blocks.push({ lang:lang||"", code:code.replace(/\n$/,"") });
-        return "
+function scrollBottom(){ var m = document.getElementById("messages"); m.scrollTop = m.scrollHeight; }
+</script>
+</body>
+</html>