Merge branch 'master' into fix/6010-cache-providers

This commit is contained in:
Sean authored and GitHub committed 2026-07-13 17:27:32 +08:00
commit b1867d7bf4
7 files changed
+984 -119

No files matched your search

+6
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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*\\(');
+579
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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()
+28 -4
View File
@@ -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: