From 61b4b5111829e656996a63eec5ce230e63bdb096 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 20:34:04 +0300 Subject: [PATCH] Hide orphan json fences in chat bubbles (0.15.14). Co-authored-by: Cursor --- Assets/assistent.bundle.js | 52 +++++++++++++++++++++++++++++++------- SwarmAssistentExtension.cs | 2 +- src/app.js | 23 +++++++++++++---- src/patch.js | 35 ++++++++++++++++++++++--- test/patch.test.js | 24 ++++++++++++++++++ 5 files changed, 117 insertions(+), 19 deletions(-) diff --git a/Assets/assistent.bundle.js b/Assets/assistent.bundle.js index bd1ace2..21627ab 100644 --- a/Assets/assistent.bundle.js +++ b/Assets/assistent.bundle.js @@ -138,6 +138,7 @@ } } var FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi; + var OPEN_FENCE_RE = /```(?:json)?/i; function has(obj, key) { return obj[key] !== void 0 && obj[key] !== null; } @@ -194,6 +195,26 @@ } return null; } + function stripFenceArtifacts(text) { + let t = String(text || "").replace(/\r\n/g, "\n"); + t = t.replace(/```(?:json)?\s*[\s\S]*?```/gi, ""); + t = t.replace(/```(?:json)?\s*[\s\S]*$/gi, ""); + t = t.replace(/(?:^|\n)\s*```(?:json)?\s*(?=\n|$)/gi, "\n"); + t = t.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, "\n"); + t = t.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, "\n"); + return t.replace(/\n{3,}/g, "\n\n").trim(); + } + function liveStreamProse(text) { + const t = String(text || ""); + const open = t.search(OPEN_FENCE_RE); + if (open >= 0) { + const tail = t.slice(open); + if (!/```(?:json)?\s*[\s\S]*?```/i.test(tail)) { + return stripFenceArtifacts(t.slice(0, open)); + } + } + return stripFenceArtifacts(visibleProse(t) || t); + } function extractPatch(text) { if (!text) { return { prose: text || "", patch: null }; @@ -224,24 +245,24 @@ if (chosen) { const idx = lastTerminal ? lastTermIndex : lastAnyIndex; const len = lastTerminal ? lastTermLen : lastAnyLen; - const prose = (text.slice(0, idx) + text.slice(idx + len)).trim(); + const prose = stripFenceArtifacts(text.slice(0, idx) + text.slice(idx + len)); return { prose, patch: chosen }; } const brace = text.lastIndexOf("{"); if (brace >= 0) { const parsed = tryParsePatchJson(text.slice(brace)); if (parsed) { - return { prose: text.slice(0, brace).trim(), patch: parsed }; + return { prose: stripFenceArtifacts(text.slice(0, brace)), patch: parsed }; } } - return { prose: text, patch: null }; + return { prose: stripFenceArtifacts(text), patch: null }; } function visibleProse(text) { const { prose, patch } = extractPatch(text); if (patch) { return prose || ""; } - return String(text || ""); + return stripFenceArtifacts(text); } function isTerminalStreamPatch(obj) { if (!obj || typeof obj !== "object") { @@ -279,6 +300,8 @@ SA2.extractPatch = extractPatch; SA2.generateFlagOn = generateFlagOn2; SA2.visibleProse = visibleProse; + SA2.stripFenceArtifacts = stripFenceArtifacts; + SA2.liveStreamProse = liveStreamProse; } // src/persist.js @@ -1975,12 +1998,23 @@ t = t.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1$2"); return t; } - function formatAssistantProseHtml(raw) { + function stripChatFenceArtifacts(raw) { + if (window.SA && typeof SA.stripFenceArtifacts === "function") { + return SA.stripFenceArtifacts(raw); + } let text = String(raw || "").replace(/\r\n/g, "\n"); - text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, "\n"); - text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, "\n"); text = text.replace(/```(?:json)?\s*[\s\S]*?```/gi, ""); - text = text.replace(/\n{3,}/g, "\n\n").trim(); + text = text.replace(/```(?:json)?\s*[\s\S]*$/gi, ""); + return text.replace(/\n{3,}/g, "\n\n").trim(); + } + function liveAssistantText(raw) { + if (window.SA && typeof SA.liveStreamProse === "function") { + return SA.liveStreamProse(raw); + } + return stripChatFenceArtifacts(raw) || String(raw || ""); + } + function formatAssistantProseHtml(raw) { + let text = stripChatFenceArtifacts(raw); if (!text) { return ""; } @@ -2039,7 +2073,7 @@ if (live) { body.classList.add("sa-prose", "sa-prose-live"); body.classList.remove("sa-prose-rich"); - body.textContent = raw; + body.textContent = liveAssistantText(raw); return; } body.classList.add("sa-prose", "sa-prose-rich"); diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 1937c87..6c1d4df 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.15.13"; + Version = "0.15.14"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"]; } diff --git a/src/app.js b/src/app.js index b13a234..da63b7c 100644 --- a/src/app.js +++ b/src/app.js @@ -689,12 +689,25 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } } /** Lightweight chat prose: ### Critique → «Критика», lists, bold — not a full markdown engine. */ - function formatAssistantProseHtml(raw) { + function stripChatFenceArtifacts(raw) { + if (window.SA && typeof SA.stripFenceArtifacts === 'function') { + return SA.stripFenceArtifacts(raw); + } let text = String(raw || '').replace(/\r\n/g, '\n'); - text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n'); - text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n'); text = text.replace(/```(?:json)?\s*[\s\S]*?```/gi, ''); - text = text.replace(/\n{3,}/g, '\n\n').trim(); + text = text.replace(/```(?:json)?\s*[\s\S]*$/gi, ''); + return text.replace(/\n{3,}/g, '\n\n').trim(); + } + + function liveAssistantText(raw) { + if (window.SA && typeof SA.liveStreamProse === 'function') { + return SA.liveStreamProse(raw); + } + return stripChatFenceArtifacts(raw) || String(raw || ''); + } + + function formatAssistantProseHtml(raw) { + let text = stripChatFenceArtifacts(raw); if (!text) { return ''; } @@ -755,7 +768,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } if (live) { body.classList.add('sa-prose', 'sa-prose-live'); body.classList.remove('sa-prose-rich'); - body.textContent = raw; + body.textContent = liveAssistantText(raw); return; } body.classList.add('sa-prose', 'sa-prose-rich'); diff --git a/src/patch.js b/src/patch.js index d09e38c..27dd758 100644 --- a/src/patch.js +++ b/src/patch.js @@ -25,6 +25,7 @@ export function getPatchKeys() { } const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi; +const OPEN_FENCE_RE = /```(?:json)?/i; function has(obj, key) { return obj[key] !== undefined && obj[key] !== null; @@ -87,6 +88,30 @@ function tryParsePatchJson(raw) { return null; } +/** Remove fenced blocks and orphan ```json openers left when JSON was parsed unfenced. */ +export function stripFenceArtifacts(text) { + let t = String(text || '').replace(/\r\n/g, '\n'); + t = t.replace(/```(?:json)?\s*[\s\S]*?```/gi, ''); + t = t.replace(/```(?:json)?\s*[\s\S]*$/gi, ''); + t = t.replace(/(?:^|\n)\s*```(?:json)?\s*(?=\n|$)/gi, '\n'); + t = t.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n'); + t = t.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n'); + return t.replace(/\n{3,}/g, '\n\n').trim(); +} + +/** While streaming: hide an unclosed fence tail so ```json does not leak into the bubble. */ +export function liveStreamProse(text) { + const t = String(text || ''); + const open = t.search(OPEN_FENCE_RE); + if (open >= 0) { + const tail = t.slice(open); + if (!/```(?:json)?\s*[\s\S]*?```/i.test(tail)) { + return stripFenceArtifacts(t.slice(0, open)); + } + } + return stripFenceArtifacts(visibleProse(t) || t); +} + export function extractPatch(text) { if (!text) { return { prose: text || '', patch: null }; @@ -117,7 +142,7 @@ export function extractPatch(text) { if (chosen) { const idx = lastTerminal ? lastTermIndex : lastAnyIndex; const len = lastTerminal ? lastTermLen : lastAnyLen; - const prose = (text.slice(0, idx) + text.slice(idx + len)).trim(); + const prose = stripFenceArtifacts(text.slice(0, idx) + text.slice(idx + len)); return { prose, patch: chosen }; } // Unfenced trailing object — some turns emit raw {prompt, generate:true}. @@ -125,10 +150,10 @@ export function extractPatch(text) { if (brace >= 0) { const parsed = tryParsePatchJson(text.slice(brace)); if (parsed) { - return { prose: text.slice(0, brace).trim(), patch: parsed }; + return { prose: stripFenceArtifacts(text.slice(0, brace)), patch: parsed }; } } - return { prose: text, patch: null }; + return { prose: stripFenceArtifacts(text), patch: null }; } /** Chat body text: never fall back to the raw fence when a patch was extracted. */ @@ -137,7 +162,7 @@ export function visibleProse(text) { if (patch) { return prose || ''; } - return String(text || ''); + return stripFenceArtifacts(text); } export function isTerminalStreamPatch(obj) { @@ -180,4 +205,6 @@ export function attachPatch(SA) { SA.extractPatch = extractPatch; SA.generateFlagOn = generateFlagOn; SA.visibleProse = visibleProse; + SA.stripFenceArtifacts = stripFenceArtifacts; + SA.liveStreamProse = liveStreamProse; } diff --git a/test/patch.test.js b/test/patch.test.js index 9a38638..1319e53 100644 --- a/test/patch.test.js +++ b/test/patch.test.js @@ -3,8 +3,10 @@ import assert from 'node:assert/strict'; import { extractPatch, isPatchObject, + liveStreamProse, normalizePatch, setPatchKeys, + stripFenceArtifacts, visibleProse, } from '../src/patch.js'; import { @@ -63,6 +65,28 @@ describe('patch.js', () => { assert.equal(visibleProse('просто чат без патча'), 'просто чат без патча'); }); + it('extractPatch strips orphan ```json when JSON is unfenced', () => { + const text = 'О, круто\n\n```json\n{"prompt":"girl on leather sofa, flirt","generate":true}'; + const { prose, patch } = extractPatch(text); + assert.ok(patch); + assert.equal(patch.generate, true); + assert.equal(prose, 'О, круто'); + assert.ok(!prose.includes('```')); + }); + + it('liveStreamProse hides unclosed fence while streaming', () => { + const partial = 'О, круто\n\n```json\n{"prompt":"girl'; + assert.equal(liveStreamProse(partial), 'О, круто'); + assert.equal( + liveStreamProse('Ок\n```json\n{"prompt":"fox","generate":true}\n```'), + 'Ок', + ); + }); + + it('stripFenceArtifacts removes lone JSON Patch headers', () => { + assert.equal(stripFenceArtifacts('### JSON Patch\n'), ''); + }); + it('scheduler-only patch is detected with full key list', () => { setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']); assert.equal(isPatchObject({ scheduler: 'euler' }), true);