|
|
@@ -118,7 +118,25 @@
|
|
|
.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;} .bubble h2,.bubble h3,.bubble h4{margin:14px 0 8px;line-height:1.3;}
|
|
|
+ .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 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;
|
|
|
@@ -221,7 +239,6 @@
|
|
|
.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;}
|
|
|
@@ -1086,6 +1103,42 @@ function streamGenerate(conv, payload, opts, files, typing){
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+/* 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(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;
|
|
|
@@ -1114,15 +1167,14 @@ function appendReasoning(st, chunk){
|
|
|
st.thinking = buildThinking("");
|
|
|
st.thinking.addClass("open live");
|
|
|
st.thinking.find(".tk-label").text("Thinking…");
|
|
|
- st.thinkBody = st.thinking.find(".thinking-body").addClass("streaming");
|
|
|
+ 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);
|
|
|
}
|
|
|
- st.thinkBody.text(st.reasoning);
|
|
|
- maybeScroll();
|
|
|
+ scheduleStreamRender(st);
|
|
|
}
|
|
|
|
|
|
// appendDelta grows the live answer text.
|
|
|
@@ -1140,10 +1192,7 @@ function appendDelta(st, chunk){
|
|
|
}
|
|
|
}
|
|
|
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();
|
|
|
+ scheduleStreamRender(st);
|
|
|
}
|
|
|
|
|
|
// finishStream commits the streamed reply to the conversation and re-renders
|
|
|
@@ -1169,6 +1218,7 @@ function finishStream(st, result){
|
|
|
}
|
|
|
|
|
|
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; }
|
|
|
}
|
|
|
@@ -1370,53 +1420,65 @@ function closeSidebar(){ $("#sidebar,#sidebarScrim").removeClass("open"); }
|
|
|
───────────────────────────────────────────────────────────── */
|
|
|
function esc(s){ return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
|
|
|
|
|
-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 "
|