Merge branch 'master' into fix/6010-cache-providers
This commit is contained in:
7 files changed
+984
-119
No files matched your search
@@ -28,6 +28,12 @@ copilot-instructions.md
|
||||
# Generated screenshots and transient artifacts
|
||||
screenshot-*.png
|
||||
full-UI.png
|
||||
# Playwright MCP scratch — directory + ad-hoc PNGs the agent drops while
|
||||
# debugging browser sessions. /final-check.png is the convention used by the
|
||||
# local agent loop for a one-off end-of-task verification grab; ignore
|
||||
# either way so they never sneak into a commit.
|
||||
.playwright-mcp/
|
||||
final-check.png
|
||||
|
||||
# Version file written by Docker/CI build — generated, never committed
|
||||
api/_version.py
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Images and media now render inline *as the response streams*, not just after it finishes.** When the assistant emits a `MEDIA:` token (generated image, video, audio, PDF, diff, CSV, Excalidraw, etc.) mid-turn, it now appears inline in real time during streaming instead of only materializing once the turn settles — including markers that arrive split across streaming chunks. Media routes through the same escaped `/api/media` path (no XSS surface: `javascript:`/`data:`/`file:` neutralized, SVG stays an `<img>`), remote `.webm` correctly renders as video, and normal prose streaming (including the fade effect) is unchanged when no media is present. Thanks @silent-reader-cn. (#5802)
|
||||
|
||||
- **Docker / no-git installs now get an update notice.** Container and other non-git WebUI installs previously had no way to see that a newer release existed (the in-app updater only works on git checkouts). The Settings update panel now shows an informational notice when the running image trails the latest published release — e.g. "WebUI (v0.52.30 → v0.52.43): 13 releases available · Manual update required: run `docker pull …`, then recreate the container." It is informational only: the Force-update and Clear-lock actions are disabled/hidden for these installs and the notice can never trigger a git reset. Prerelease tags are ignored so stable installs only see stable releases. Thanks @rodboev. (#5733, #2729)
|
||||
|
||||
- **Reverse-proxy SSO via trusted identity headers (opt-in, off by default).** You can now put the WebUI behind a reverse proxy you run (Authelia, oauth2-proxy, corporate SSO, …) that authenticates the user and forwards their identity in a header. Set `HERMES_WEBUI_TRUSTED_AUTH_HEADER` (e.g. `Remote-User`) plus `HERMES_WEBUI_TRUSTED_PROXY_CIDRS` with the **exact** IP(s) of your proxy — the header is honored *only* when the request's un-spoofable socket peer is an allowlisted proxy, so a direct client can't forge it. Optional group→profile mapping (`HERMES_WEBUI_GROUP_PROFILE_MAP`, JSON) and SSO single-logout URL are supported. Fails closed on empty/malformed config; existing password/passkey auth is unchanged when this is not configured. See `.env.example` for the full setup and the security warning. Thanks @rodboev. (#5568, #3351)
|
||||
|
||||
+278
-5
@@ -3720,7 +3720,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
try{
|
||||
let st=_anchorProseSmdCache.get(key);
|
||||
// Self-heal desyncs (edit/sanitize made the text no longer a pure append):
|
||||
// rebuild the parser+node from scratch, mirroring _smdWrite's guard.
|
||||
// rebuild the parser+node from scratch, mirroring the _smdWrite guard.
|
||||
if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null;
|
||||
if(st && st.fade!==fade) st=null;
|
||||
if(!st){
|
||||
@@ -3734,6 +3734,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body);
|
||||
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
|
||||
st={node,parser:window.smd.parser(renderer),writtenText:'',fade};
|
||||
_smdBindParserIdentity(renderer,st.parser,body);
|
||||
_anchorProseSmdCache.set(key,st);
|
||||
// Bound memory across turns: keys embed the stream id, so stale entries
|
||||
// from finished streams age out here.
|
||||
@@ -3759,6 +3760,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
window.__anchorProseIncrementalNode=_anchorProseIncrementalNode;
|
||||
function _clearAnchorProseIncrementalNode(){
|
||||
if(typeof window!=='undefined'&&window.__anchorProseIncrementalNode===_anchorProseIncrementalNode) window.__anchorProseIncrementalNode=null;
|
||||
// Clear the per-parser MEDIA tail for each cached smd parser.
|
||||
// _anchorProseSmdCache is a Map<key, {parser, ...}>; we can't
|
||||
// iterate a WeakMap to clean up, but WeakMap keys become eligible
|
||||
// for GC once the parser objects are released by the cache clear
|
||||
// below, so the WeakMap entries are automatically removed. The
|
||||
// explicit _smdMediaTailClear per-parser is a best-effort guard
|
||||
// for cache entries that may hold the last strong reference.
|
||||
if(typeof _anchorProseSmdCache!=='undefined'&&_anchorProseSmdCache.size){
|
||||
_anchorProseSmdCache.forEach(function(st){
|
||||
if(st&&st.parser&&typeof _smdMediaTailFlush==='function'){
|
||||
_smdMediaTailFlush(st.parser);
|
||||
}
|
||||
if(st&&st.parser&&typeof _smdMediaTailClear==='function'){
|
||||
_smdMediaTailClear(st.parser);
|
||||
}
|
||||
});
|
||||
}
|
||||
_anchorProseSmdCache.clear();
|
||||
}
|
||||
function _anchorHasReasoningEvents(){
|
||||
@@ -4038,6 +4056,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const baseRenderer=fade ? _streamFadeRenderer(el) : _safeSmdRenderer(el);
|
||||
const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);
|
||||
_smdParser=window.smd.parser(renderer);
|
||||
_smdBindParserIdentity(renderer,_smdParser,el);
|
||||
}
|
||||
function _smdRendererWithoutUnderscoreEmphasis(renderer){
|
||||
if(!renderer||!window.smd) return renderer;
|
||||
@@ -4070,13 +4089,24 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_streamingKatexTimer){clearTimeout(_streamingKatexTimer);_streamingKatexTimer=null;}
|
||||
if(_smdParser&&window.smd){
|
||||
try{window.smd.parser_end(_smdParser);}catch(_){}
|
||||
// parser_end may flush remaining markdown that creates new links/images —
|
||||
// re-sanitize the body before the DOM is handed off to highlightCode / renderMessages.
|
||||
if(assistantBody){_sanitizeSmdLinks(assistantBody);enhanceMarkdownTables(assistantBody);}
|
||||
}
|
||||
// parser_end may emit one final add_text chunk; flush MEDIA tails after it
|
||||
// so a final extensionless URL is rendered before the settled re-render.
|
||||
if(typeof _smdMediaTailFlush==='function') _smdMediaTailFlush(_smdParser);
|
||||
if(typeof _smdMediaTailFlush==='function') _smdMediaTailFlush(__SMD_PARSER_FALLBACK);
|
||||
// parser_end / tail flush may create new links/images — re-sanitize the
|
||||
// body before the DOM is handed off to highlightCode / renderMessages.
|
||||
if(assistantBody){_sanitizeSmdLinks(assistantBody);enhanceMarkdownTables(assistantBody);}
|
||||
// Clear the per-parser MEDIA tail buffer — any incomplete MEDIA
|
||||
// prefix the parser was holding is no longer relevant.
|
||||
if(typeof _smdMediaTailClear==='function') _smdMediaTailClear(_smdParser);
|
||||
if(typeof _smdClearParserIdentity==='function') _smdClearParserIdentity(assistantBody,_smdParser);
|
||||
_smdParser=null;
|
||||
_smdWrittenLen=0;
|
||||
_smdWrittenText='';
|
||||
// Clear the fallback MEDIA tail buffer too; fallback chunks are keyed
|
||||
// by __SMD_PARSER_FALLBACK, not null.
|
||||
if(typeof _smdMediaTailClear==='function') _smdMediaTailClear(__SMD_PARSER_FALLBACK);
|
||||
}
|
||||
function _scheduleStreamingKatex(){
|
||||
if(_streamingKatexTimer) return;
|
||||
@@ -4228,12 +4258,33 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const renderer=window.smd.default_renderer(el);
|
||||
const baseAddText=renderer.add_text;
|
||||
const baseSetAttr=renderer.set_attr;
|
||||
const parserFor = (data)=>{
|
||||
return _smdParserKey(data, el);
|
||||
};
|
||||
const writeFadeText=(writeParent, writeData, writeText)=>{
|
||||
if(!writeParent||_streamFadeSkipNode(writeParent)){
|
||||
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
|
||||
return;
|
||||
}
|
||||
_streamFadeAppendText(writeParent, writeText);
|
||||
};
|
||||
renderer.add_text=(data,text)=>{
|
||||
const parent=data&&data.nodes&&data.nodes[data.index];
|
||||
if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
|
||||
// MEDIA-in-stream: if this chunk carries a MEDIA:<ref> token, defer to
|
||||
// the shared interceptor so the token becomes a real media element
|
||||
// instead of plain text. The fade renderer would otherwise wrap every
|
||||
// word in a stream-fade-word span, leaving MEDIA: paths visible.
|
||||
const parser=parserFor(data);
|
||||
const hasMediaTail=!!(_SMD_MEDIA_TAIL&&parser&&_SMD_MEDIA_TAIL.has&&_SMD_MEDIA_TAIL.has(parser));
|
||||
const value=String(text||'');
|
||||
const hasMediaPrefixTail=!!_smdMediaPrefixTail(value);
|
||||
if(/MEDIA:/.test(value)||hasMediaTail||hasMediaPrefixTail){
|
||||
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText);
|
||||
return;
|
||||
}
|
||||
const frag=document.createDocumentFragment();
|
||||
const wordRe=/(\S+)(\s*)/g;
|
||||
const value=String(text||'');
|
||||
const reduceMotion=_streamFadeReduceMotionEnabled();
|
||||
const appendStartedAt=performance.now();
|
||||
let last=0, match, changed=false;
|
||||
@@ -4288,9 +4339,231 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// DOM nodes as plain text nodes (no animation spans). Used on the non-fade
|
||||
// streaming path to eliminate _sanitizeSmdLinks(assistantBody) O(DOM) scans
|
||||
// on every token event (#WebUI-perf).
|
||||
// MEDIA-in-stream fix: also wraps add_text so MEDIA:<ref> tokens that arrive
|
||||
// mid-turn are converted to inline media elements at insert time, matching
|
||||
// what the full renderMd() pipeline does on the settled assistant message.
|
||||
// Without this, streamed prose shows MEDIA:C:\... as literal text until the
|
||||
// turn settles and the full re-render swaps it for the real <img>.
|
||||
// SAFETY & CROSS-CHUNK SPLITS (Greptile #1 + #2):
|
||||
// 1. Prose slices go back to the owning text writer (text nodes or
|
||||
// fade spans), NOT through DOMParser — mixed prose with HTML entities /
|
||||
// malicious <img onerror> stays as literal text.
|
||||
// 2. Each MEDIA token's HTML (from _inlineMediaHtmlForRef) is handed
|
||||
// to DOMParser one at a time — only trusted markup is parsed.
|
||||
// 3. A MEDIA prefix split across smd flushes (e.g. "MEDIA:" then
|
||||
// "foo.png") is buffered in a per-parser tail buffer and completed
|
||||
// on the next add_text call.
|
||||
const _MEDIA_TAIL_MAX = 4096; // bytes; defensive cap on per-parser buffer
|
||||
const _SMD_MEDIA_PREFIX = 'MEDIA:';
|
||||
function _smdMediaPrefixTail(value){
|
||||
const text=String(value||'');
|
||||
const max=Math.min(_SMD_MEDIA_PREFIX.length,text.length);
|
||||
for(let len=max;len>0;len-=1){
|
||||
const suffix=text.slice(text.length-len);
|
||||
if(_SMD_MEDIA_PREFIX.startsWith(suffix)) return suffix;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function _smdAppendPlainText(parent, data, text, baseAddText){
|
||||
const value=String(text||'');
|
||||
if(parent&&parent.appendChild&&typeof document!=='undefined'&&document.createTextNode){
|
||||
parent.appendChild(document.createTextNode(value));
|
||||
return;
|
||||
}
|
||||
if(baseAddText) baseAddText(data,value);
|
||||
}
|
||||
function _smdMediaWriteText(parent, data, baseAddText, writeText, text){
|
||||
if(writeText){
|
||||
writeText(parent, data, String(text||''));
|
||||
return;
|
||||
}
|
||||
if(baseAddText) baseAddText(data,String(text||''));
|
||||
}
|
||||
function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data, writeText){
|
||||
if(!tailMap||!parser) return;
|
||||
if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data, writeText});
|
||||
else tailMap.delete(parser);
|
||||
}
|
||||
function _smdMediaTailEntryChunk(entry){
|
||||
return entry && typeof entry==='object' && Object.prototype.hasOwnProperty.call(entry,'chunk') ? entry.chunk : entry;
|
||||
}
|
||||
function _smdMediaTailSameOwner(entry, parent, baseAddText, writeText){
|
||||
return !!entry && entry.parent===parent && entry.baseAddText===baseAddText && entry.writeText===writeText;
|
||||
}
|
||||
function _smdMediaRefHasReliableBoundary(rawRef){
|
||||
const raw=String(rawRef||'');
|
||||
if(/[?#]$/.test(raw)) return false;
|
||||
const ref=raw.split(/[?#]/,1)[0];
|
||||
return /\.(?:png|jpe?g|gif|webp|bmp|ico|svg|avif|mp4|webm|mov|m4v|mkv|avi|ogv|mp3|wav|ogg|m4a|aac|wma|opus|flac|oga|pdf|html?|csv|diff|patch|excalidraw)$/i.test(ref);
|
||||
}
|
||||
function _smdMediaTailFlushEntry(entry){
|
||||
const chunk=_smdMediaTailEntryChunk(entry);
|
||||
if(!chunk) return;
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
|
||||
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
|
||||
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
|
||||
}
|
||||
function _smdMediaTailFlush(parser){
|
||||
if(!_SMD_MEDIA_TAIL||!parser||!_SMD_MEDIA_TAIL.get) return;
|
||||
const entry=_SMD_MEDIA_TAIL.get(parser);
|
||||
if(!entry) return;
|
||||
_SMD_MEDIA_TAIL.delete(parser);
|
||||
_smdMediaTailFlushEntry(entry);
|
||||
}
|
||||
function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser, writeText){
|
||||
const value=String(text||'');
|
||||
const tails=tailMap||(typeof _SMD_MEDIA_TAIL!=='undefined'&&_SMD_MEDIA_TAIL)||null;
|
||||
const writeCurrent=(chunk)=>_smdMediaWriteText(parent, data, baseAddText, writeText, chunk);
|
||||
if(!value){
|
||||
writeCurrent('');
|
||||
return;
|
||||
}
|
||||
// Pull any pending tail from a previous (split) chunk, then clear it;
|
||||
// this call will either complete it, re-buffer it, or flush it as text.
|
||||
let leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
|
||||
let lead = _smdMediaTailEntryChunk(leadEntry);
|
||||
if(lead && !_smdMediaTailSameOwner(leadEntry, parent, baseAddText, writeText)){
|
||||
if(tails && parser && tails.delete) tails.delete(parser);
|
||||
_smdMediaTailFlushEntry(leadEntry);
|
||||
leadEntry=null;
|
||||
lead='';
|
||||
}else if(lead && tails && parser && tails.delete){
|
||||
tails.delete(parser);
|
||||
}
|
||||
const combined = lead ? lead + value : value;
|
||||
// Fast path: no MEDIA tokens in the (possibly combined) string.
|
||||
if(!/MEDIA:/.test(combined)){
|
||||
const prefixTail=_smdMediaPrefixTail(combined);
|
||||
if(prefixTail && tails && parser && prefixTail.length < _MEDIA_TAIL_MAX){
|
||||
const stable=combined.slice(0, combined.length-prefixTail.length);
|
||||
if(stable) writeCurrent(stable);
|
||||
_smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data, writeText);
|
||||
return;
|
||||
}
|
||||
writeCurrent(combined);
|
||||
return;
|
||||
}
|
||||
// Walk the combined string, slicing into prose + MEDIA token runs.
|
||||
// Prose runs go through the owning text writer. MEDIA tokens go through
|
||||
// the single-token DOMParser helper only after a delimiter or
|
||||
// reliable filename suffix proves the ref is complete.
|
||||
const re=/MEDIA:([^\s\)\]]+)/g;
|
||||
let last=0, m;
|
||||
let unmatchedTail=null;
|
||||
while((m=re.exec(combined))){
|
||||
const matchEnd = m.index + m[0].length;
|
||||
if(m.index>last){
|
||||
const slice = combined.slice(last, m.index);
|
||||
writeCurrent(slice);
|
||||
}
|
||||
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
|
||||
const candidate = combined.slice(m.index);
|
||||
if(candidate.length < _MEDIA_TAIL_MAX){
|
||||
unmatchedTail = candidate;
|
||||
} else {
|
||||
writeCurrent(candidate);
|
||||
}
|
||||
last = combined.length;
|
||||
break;
|
||||
}
|
||||
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
|
||||
last = matchEnd;
|
||||
}
|
||||
// Tail buffer — hold trailing bytes that look like an unterminated
|
||||
// MEDIA prefix; flush any prose before the partial MEDIA suffix.
|
||||
const rest = combined.slice(last);
|
||||
if(rest){
|
||||
const tailMatch = /MEDIA:[^\s\)\]]*$/.exec(rest);
|
||||
const prefixTail = tailMatch ? '' : _smdMediaPrefixTail(rest);
|
||||
const tailValue = tailMatch ? tailMatch[0] : prefixTail;
|
||||
if(tailValue && rest.length < _MEDIA_TAIL_MAX){
|
||||
const tailStart = tailMatch ? tailMatch.index : rest.length-prefixTail.length;
|
||||
if(tailStart>0) writeCurrent(rest.slice(0, tailStart));
|
||||
unmatchedTail = tailValue;
|
||||
} else {
|
||||
writeCurrent(rest);
|
||||
}
|
||||
}
|
||||
if(tails && parser){
|
||||
_smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data, writeText);
|
||||
}
|
||||
}
|
||||
// Single-token DOM splice. Only ever fed the output of
|
||||
// _inlineMediaHtmlForRef (trusted markup fragment). Plain text
|
||||
// goes through baseAddText → createTextNode — NEVER here.
|
||||
function _smdAppendMediaNode(parent, rawRef){
|
||||
if(!parent||!rawRef) return false;
|
||||
const mediaHtml = (typeof _inlineMediaHtmlForRef==='function')
|
||||
? _inlineMediaHtmlForRef(String(rawRef))
|
||||
: '';
|
||||
if(!mediaHtml) return false;
|
||||
let host=null;
|
||||
try{
|
||||
const doc=new DOMParser().parseFromString('<div>'+mediaHtml+'</div>','text/html');
|
||||
host=doc.body&&doc.body.firstChild;
|
||||
}catch(_){ host=null; }
|
||||
if(!host||!host.childNodes||!host.childNodes.length) return false;
|
||||
const frag=document.createDocumentFragment();
|
||||
while(host.firstChild) frag.appendChild(host.firstChild);
|
||||
parent.appendChild(frag);
|
||||
_smdScheduleMediaPostProcess(parent);
|
||||
return true;
|
||||
}
|
||||
function _smdScheduleMediaPostProcess(root){
|
||||
if(!root) return;
|
||||
if(typeof _postProcessWithAnchorSuppression!=='function'
|
||||
&& typeof postProcessRenderedMessages!=='function'
|
||||
&& typeof _applyMediaPlaybackPreferences!=='function') return;
|
||||
const run=()=>{
|
||||
try{
|
||||
if(typeof _postProcessWithAnchorSuppression==='function') _postProcessWithAnchorSuppression(root);
|
||||
else if(typeof postProcessRenderedMessages==='function') postProcessRenderedMessages(root);
|
||||
if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(root);
|
||||
}catch(_){}
|
||||
};
|
||||
if(typeof requestAnimationFrame==='function') requestAnimationFrame(run);
|
||||
else if(typeof setTimeout==='function') setTimeout(run,0);
|
||||
else run();
|
||||
}
|
||||
// Per-parser tail buffer keyed by parser instance so concurrent
|
||||
// smd parsers (live prose + anchor-scene rows + tool-card streams)
|
||||
// keep their own pending bytes. Cleared inside _smdEndParser /
|
||||
// _clearAnchorProseIncrementalNode on stream end.
|
||||
const _SMD_MEDIA_TAIL = (typeof WeakMap!=='undefined') ? new WeakMap() : new Map();
|
||||
// Sentinel for parserFor fallback — a dedicated object instead of
|
||||
// a string, so WeakMap.set doesn't throw TypeError when all three
|
||||
// parser-identity sources are unavailable (Greptile #3).
|
||||
const __SMD_PARSER_FALLBACK = {};
|
||||
function _smdParserKey(data, el){
|
||||
return (data && data.parser) || (el && el.__smdParser) || __SMD_PARSER_FALLBACK;
|
||||
}
|
||||
function _smdBindParserIdentity(renderer, parser, el){
|
||||
if(renderer&&renderer.data) renderer.data.parser=parser;
|
||||
if(el) el.__smdParser=parser;
|
||||
}
|
||||
function _smdClearParserIdentity(el, parser){
|
||||
if(!el || (parser && el.__smdParser!==parser)) return;
|
||||
try{delete el.__smdParser;}catch(_){el.__smdParser=null;}
|
||||
}
|
||||
function _smdMediaTailClear(parser){
|
||||
if(_SMD_MEDIA_TAIL && parser) _SMD_MEDIA_TAIL.delete(parser);
|
||||
// Also clear the fallback key if it was ever set
|
||||
if(_SMD_MEDIA_TAIL && parser === __SMD_PARSER_FALLBACK) _SMD_MEDIA_TAIL.delete(parser);
|
||||
}
|
||||
function _safeSmdRenderer(el){
|
||||
const renderer=window.smd.default_renderer(el);
|
||||
const baseSetAttr=renderer.set_attr;
|
||||
const baseAddText=renderer.add_text;
|
||||
const writePlainText=(writeParent, writeData, writeText)=>{
|
||||
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
|
||||
};
|
||||
const parserFor = (data)=>{
|
||||
return _smdParserKey(data, el);
|
||||
};
|
||||
renderer.add_text=(data,text)=>{
|
||||
const parent=data&&data.nodes&&data.nodes[data.index];
|
||||
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data), writePlainText);
|
||||
};
|
||||
renderer.set_attr=(data,attr,value)=>{
|
||||
const isHref=window.smd&&attr===window.smd.HREF;
|
||||
const isSrc=window.smd&&attr===window.smd.SRC;
|
||||
|
||||
+77
-110
@@ -2468,8 +2468,8 @@ function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
|
||||
}
|
||||
function _mediaKindForName(name=''){
|
||||
const clean=String(name||'').split('?')[0].toLowerCase();
|
||||
if(_AUDIO_EXTS.test(clean)) return 'audio';
|
||||
if(_VIDEO_EXTS.test(clean)) return 'video';
|
||||
if(_AUDIO_EXTS.test(clean)) return 'audio';
|
||||
if(_IMAGE_EXTS.test(clean)) return 'image';
|
||||
return '';
|
||||
}
|
||||
@@ -2486,6 +2486,81 @@ function _mediaPlayerHtml(kind, src, name, extra=''){
|
||||
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
||||
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span>${extra}</div>${_mediaSpeedControlsHtml(kind,safeName)}</div>`;
|
||||
}
|
||||
// Shared MEDIA: token renderer used by both the full-pipeline renderMd() and
|
||||
// the streaming smd path in messages.js. Centralised so the live + settled
|
||||
// representations of the same MEDIA token stay byte-identical, otherwise the
|
||||
// streamed prose loses its image when the answer settles (#MEDIA-in-stream).
|
||||
// `sessionId` is forwarded into /api/media so the same allow-list check applies
|
||||
// to streamed references too; falls back to whatever the current session is.
|
||||
function _inlineMediaHtmlForRef(ref, sessionId){
|
||||
if(ref==null) return '';
|
||||
// Keep this logic self-contained: some tests extract renderMd() alone and
|
||||
// execute it in node, without the top-level helper functions from ui.js.
|
||||
// Tests look for `new URL(ref)` / `u.pathname` / `api/media?path=` patterns,
|
||||
// so the variable name is the original `ref` (not `r`) and the file://
|
||||
// unwrap keeps the matched identifier visible.
|
||||
if(/^file:\/\//i.test(ref)){
|
||||
try{
|
||||
const u=new URL(ref);
|
||||
ref=decodeURIComponent(u.pathname||ref.replace(/^file:\/\//i,''));
|
||||
}catch(_){
|
||||
try{ref=decodeURIComponent(ref.replace(/^file:\/\//i,''));}
|
||||
catch(__){ref=ref.replace(/^file:\/\//i,'');}
|
||||
}
|
||||
}
|
||||
if(/^https?:\/\//i.test(ref)){
|
||||
let src=ref;
|
||||
if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){
|
||||
const base=(typeof document!=='undefined'&&document.baseURI||'').replace(/\/$/,'');
|
||||
src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base);
|
||||
}
|
||||
const urlPath=src.split('?')[0];
|
||||
// SVG URLs → render inline as image (must precede the https:// <img>
|
||||
// catch-all below so extensionless CDN SVG paths still match)
|
||||
if(_SVG_EXTS.test(urlPath)){
|
||||
return `<img class="msg-media-svg" src="${esc(src)}" alt="${esc(typeof t==='function'?t('media_svg_label'):'svg')}" loading="lazy">`;
|
||||
}
|
||||
const mediaKind=_mediaKindForName(urlPath);
|
||||
if(mediaKind==='audio'||mediaKind==='video') return _mediaPlayerHtml(mediaKind,src,urlPath.split('/').pop()||mediaKind);
|
||||
// Render all https:// URLs as <img> — extensionless CDN paths like fal.media still work (#853)
|
||||
if(_IMAGE_EXTS.test(urlPath) || /^https?:\/\//i.test(src)){
|
||||
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy">`;
|
||||
}
|
||||
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
|
||||
}
|
||||
// Local file path — route through /api/media so the session allow-list check
|
||||
// (api/routes.py _resolve_media_path) gates the access the same way it does
|
||||
// for the full-pipeline renderer.
|
||||
const sid=sessionId
|
||||
|| (typeof S!=='undefined'&&S&&S.session&&S.session.session_id?String(S.session.session_id):'')
|
||||
|| '';
|
||||
const apiUrl='api/media?path='+encodeURIComponent(ref)+(sid?'&session_id='+encodeURIComponent(sid):'');
|
||||
const localKind=_mediaKindForName(ref);
|
||||
// localArtifactCard(...)
|
||||
if(localKind==='image'){
|
||||
const safeName=esc(ref.split('/').pop()||'image');
|
||||
const tt=(typeof t==='function')?t:(key=>({media_download:'Download'}[key]||key));
|
||||
const dlLabel=esc(tt('media_download'));
|
||||
const dlSvg='<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
||||
return `<span class="msg-artifact-image"><img class="msg-media-img" src="${esc(apiUrl)}" alt="${safeName}" loading="lazy"><a class="msg-artifact-download" href="${esc(apiUrl)}" download="${safeName}" title="${dlLabel}" aria-label="${dlLabel}" onclick="event.stopPropagation()">${dlSvg}</a></span>`;
|
||||
}
|
||||
if(_SVG_EXTS.test(ref)) return `<img class="msg-media-svg" src="${esc(apiUrl)}" alt="${esc(typeof t==='function'?t('media_svg_label'):'svg')}" loading="lazy">`;
|
||||
if(localKind==='audio'||localKind==='video'){
|
||||
return _mediaPlayerHtml(localKind,apiUrl+'&inline=1',ref.split('/').pop()||ref);
|
||||
}
|
||||
if(_PDF_EXTS.test(ref)){
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
return `<div class="pdf-preview-load" data-path="${esc(ref)}"><span class="pdf-preview-spinner">⏳</span> ${esc(typeof t==='function'?t('pdf_loading'):'Loading')} ${fname}...</div>`;
|
||||
}
|
||||
if(_HTML_EXTS.test(ref)){
|
||||
return `<div class="html-preview-load" data-path="${esc(ref)}"><span class="html-preview-spinner">⏳</span> ${esc(typeof t==='function'?t('html_loading'):'Loading')}...</div>`;
|
||||
}
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
if(/\.(patch|diff)$/i.test(ref)) return `<div class="diff-inline-load" data-path="${esc(ref)}">${esc(typeof t==='function'?t('diff_loading'):'Loading diff')} ${fname}...</div>`;
|
||||
if(_CSV_EXTS.test(ref)) return `<div class="csv-inline-load" data-path="${esc(ref)}">${esc(typeof t==='function'?t('csv_loading'):'Loading')} ${fname}...</div>`;
|
||||
if(_EXCALIDRAW_EXTS.test(ref)) return `<div class="excalidraw-inline-load" data-path="${esc(ref)}">${esc(typeof t==='function'?t('excalidraw_loading'):'Loading')} ${fname}...</div>`;
|
||||
return `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
|
||||
}
|
||||
function _renderAttachmentHtml(fname, url){
|
||||
const kind=_mediaKindForName(fname);
|
||||
if(kind==='image') return `<img class="msg-media-img" src="${esc(url)}" alt="${esc(fname)}" loading="lazy">`;
|
||||
@@ -7212,115 +7287,7 @@ function renderMd(raw){
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
|
||||
// ── Restore MEDIA stash → inline images or download links ─────────────────
|
||||
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
|
||||
let ref=media_stash[+i];
|
||||
// Keep this logic self-contained: some tests extract renderMd() alone and
|
||||
// execute it in node, without the top-level helper functions from ui.js.
|
||||
const mediaKindForName=(name='')=>{
|
||||
const clean=String(name||'').split('?')[0].toLowerCase();
|
||||
if(/\.(mp3|wav|m4a|aac|ogg|oga|opus|flac)$/i.test(clean)) return 'audio';
|
||||
if(/\.(mp4|mov|m4v|webm|ogv|avi|mkv)$/i.test(clean)) return 'video';
|
||||
if(_IMAGE_EXTS.test(clean)) return 'image';
|
||||
return '';
|
||||
};
|
||||
const mediaPlayerHtml=(kind,src,name)=>{
|
||||
if(typeof _mediaPlayerHtml==='function') return _mediaPlayerHtml(kind,src,name);
|
||||
const safeName=esc(name||kind||'media');
|
||||
const safeSrc=esc(src);
|
||||
const tag=kind==='video'
|
||||
? `<video class="msg-media-player msg-media-video" src="${safeSrc}" controls preload="metadata" playsinline title="${safeName}"></video>`
|
||||
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
||||
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
|
||||
};
|
||||
const localArtifactCard=(src,name)=>{
|
||||
const safeSrc=esc(src);
|
||||
const safeName=esc(name||'image');
|
||||
const tt=(typeof t==='function')?t:(key=>({media_download:'Download'}[key]||key));
|
||||
// Clean inline image (keeps the existing .msg-media-img lightbox-on-click
|
||||
// behavior) with a hover/focus-revealed Download action overlaid top-right,
|
||||
// matching the ChatGPT/Claude/Gemini pattern. The image stays the hero —
|
||||
// no permanent card chrome. Download is the one affordance the lightbox
|
||||
// (zoom-on-click) doesn't already provide.
|
||||
const dlLabel=esc(tt('media_download'));
|
||||
const dlSvg='<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
||||
return `<span class="msg-artifact-image"><img class="msg-media-img" src="${safeSrc}" alt="${safeName}" loading="lazy"><a class="msg-artifact-download" href="${safeSrc}" download="${safeName}" title="${dlLabel}" aria-label="${dlLabel}" onclick="event.stopPropagation()">${dlSvg}</a></span>`;
|
||||
};
|
||||
if(/^file:\/\//i.test(ref)){
|
||||
try{
|
||||
const u=new URL(ref);
|
||||
ref=decodeURIComponent(u.pathname||ref.replace(/^file:\/\//i,''));
|
||||
}catch(_){
|
||||
try{ref=decodeURIComponent(ref.replace(/^file:\/\//i,''));}
|
||||
catch(__){ref=ref.replace(/^file:\/\//i,'');}
|
||||
}
|
||||
}
|
||||
// HTTP(S) URL
|
||||
if(/^https?:\/\//i.test(ref)){
|
||||
// Rewrite localhost/127.0.0.1 to the actual server base URL so remote
|
||||
// users (VPN, Docker, deployed) can load agent-generated images (#642).
|
||||
// Strip the trailing slash from document.baseURI so the URL's own path
|
||||
// joins cleanly — this preserves any subpath mount (e.g. /hermes/).
|
||||
let src=ref;
|
||||
if(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i.test(src)){
|
||||
const base=(document.baseURI||'').replace(/\/$/,'');
|
||||
src=src.replace(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/i,base);
|
||||
}
|
||||
// MEDIA: tokens are usually tool-generated images. Render all https://
|
||||
// URLs as <img> so extensionless CDN paths still work (#853), while
|
||||
// preserving explicit audio/video/SVG URLs with their proper handlers.
|
||||
const urlPath=src.split('?')[0];
|
||||
const mediaKind=mediaKindForName(urlPath);
|
||||
// SVG URLs → render inline as image
|
||||
if(_SVG_EXTS.test(urlPath)){
|
||||
return `<img class="msg-media-svg" src="${esc(src)}" alt="${t('media_svg_label')}" loading="lazy">`;
|
||||
}
|
||||
if(mediaKind==='audio'||mediaKind==='video') return mediaPlayerHtml(mediaKind,src,urlPath.split('/').pop()||mediaKind);
|
||||
// Render all https:// URLs as <img> — extensionless CDN paths like fal.media still work (#853)
|
||||
if(_IMAGE_EXTS.test(urlPath) || /^https?:\/\//i.test(src)){
|
||||
return `<img class="msg-media-img" src="${esc(src)}" alt="image" loading="lazy">`;
|
||||
}
|
||||
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
|
||||
}
|
||||
// Local file path
|
||||
const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):'';
|
||||
const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
|
||||
const localKind=mediaKindForName(ref);
|
||||
if(localKind==='image'){
|
||||
return localArtifactCard(apiUrl,ref.split('/').pop()||'image');
|
||||
}
|
||||
// SVG → inline image (no download, render directly)
|
||||
if(_SVG_EXTS.test(ref)){
|
||||
return `<img class="msg-media-svg" src="${esc(apiUrl)}" alt="${t('media_svg_label')}" loading="lazy">`;
|
||||
}
|
||||
// Audio/video → inline player with speed controls; use &inline=1 for byte-range seeking
|
||||
if(_AUDIO_EXTS.test(ref)||_VIDEO_EXTS.test(ref)){
|
||||
const kind=_AUDIO_EXTS.test(ref)?'audio':'video';
|
||||
return _mediaPlayerHtml(kind,apiUrl+'&inline=1',ref.split('/').pop()||ref);
|
||||
}
|
||||
// PDF files → render first page preview with lazy-load
|
||||
if(_PDF_EXTS.test(ref)){
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
return `<div class="pdf-preview-load" data-path="${esc(ref)}"><span class="pdf-preview-spinner">⏳</span> ${t('pdf_loading')} ${fname}...</div>`;
|
||||
}
|
||||
// HTML files → render inline in sandboxed iframe with lazy-load
|
||||
if(_HTML_EXTS.test(ref)){
|
||||
return `<div class="html-preview-load" data-path="${esc(ref)}"><span class="html-preview-spinner">⏳</span> ${t('html_loading')}</div>`;
|
||||
}
|
||||
// .patch/.diff files → render inline as colored diff instead of download
|
||||
const fname=esc(ref.split('/').pop()||ref);
|
||||
if(/\.(patch|diff)$/i.test(ref)){
|
||||
return `<div class="diff-inline-load" data-path="${esc(ref)}">${t('diff_loading')} ${fname}...</div>`;
|
||||
}
|
||||
// CSV files → lazy-load and render as table
|
||||
if(_CSV_EXTS.test(ref)){
|
||||
return `<div class="csv-inline-load" data-path="${esc(ref)}">${t('csv_loading')} ${fname}...</div>`;
|
||||
}
|
||||
// Excalidraw files → lazy-load inline embed
|
||||
if(_EXCALIDRAW_EXTS.test(ref)){
|
||||
return `<div class="excalidraw-inline-load" data-path="${esc(ref)}">${t('excalidraw_loading')} ${fname}...</div>`;
|
||||
}
|
||||
return `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
|
||||
});
|
||||
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>_inlineMediaHtmlForRef(media_stash[+i]));
|
||||
|
||||
// ── End MEDIA restore ──────────────────────────────────────────────────────
|
||||
// Restore blockquote stash. Done last so the inner HTML (already produced
|
||||
|
||||
@@ -41,6 +41,20 @@ const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
|
||||
const _SVG_EXTS=/\.svg$/i;
|
||||
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
|
||||
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
|
||||
// Minimal stand-in for ui.js' _inlineMediaHtmlForRef used when the driver
|
||||
// extracts only renderMd(). Mirrors the live UI for the cases the existing
|
||||
// tests assert against (https image, bare file:// image). The full renderer
|
||||
// (ui.js) is preferred on the real page — these nodes only cover what is
|
||||
// reachable from a standalone renderMd() invocation.
|
||||
function _inlineMediaHtmlForRef(ref){
|
||||
const r = String(ref || '');
|
||||
if (/^https?:\/\//.test(r)) return `<img class="msg-media-img" src="${esc(r)}" alt="image" loading="lazy">`;
|
||||
if (/^file:\/\//.test(r)){
|
||||
const m = r.replace(/^file:\/\//i, '');
|
||||
return `<img class="msg-media-img" src="api/media?path=${encodeURIComponent(m)}" alt="image" loading="lazy">`;
|
||||
}
|
||||
return `<img class="msg-media-img" src="api/media?path=${encodeURIComponent(r)}" alt="image" loading="lazy">`;
|
||||
}
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
"""
|
||||
Tests for the MEDIA-in-stream fix: MEDIA:<ref> tokens that arrive mid-turn
|
||||
during streaming used to render as the raw path text until the turn settled
|
||||
and the full renderMd() pipeline re-rendered the row. The fix lets the smd
|
||||
streaming renderer replace MEDIA tokens with the same DOM the full pipeline
|
||||
emits, so live prose shows real images inline.
|
||||
|
||||
Static coverage (in TestSmdMediaInStream):
|
||||
1. messages.js: _smdMediaAwareAddText wrapper exists and references
|
||||
_inlineMediaHtmlForRef (the shared renderer from ui.js).
|
||||
2. messages.js: _safeSmdRenderer's add_text wraps every text chunk through
|
||||
the MEDIA-aware interceptor.
|
||||
3. messages.js: _streamFadeRenderer also short-circuits to the MEDIA-aware
|
||||
interceptor when its chunk carries a MEDIA token, instead of wrapping
|
||||
the token in a stream-fade-word span.
|
||||
4. ui.js: a single _inlineMediaHtmlForRef function is the canonical
|
||||
renderer used by BOTH renderMd() MEDIA restore and the streaming path.
|
||||
|
||||
Behavioural coverage (in TestSmdMediaAwareAddTextBehaviour): drives the
|
||||
actual JS through a minimal in-process DOM shim that supports the
|
||||
createElement / appendChild / createTextNode / DOMParser surface the
|
||||
interceptor uses. These cases answer Greptile's two confidence-sapping
|
||||
notes head-on:
|
||||
- "Mixed prose and MEDIA chunks can parse model text as DOM" — covered by
|
||||
the prose-only and prose-around-MEDIA cases (no entities ever decode
|
||||
on prose; prose enters baseAddText directly via createTextNode).
|
||||
- "MEDIA tokens split across parser flushes can still show as raw text
|
||||
during streaming" — covered by the split-MEDIA case (the tail buffer
|
||||
finishes the token on the second call).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _extract_js_function(src: str, name: str) -> str:
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
if not match:
|
||||
raise ValueError(f"Could not find JS function {name}")
|
||||
start = match.start()
|
||||
brace = src.index("{", match.end())
|
||||
depth = 1
|
||||
pos = brace + 1
|
||||
while pos < len(src) and depth:
|
||||
if src[pos] == "{":
|
||||
depth += 1
|
||||
elif src[pos] == "}":
|
||||
depth -= 1
|
||||
pos += 1
|
||||
return src[start:pos]
|
||||
|
||||
|
||||
def _run_real_smd_media_cases() -> dict:
|
||||
helpers = "\n".join(
|
||||
[
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaPrefixTail"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdAppendPlainText"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaWriteText"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailSet"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailEntryChunk"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailSameOwner"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaRefHasReliableBoundary"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlushEntry"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlush"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaAwareAddText"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdAppendMediaNode"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdScheduleMediaPostProcess"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdParserKey"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdBindParserIdentity"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailClear"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeSkipNode"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeReduceMotionEnabled"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeBindCleanup"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeAppendText"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeRenderer"),
|
||||
_extract_js_function(MESSAGES_JS, "_safeSmdRenderer"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdRendererWithoutUnderscoreEmphasis"),
|
||||
]
|
||||
)
|
||||
script = (
|
||||
"import * as smd from './static/vendor/smd.min.js';\n"
|
||||
"globalThis.window = { smd };\n"
|
||||
"globalThis.requestAnimationFrame = cb => cb();\n"
|
||||
"const _MEDIA_TAIL_MAX = 4096;\n"
|
||||
"const _SMD_MEDIA_PREFIX = 'MEDIA:';\n"
|
||||
"const _SMD_MEDIA_TAIL = new WeakMap();\n"
|
||||
"const __SMD_PARSER_FALLBACK = {};\n"
|
||||
"const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|message:|\\/|#|\\?|\\.|api|session\\/)/i;\n"
|
||||
"const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\\/|#|\\?|\\.)/i;\n"
|
||||
"const _STREAM_FADE_MS = 620;\n"
|
||||
"let _streamFadeCurrentMs = _STREAM_FADE_MS;\n"
|
||||
"let _streamFadeLatestAnimationEndAt = 0;\n"
|
||||
"let _streamFadeReduceMotionMql = null;\n"
|
||||
"let _streamFadeReduceMotion = false;\n"
|
||||
"let _streamFadeReduceMotionOnChange = null;\n"
|
||||
"let postProcessCalls = 0;\n"
|
||||
"let playbackCalls = 0;\n"
|
||||
"function _smdLinkHref(value){ return String(value || ''); }\n"
|
||||
"function _postProcessWithAnchorSuppression(root){ postProcessCalls += root.querySelectorAll('.pdf-preview-load').length; }\n"
|
||||
"function _applyMediaPlaybackPreferences(){ playbackCalls += 1; }\n"
|
||||
"function esc(value){ return String(value ?? '').replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c])); }\n"
|
||||
"function _inlineMediaHtmlForRef(ref){\n"
|
||||
" const raw = String(ref || '');\n"
|
||||
" if(/\\.pdf$/i.test(raw)) return `<div class=\"pdf-preview-load\" data-path=\"${esc(raw)}\">PDF</div>`;\n"
|
||||
" return `<span class=\"media-node\" data-ref=\"${esc(raw)}\"></span>`;\n"
|
||||
"}\n"
|
||||
"class FakeNode{\n"
|
||||
" constructor(type, tag='', text=''){\n"
|
||||
" this.nodeType=type; this.tagName=tag; this.children=[]; this.parentNode=null; this.attributes={}; this.data=text;\n"
|
||||
" this.style={ setProperty:()=>{} };\n"
|
||||
" this.classList={ contains:name=>(this.attributes.class||'').split(/\\s+/).includes(name), add:name=>this.setAttribute('class', ((this.attributes.class||'')+' '+name).trim()) };\n"
|
||||
" }\n"
|
||||
" get childNodes(){ return this.children; }\n"
|
||||
" get firstChild(){ return this.children[0] || null; }\n"
|
||||
" get parentElement(){ return this.parentNode; }\n"
|
||||
" get className(){ return this.attributes.class || ''; }\n"
|
||||
" set className(value){ this.attributes.class=String(value); }\n"
|
||||
" appendChild(child){\n"
|
||||
" if(child.nodeType===11){ while(child.firstChild) this.appendChild(child.firstChild); return child; }\n"
|
||||
" if(child.parentNode){ const old=child.parentNode.children.indexOf(child); if(old>=0) child.parentNode.children.splice(old,1); }\n"
|
||||
" child.parentNode=this; this.children.push(child); return child;\n"
|
||||
" }\n"
|
||||
" addEventListener(){}\n"
|
||||
" replaceWith(node){ if(!this.parentNode) return; const i=this.parentNode.children.indexOf(this); if(i>=0){ node.parentNode=this.parentNode; this.parentNode.children.splice(i,1,node); this.parentNode=null; } }\n"
|
||||
" setAttribute(name,value){ this.attributes[name]=String(value); }\n"
|
||||
" getAttribute(name){ return this.attributes[name] ?? null; }\n"
|
||||
" querySelectorAll(selector){\n"
|
||||
" const cls=selector.startsWith('.') ? selector.slice(1) : '';\n"
|
||||
" const out=[];\n"
|
||||
" const visit=node=>{ for(const child of node.children){ const classes=(child.attributes.class||'').split(/\\s+/); if(cls&&classes.includes(cls)) out.push(child); visit(child); } };\n"
|
||||
" visit(this); return out;\n"
|
||||
" }\n"
|
||||
" get textContent(){ return this.nodeType===3 ? this.data : this.children.map(c=>c.textContent).join(''); }\n"
|
||||
" set textContent(value){\n"
|
||||
" if(this.nodeType===3){ this.data=String(value); return; }\n"
|
||||
" this.children=[];\n"
|
||||
" const text=String(value);\n"
|
||||
" if(text) this.appendChild(document.createTextNode(text));\n"
|
||||
" }\n"
|
||||
" get outerHTML(){\n"
|
||||
" if(this.nodeType===3) return esc(this.data);\n"
|
||||
" if(this.nodeType===11) return this.children.map(c=>c.outerHTML).join('');\n"
|
||||
" const attrs=Object.entries(this.attributes).map(([k,v])=>` ${k}=\"${esc(v)}\"`).join('');\n"
|
||||
" return `<${this.tagName}${attrs}>${this.children.map(c=>c.outerHTML).join('')}</${this.tagName}>`;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"globalThis.document = {\n"
|
||||
" createElement: tag => new FakeNode(1, tag),\n"
|
||||
" createTextNode: text => new FakeNode(3, '#text', String(text)),\n"
|
||||
" createDocumentFragment: () => new FakeNode(11, '#fragment'),\n"
|
||||
"};\n"
|
||||
"globalThis.DOMParser = class { parseFromString(html){\n"
|
||||
" const host=document.createElement('div');\n"
|
||||
" const cls=html.includes('pdf-preview-load') ? 'pdf-preview-load' : 'media-node';\n"
|
||||
" const node=document.createElement(html.includes('pdf-preview-load') ? 'div' : 'span');\n"
|
||||
" node.setAttribute('class', cls);\n"
|
||||
" const ref=(html.match(/data-ref=\"([^\"]*)\"/)||html.match(/data-path=\"([^\"]*)\"/)||[])[1]||'';\n"
|
||||
" if(ref) node.setAttribute(cls==='pdf-preview-load' ? 'data-path' : 'data-ref', ref);\n"
|
||||
" host.appendChild(node);\n"
|
||||
" return { body: { firstChild: host } };\n"
|
||||
"} };\n"
|
||||
f"{helpers}\n"
|
||||
"function collectTagTexts(root, tag){\n"
|
||||
" const out=[]; const wanted=String(tag).toLowerCase();\n"
|
||||
" const visit=node=>{ for(const child of node.children){ if(String(child.tagName||'').toLowerCase()===wanted) out.push(child.textContent); visit(child); } };\n"
|
||||
" visit(root); return out;\n"
|
||||
"}\n"
|
||||
"function collectClassTexts(root, cls){ return root.querySelectorAll('.'+cls).map(node=>node.textContent); }\n"
|
||||
"function renderChunks(chunks, mode){\n"
|
||||
" postProcessCalls = 0; playbackCalls = 0;\n"
|
||||
" const root=document.createElement('div');\n"
|
||||
" const baseRenderer=mode==='fade' ? _streamFadeRenderer(root) : _safeSmdRenderer(root);\n"
|
||||
" const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer);\n"
|
||||
" const parser=smd.parser(renderer);\n"
|
||||
" _smdBindParserIdentity(renderer, parser, root);\n"
|
||||
" for(const chunk of chunks) smd.parser_write(parser, chunk);\n"
|
||||
" smd.parser_end(parser);\n"
|
||||
" _smdMediaTailFlush(parser);\n"
|
||||
" _smdMediaTailClear(parser);\n"
|
||||
" return { html: root.outerHTML, text: root.textContent, liTexts: collectTagTexts(root, 'li'), fadeWords: collectClassTexts(root, 'stream-fade-word'), postProcessCalls, playbackCalls };\n"
|
||||
"}\n"
|
||||
"function renderModes(chunks){ return { safe: renderChunks(chunks, 'safe'), fade: renderChunks(chunks, 'fade') }; }\n"
|
||||
"const marker='MEDIA:';\n"
|
||||
"const prefixSplits={};\n"
|
||||
"for(let i=1;i<marker.length;i++) prefixSplits[i]=renderModes(['\\n\\n'+marker.slice(0,i), marker.slice(i)+'C:/tmp/live.png ']);\n"
|
||||
"const refSplit=renderModes(['MEDIA:C:/tmp/li', 've.png ']);\n"
|
||||
"const finalExtensionless=renderModes(['MEDIA:https://fal.media/generated']);\n"
|
||||
"const pdf=renderModes(['MEDIA:C:/tmp/report.pdf ']);\n"
|
||||
"const falsePrefix=renderModes(['M', 'aybe plain prose ']);\n"
|
||||
"const crossParent=renderModes(['- ME', '\\n- ow']);\n"
|
||||
"console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf, falsePrefix, crossParent}));\n"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[NODE, "--input-type=module", "-e", script],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
class TestSmdMediaInStream(unittest.TestCase):
|
||||
"""Verify the streaming smd path produces real <img> for MEDIA tokens."""
|
||||
|
||||
def test_inline_media_renderer_exists_in_ui_js(self):
|
||||
self.assertIn(
|
||||
"function _inlineMediaHtmlForRef",
|
||||
UI_JS,
|
||||
"ui.js must export _inlineMediaHtmlForRef so messages.js can reuse it",
|
||||
)
|
||||
|
||||
def test_render_md_media_restore_uses_shared_renderer(self):
|
||||
# The renderMd MEDIA restore pass now delegates to the shared helper
|
||||
# instead of carrying its own copy of the URL → HTML mapping.
|
||||
marker = "_inlineMediaHtmlForRef(media_stash["
|
||||
self.assertIn(
|
||||
marker, UI_JS,
|
||||
"renderMd MEDIA restore must delegate to _inlineMediaHtmlForRef "
|
||||
"so the live + settled representations of the same MEDIA token "
|
||||
"stay byte-identical",
|
||||
)
|
||||
|
||||
def test_messages_has_smd_media_aware_wrapper(self):
|
||||
self.assertIn(
|
||||
"function _smdMediaAwareAddText",
|
||||
MESSAGES_JS,
|
||||
"messages.js must define _smdMediaAwareAddText to convert MEDIA "
|
||||
"tokens into DOM elements at smd insert time",
|
||||
)
|
||||
|
||||
def test_smd_media_aware_wrapper_invokes_shared_renderer(self):
|
||||
# The whole point of the fix: the streaming path uses the SAME renderer
|
||||
# the renderMd pipeline uses. If messages.js constructed the HTML
|
||||
# inline, the live + settled images could diverge.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 6000]
|
||||
self.assertIn(
|
||||
"_inlineMediaHtmlForRef", block,
|
||||
"_smdMediaAwareAddText must call _inlineMediaHtmlForRef to keep "
|
||||
"streaming and settled MEDIA paths byte-identical",
|
||||
)
|
||||
|
||||
def test_safe_smd_renderer_wraps_add_text_with_media_interceptor(self):
|
||||
idx = MESSAGES_JS.index("function _safeSmdRenderer")
|
||||
block = MESSAGES_JS[idx:idx + 2000]
|
||||
self.assertIn(
|
||||
"_smdMediaAwareAddText", block,
|
||||
"_safeSmdRenderer's add_text override must route text chunks "
|
||||
"through _smdMediaAwareAddText so MEDIA tokens become DOM nodes",
|
||||
)
|
||||
|
||||
def test_stream_fade_renderer_short_circuits_media_chunks(self):
|
||||
idx = MESSAGES_JS.index("function _streamFadeRenderer")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertIn(
|
||||
"_smdMediaAwareAddText", block,
|
||||
"_streamFadeRenderer's add_text override must short-circuit to "
|
||||
"_smdMediaAwareAddText when the chunk carries a MEDIA token "
|
||||
"(otherwise the token would be wrapped in a stream-fade-word "
|
||||
"span and stay visible as literal text)",
|
||||
)
|
||||
|
||||
def test_stream_fade_renderer_consumes_buffered_media_tail(self):
|
||||
# Greptile re-review: fade streaming previously only checked the
|
||||
# current chunk for /MEDIA:/. If the previous chunk buffered "MEDIA:"
|
||||
# and the next chunk was only the ref, fade wrapping rendered the path
|
||||
# as literal text instead of completing the MEDIA token.
|
||||
idx = MESSAGES_JS.index("function _streamFadeRenderer")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertIn("const parser=parserFor(data);", block)
|
||||
self.assertIn("_SMD_MEDIA_TAIL.has(parser)", block)
|
||||
self.assertIn("||hasMediaTail", block)
|
||||
self.assertIn("hasMediaPrefixTail", block)
|
||||
self.assertIn("_smdMediaPrefixTail(value)", block)
|
||||
|
||||
def test_media_interceptor_handles_token_at_chunk_start(self):
|
||||
# The smd parser can split chunks mid-text. The fix must handle MEDIA
|
||||
# tokens wherever they appear in a single add_text call, not just at
|
||||
# the boundary of the chunk.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertIn("/MEDIA:", block,
|
||||
"Interceptor must scan every chunk for MEDIA tokens")
|
||||
|
||||
def test_media_interceptor_falls_back_to_base_when_no_token(self):
|
||||
# Fast path: when the chunk + buffered tail carries no MEDIA token,
|
||||
# the wrapper should delegate to the injected text writer so the
|
||||
# owning renderer's semantics (plain text for safe mode, word fade for
|
||||
# fade mode) survive for plain prose.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertIn("const writeCurrent=", block)
|
||||
self.assertIn("writeCurrent(combined)", block)
|
||||
self.assertIn("function _smdMediaWriteText", MESSAGES_JS)
|
||||
|
||||
def test_no_recursive_infinite_loop_via_baseAddText(self):
|
||||
# Regression guard: the fade renderer's add_text is itself a wrapper.
|
||||
# If the MEDIA interceptor re-routed ALL chunks through baseAddText
|
||||
# regardless of token presence, plain prose would re-enter the fade
|
||||
# wrapper and on the next chunk also be re-processed. The fast-path
|
||||
# delegation happens only when /MEDIA:/ does NOT match.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertTrue(
|
||||
"! /MEDIA:/.test" in block.replace(" ", "") or "! /MEDIA:/.test(lead + value)" in block or "! /MEDIA:/.test(lead + value)" in block or "! /MEDIA:/.test(combined)" in block or "!/MEDIA:/.test" in block,
|
||||
"Interceptor must have an early-return fast path when the "
|
||||
"chunk lacks a MEDIA token (i.e. a `!/MEDIA:/` early bail before "
|
||||
"delegating to baseAddText)",
|
||||
)
|
||||
|
||||
def test_plain_text_does_not_go_through_dom_parser(self):
|
||||
# Greptile #1 (safety): the previous implementation concatenated
|
||||
# prose + MEDIA HTML and ran the whole string through DOMParser. That
|
||||
# meant agent-supplied prose could be parsed by the HTML parser
|
||||
# (entity-decoded / re-serialised) instead of going through a pure
|
||||
# text-node insertion. The new implementation routes plain prose
|
||||
# back to baseAddText (which uses createTextNode) and only sends
|
||||
# each MEDIA token's HTML through DOMParser.
|
||||
# The single-token DOMParser helper must exist and accept ONE ref;
|
||||
# the loop body must call baseAddText for any prose slice *before*
|
||||
# it would attempt to splice HTML.
|
||||
self.assertIn("function _smdAppendMediaNode", MESSAGES_JS)
|
||||
smd_block = MESSAGES_JS[MESSAGES_JS.index("function _smdAppendMediaNode"):MESSAGES_JS.index("function _smdAppendMediaNode")+2000]
|
||||
self.assertIn("parseFromString", smd_block)
|
||||
self.assertNotIn("parseFromString('<div>'+value+'</div>'", MESSAGES_JS,
|
||||
"Plain chunk text must never be concatenated into "
|
||||
"the DOMParser input — only the single-token "
|
||||
"mediaHtml produced by _inlineMediaHtmlForRef may "
|
||||
"be parsed.")
|
||||
|
||||
def test_cross_chunk_media_tail_buffer_exists(self):
|
||||
# Greptile #2 (cross-chunk split): when smd flushes a MEDIA token
|
||||
# in two pieces (e.g. "MEDIA:C:\\Users\\Admin" then "\\foo.png"),
|
||||
# the second half alone would not match the MEDIA regex; if we
|
||||
# only operate on each chunk independently both pieces render as
|
||||
# raw text. The new implementation keeps a per-parser tail buffer
|
||||
# for incomplete MEDIA prefixes.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 5000]
|
||||
# The interceptor must reference a module-level Map/WeakMap that
|
||||
# backstops partial MEDIA prefixes across calls.
|
||||
self.assertTrue(
|
||||
"_SMD_MEDIA_TAIL" in MESSAGES_JS or "_smdMediaTailSet" in MESSAGES_JS,
|
||||
"Interceptor must consult a per-parser tail buffer so a "
|
||||
"MEDIA:<ref> split across two smd flushes still resolves to "
|
||||
"a media element on the second call",
|
||||
)
|
||||
# And the interceptor must actually call a tail-mutating setter
|
||||
# somewhere on the trailing path, not just read.
|
||||
self.assertIn("unmatchedTail", block,
|
||||
"Interceptor must record the trailing bytes that look "
|
||||
"like an incomplete MEDIA prefix so the next add_text "
|
||||
"call can prepend them and finish the token")
|
||||
|
||||
def test_media_prefix_rolls_across_chunk_boundaries(self):
|
||||
# A split can happen inside the sentinel itself ("ME" + "DIA:foo.png"),
|
||||
# not only after the full "MEDIA:" prefix. Keep a rolling suffix scan
|
||||
# so the first half is buffered instead of rendered as visible prose.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 7000]
|
||||
self.assertIn("const _SMD_MEDIA_PREFIX = 'MEDIA:'", MESSAGES_JS)
|
||||
self.assertIn("function _smdMediaPrefixTail", MESSAGES_JS)
|
||||
self.assertIn("_smdMediaPrefixTail(combined)", block)
|
||||
self.assertIn("_smdMediaPrefixTail(rest)", block)
|
||||
self.assertIn("_SMD_MEDIA_PREFIX.startsWith(suffix)", MESSAGES_JS)
|
||||
|
||||
def test_partial_media_ref_at_chunk_end_is_buffered_until_boundary(self):
|
||||
# Greptile re-review: /MEDIA:([^\s)\]]+)/g will happily match
|
||||
# "MEDIA:fo" at the end of a chunk even if the next chunk is "o.png".
|
||||
# The interceptor must not emit a media node for that partial ref;
|
||||
# it should keep the candidate in unmatchedTail unless a delimiter or
|
||||
# reliable filename suffix proves the ref is complete.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertIn("function _smdMediaRefHasReliableBoundary", MESSAGES_JS)
|
||||
self.assertIn("matchEnd===combined.length", block)
|
||||
self.assertIn("!_smdMediaRefHasReliableBoundary(m[1])", block)
|
||||
self.assertIn("unmatchedTail = candidate", block)
|
||||
|
||||
def test_media_ref_boundary_extension_list_matches_renderer_formats(self):
|
||||
# Keep the streaming boundary whitelist aligned with ui.js media
|
||||
# renderer extension families. Otherwise complete refs at chunk end
|
||||
# (e.g. MEDIA:clip.aac) can be buffered and then dropped on stream end.
|
||||
idx = MESSAGES_JS.index("function _smdMediaRefHasReliableBoundary")
|
||||
block = MESSAGES_JS[idx:idx + 900]
|
||||
for ext in [
|
||||
"png", "jpe?g", "gif", "webp", "bmp", "ico", "svg", "avif",
|
||||
"mp4", "webm", "mov", "m4v", "mkv", "avi", "ogv",
|
||||
"mp3", "wav", "ogg", "m4a", "aac", "wma", "opus", "flac", "oga",
|
||||
"pdf", "html?", "csv", "diff", "patch", "excalidraw",
|
||||
]:
|
||||
self.assertIn(ext, block)
|
||||
|
||||
def test_extensionless_https_media_ref_is_a_reliable_boundary(self):
|
||||
# _inlineMediaHtmlForRef renders any http(s) ref as an image, including
|
||||
# extensionless CDN URLs such as fal.media generated assets. The stream
|
||||
# boundary check must therefore treat a complete http(s) ref as complete
|
||||
# even when it has no filename extension.
|
||||
self.assertIn("function _smdMediaTailFlush", MESSAGES_JS)
|
||||
self.assertIn("/^MEDIA:([^", MESSAGES_JS)
|
||||
self.assertIn("_smdMediaTailFlush(_smdParser)", MESSAGES_JS)
|
||||
|
||||
def test_extensionless_https_tail_waits_until_stream_end(self):
|
||||
# A chunk ending at MEDIA:https://fal.med may still be mid-URL. Do not
|
||||
# treat http(s) scheme alone as a reliable boundary; the stream-end
|
||||
# flush is responsible for rendering a final extensionless URL.
|
||||
idx = MESSAGES_JS.index("function _smdMediaRefHasReliableBoundary")
|
||||
block = MESSAGES_JS[idx:idx + 900]
|
||||
self.assertNotIn("/^https?:", block)
|
||||
self.assertIn("_smdMediaTailFlush", MESSAGES_JS)
|
||||
|
||||
def test_tail_buffer_size_cap(self):
|
||||
# Defensive: a runaway tail buffer from a malformed stream could
|
||||
# exhaust memory. The implementation must enforce a max length on
|
||||
# the per-parser tail.
|
||||
self.assertIn("_MEDIA_TAIL_MAX", MESSAGES_JS,
|
||||
"Tail buffer must enforce a max length to bound memory")
|
||||
|
||||
def test_per_parser_tail_isolation(self):
|
||||
# Multiple smd parsers run concurrently in the worklog + anchor
|
||||
# scene + main live body. The tail buffer must be keyed by parser
|
||||
# (not just by element) so a split MEDIA token in stream A doesn't
|
||||
# get prepended to a chunk in stream B.
|
||||
self.assertTrue(
|
||||
("parserFor" in MESSAGES_JS and "_SMD_MEDIA_TAIL.get(parser)" in MESSAGES_JS)
|
||||
or ("tails.get(parser)" in MESSAGES_JS and "parserFor" in MESSAGES_JS),
|
||||
"Tail buffer must be keyed by a stable parser identity so "
|
||||
"concurrent streams don't cross-pollinate",
|
||||
)
|
||||
|
||||
def test_tail_entries_preserve_original_text_owner(self):
|
||||
# A buffered MEDIA-looking suffix belongs to the parent/writer that
|
||||
# produced it. If the next smd add_text callback is for a different
|
||||
# parent, the scanner must flush through the original owner instead
|
||||
# of concatenating across DOM nodes.
|
||||
idx = MESSAGES_JS.index("function _smdMediaTailSet")
|
||||
block = MESSAGES_JS[idx:idx + 3500]
|
||||
self.assertIn("writeText", block)
|
||||
self.assertIn("function _smdMediaTailSameOwner", MESSAGES_JS)
|
||||
self.assertIn("entry.parent===parent", MESSAGES_JS)
|
||||
self.assertIn("entry.writeText===writeText", MESSAGES_JS)
|
||||
self.assertIn("_smdMediaTailFlushEntry(leadEntry)", MESSAGES_JS)
|
||||
|
||||
def test_stream_fade_media_scanner_preserves_plain_prose_fade(self):
|
||||
# False MEDIA-prefix tails (for example "M" + "aybe") still enter
|
||||
# the scanner. Those plain prose writes must use the non-recursive
|
||||
# fade appender, not the raw default_renderer text writer.
|
||||
idx = MESSAGES_JS.index("function _streamFadeRenderer")
|
||||
block = MESSAGES_JS[idx:idx + 7000]
|
||||
self.assertIn("const writeFadeText=", block)
|
||||
self.assertIn("_streamFadeAppendText(writeParent, writeText)", block)
|
||||
self.assertIn("_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText)", block)
|
||||
|
||||
def test_smd_parser_identity_is_bound_to_real_parser(self):
|
||||
# smd's renderer.data does not expose a parser by default. Bind the
|
||||
# created parser onto both renderer.data and the owning element so every
|
||||
# add_text path uses the same key for tail set/get/flush/clear.
|
||||
self.assertIn("function _smdParserKey", MESSAGES_JS)
|
||||
self.assertIn("function _smdBindParserIdentity", MESSAGES_JS)
|
||||
self.assertIn("renderer.data.parser=parser", MESSAGES_JS)
|
||||
self.assertIn("el.__smdParser=parser", MESSAGES_JS)
|
||||
smd_new = MESSAGES_JS[
|
||||
MESSAGES_JS.index("function _smdNewParser"):
|
||||
MESSAGES_JS.index("function _smdRendererWithoutUnderscoreEmphasis")
|
||||
].replace(" ", "")
|
||||
self.assertIn("_smdBindParserIdentity(renderer,_smdParser,el)", smd_new)
|
||||
anchor = MESSAGES_JS[
|
||||
MESSAGES_JS.index("function _anchorProseIncrementalNode"):
|
||||
MESSAGES_JS.index("function _clearAnchorProseIncrementalNode")
|
||||
].replace(" ", "")
|
||||
self.assertIn("_smdBindParserIdentity(renderer,st.parser,body)", anchor)
|
||||
self.assertNotIn("(data && data.nodes && data.nodes[data.index]) || __SMD_PARSER_FALLBACK", MESSAGES_JS)
|
||||
|
||||
def test_smd_end_parser_clears_fallback_media_tail(self):
|
||||
# Greptile re-review: parserFor falls back to __SMD_PARSER_FALLBACK,
|
||||
# so stream-end cleanup must clear that sentinel key, not null.
|
||||
idx = MESSAGES_JS.index("function _smdEndParser")
|
||||
block = MESSAGES_JS[idx:idx + 1600]
|
||||
self.assertIn("_smdMediaTailFlush(_smdParser)", block)
|
||||
self.assertIn("_smdMediaTailFlush(__SMD_PARSER_FALLBACK)", block)
|
||||
self.assertLess(block.index("parser_end"), block.index("_smdMediaTailFlush(_smdParser)"))
|
||||
self.assertIn("_smdMediaTailClear(_smdParser)", block)
|
||||
self.assertIn("_smdMediaTailClear(__SMD_PARSER_FALLBACK)", block)
|
||||
self.assertNotIn("_smdMediaTailClear(null)", block)
|
||||
|
||||
def test_anchor_prose_cleanup_flushes_media_tail_before_clear(self):
|
||||
idx = MESSAGES_JS.index("function _clearAnchorProseIncrementalNode")
|
||||
block = MESSAGES_JS[idx:idx + 1800]
|
||||
self.assertIn("_smdMediaTailFlush(st.parser)", block)
|
||||
self.assertIn("_smdMediaTailClear(st.parser)", block)
|
||||
self.assertLess(block.index("_smdMediaTailFlush(st.parser)"), block.index("_smdMediaTailClear(st.parser)"))
|
||||
|
||||
def test_live_media_insertions_are_post_processed(self):
|
||||
# Streaming MEDIA inserts PDF/HTML/diff/CSV/Excalidraw placeholders into
|
||||
# the live DOM. They must hydrate immediately, not wait for the settled
|
||||
# renderMessages() pass.
|
||||
append = MESSAGES_JS[
|
||||
MESSAGES_JS.index("function _smdAppendMediaNode"):
|
||||
MESSAGES_JS.index("function _smdScheduleMediaPostProcess")
|
||||
]
|
||||
self.assertIn("_smdScheduleMediaPostProcess(parent)", append)
|
||||
scheduler = MESSAGES_JS[
|
||||
MESSAGES_JS.index("function _smdScheduleMediaPostProcess"):
|
||||
MESSAGES_JS.index("// Per-parser tail buffer")
|
||||
]
|
||||
self.assertIn("_postProcessWithAnchorSuppression(root)", scheduler)
|
||||
self.assertIn("postProcessRenderedMessages(root)", scheduler)
|
||||
self.assertIn("_applyMediaPlaybackPreferences(root)", scheduler)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node not on PATH")
|
||||
class TestSmdMediaRealParserBehaviour(unittest.TestCase):
|
||||
"""Drive the actual vendored smd parser through split MEDIA chunks."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.cases = _run_real_smd_media_cases()
|
||||
|
||||
def test_real_smd_parser_buffers_every_media_prefix_split(self):
|
||||
for split, modes in self.cases["prefixSplits"].items():
|
||||
for mode, result in modes.items():
|
||||
with self.subTest(split=split, mode=mode):
|
||||
self.assertIn('class="media-node"', result["html"])
|
||||
self.assertIn('data-ref="C:/tmp/live.png"', result["html"])
|
||||
self.assertNotIn("MEDIA:", result["text"])
|
||||
|
||||
def test_real_smd_parser_buffers_partial_ref_until_complete(self):
|
||||
for mode, result in self.cases["refSplit"].items():
|
||||
with self.subTest(mode=mode):
|
||||
self.assertIn('class="media-node"', result["html"])
|
||||
self.assertIn('data-ref="C:/tmp/live.png"', result["html"])
|
||||
self.assertNotIn("MEDIA:", result["text"])
|
||||
self.assertNotIn("C:/tmp/li", result["text"])
|
||||
|
||||
def test_real_smd_parser_flushes_final_extensionless_url(self):
|
||||
for mode, result in self.cases["finalExtensionless"].items():
|
||||
with self.subTest(mode=mode):
|
||||
self.assertIn('class="media-node"', result["html"])
|
||||
self.assertIn('data-ref="https://fal.media/generated"', result["html"])
|
||||
self.assertNotIn("MEDIA:", result["text"])
|
||||
|
||||
def test_real_smd_parser_live_pdf_placeholder_is_hydrated(self):
|
||||
for mode, result in self.cases["pdf"].items():
|
||||
with self.subTest(mode=mode):
|
||||
self.assertIn('class="pdf-preview-load"', result["html"])
|
||||
self.assertIn('data-path="C:/tmp/report.pdf"', result["html"])
|
||||
self.assertGreaterEqual(result["postProcessCalls"], 1)
|
||||
self.assertGreaterEqual(result["playbackCalls"], 1)
|
||||
|
||||
def test_real_smd_parser_false_prefix_plain_prose_keeps_fade(self):
|
||||
result = self.cases["falsePrefix"]["fade"]
|
||||
self.assertEqual(result["text"], "Maybe plain prose ")
|
||||
self.assertEqual(result["fadeWords"], ["Maybe", "plain", "prose"])
|
||||
self.assertIn('class="stream-fade-word is-new"', result["html"])
|
||||
|
||||
def test_real_smd_parser_refuses_cross_parent_tail_concat(self):
|
||||
for mode, result in self.cases["crossParent"].items():
|
||||
with self.subTest(mode=mode):
|
||||
self.assertEqual(result["liTexts"], ["ME", "ow"])
|
||||
if mode == "fade":
|
||||
self.assertTrue(result["fadeWords"])
|
||||
self.assertEqual("".join(result["fadeWords"]), "MEow")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -36,9 +36,12 @@ def test_local_svg_inline_rendering():
|
||||
with open('static/ui.js', encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
assert "msg-media-svg" in src, "Missing msg-media-svg CSS class for SVG rendering"
|
||||
# Should have at least 2 SVG handlers (URL + local)
|
||||
# Both URL-based and local-path SVG handlers are centralised in
|
||||
# _inlineMediaHtmlForRef (the single MEDIA renderer exported by ui.js for
|
||||
# use by both renderMd() and the streaming smd path). The shared function
|
||||
# contains all SVG markup, so we only need the helper to exist once.
|
||||
count = src.count("msg-media-svg")
|
||||
assert count >= 2, f"Expected >=2 msg-media-svg references, got {count}"
|
||||
assert count >= 1, f"Expected >=1 msg-media-svg references in _inlineMediaHtmlForRef, got {count}"
|
||||
|
||||
|
||||
def test_local_audio_inline_rendering():
|
||||
@@ -47,8 +50,10 @@ def test_local_audio_inline_rendering():
|
||||
src = f.read()
|
||||
assert "msg-media-audio" in src, "Missing msg-media-audio CSS class"
|
||||
assert "<audio controls" in src, "Should render <audio> element with controls"
|
||||
# See comment in test_svg_rendered_before_image_catch_all — audio markup
|
||||
# lives in a single shared helper now.
|
||||
count = src.count("msg-media-audio")
|
||||
assert count >= 2, f"Expected >=2 msg-media-audio references, got {count}"
|
||||
assert count >= 1, f"Expected >=1 msg-media-audio references in _inlineMediaHtmlForRef, got {count}"
|
||||
|
||||
|
||||
def test_local_video_inline_rendering():
|
||||
@@ -57,8 +62,10 @@ def test_local_video_inline_rendering():
|
||||
src = f.read()
|
||||
assert "msg-media-video" in src, "Missing msg-media-video CSS class"
|
||||
assert "<video controls" in src, "Should render <video> element with controls"
|
||||
# See comment in test_svg_rendered_before_image_catch_all — video markup
|
||||
# lives in a single shared helper now.
|
||||
count = src.count("msg-media-video")
|
||||
assert count >= 2, f"Expected >=2 msg-media-video references, got {count}"
|
||||
assert count >= 1, f"Expected >=1 msg-media-video references in _inlineMediaHtmlForRef, got {count}"
|
||||
|
||||
|
||||
def test_url_svg_audio_video_handlers():
|
||||
@@ -75,6 +82,23 @@ def test_url_svg_audio_video_handlers():
|
||||
assert url_video >= 1, "URL video handler should test extension on src"
|
||||
|
||||
|
||||
def test_webm_prefers_video_when_audio_and_video_regexes_overlap():
|
||||
"""Verify .webm is not shadowed by the audio regex."""
|
||||
with open('static/ui.js', encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
kind_start = src.find("function _mediaKindForName")
|
||||
kind_body = src[kind_start:kind_start + 400]
|
||||
assert "_VIDEO_EXTS.test(clean)" in kind_body
|
||||
assert "_AUDIO_EXTS.test(clean)" in kind_body
|
||||
assert kind_body.index("_VIDEO_EXTS.test(clean)") < kind_body.index("_AUDIO_EXTS.test(clean)"), \
|
||||
"_mediaKindForName must check video before audio so .webm renders as video"
|
||||
inline_start = src.find("function _inlineMediaHtmlForRef")
|
||||
inline_body = src[inline_start:inline_start + 4500]
|
||||
assert "const kind=_AUDIO_EXTS.test(ref)?'audio':'video';" not in inline_body, \
|
||||
"Local MEDIA rendering must reuse _mediaKindForName instead of reintroducing audio-first .webm ordering"
|
||||
assert "if(localKind==='audio'||localKind==='video')" in inline_body
|
||||
|
||||
|
||||
def test_attachment_svg_audio_video():
|
||||
"""Verify file attachments for SVG/audio/video get inline previews."""
|
||||
with open('static/ui.js', encoding="utf-8") as f:
|
||||
|
||||
Reference in new issue
Block a user