diff --git a/api/config.py b/api/config.py index f14e8c43b2..c071007e1f 100644 --- a/api/config.py +++ b/api/config.py @@ -1016,6 +1016,9 @@ def verify_hermes_imports() -> tuple: ".json": "application/json", ".html": "text/html", ".htm": "text/html", + ".md": "text/markdown", + ".mkd": "text/markdown", + ".mkdn": "text/markdown", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".doc": "application/msword", diff --git a/api/routes.py b/api/routes.py index 91d0b73a12..ba2cb5dd1e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -18767,7 +18767,7 @@ def _handle_media(handler, parsed): "video/mp4", "video/quicktime", "video/webm", "video/ogg", "application/pdf", } - _SESSION_MEDIA_TOKEN_TYPES = _INLINE_IMAGE_TYPES | _AUDIO_VIDEO_PDF_TYPES | {"text/html"} + _SESSION_MEDIA_TOKEN_TYPES = _INLINE_IMAGE_TYPES | _AUDIO_VIDEO_PDF_TYPES | {"text/html", "text/markdown"} session_media_allowed = _session_media_token_allows_path( qs.get("session_id", [""])[0], target, diff --git a/static/style.css b/static/style.css index d6d8f505fa..d1d35eecd7 100644 --- a/static/style.css +++ b/static/style.css @@ -6582,6 +6582,17 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .html-preview-fallback{padding:8px;font-size:13px;} .html-preview-spinner{animation:pulse 1.5s ease-in-out infinite;} +/* ── Markdown inline preview ──────────────────────────────────────────────── */ +.md-inline-load{color:var(--muted);font-size:13px;padding:8px 12px;border:1px dashed var(--border);border-radius:8px;margin:6px 0;} +.md-preview-spinner{animation:pulse 1.5s ease-in-out infinite;} +.md-inline-wrap{border:1px solid var(--border);border-radius:6px;overflow:hidden;margin:4px 0;background:var(--surface);} +.md-inline-header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface-2);font-size:12px;font-weight:600;border-bottom:1px solid var(--border);} +.md-preview-title{color:var(--text);} +.md-inline-fallback{padding:8px;font-size:13px;} +.md-inline-content{padding:12px 16px;overflow-x:auto;} +.md-inline-content > :first-child{margin-top:0;} +.md-inline-content > :last-child{margin-bottom:0;} + /* ── Insights panel (#464) ────────────────────────────────────────────────── */ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .insights-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px;} diff --git a/static/ui.js b/static/ui.js index 00952e4a1d..81a2b1ac71 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2499,6 +2499,7 @@ const _SVG_EXTS=/\.svg$/i; const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i; const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i; const _CSV_EXTS=/\.csv$/i; +const _MD_EXTS=/\.(md|mkd|mkdn)$/i; const _EXCALIDRAW_EXTS=/\.excalidraw$/i; // ── Media playback speed controls ───────────────────────────────────────── const MEDIA_PLAYBACK_RATES=[0.5,0.75,1,1.25,1.5,2]; @@ -2661,6 +2662,9 @@ function _inlineMediaHtmlForRef(ref, sessionId, altText){ if(_HTML_EXTS.test(ref)){ return `
${esc(typeof t==='function'?t('html_loading'):'Loading')}...
`; } + if(_MD_EXTS.test(ref)){ + return `
${esc(typeof t==='function'?t('md_loading'):'Loading')}...
`; + } const fname=esc(ref.split('/').pop()||ref); if(/\.(patch|diff)$/i.test(ref)) return `
${esc(typeof t==='function'?t('diff_loading'):'Loading diff')} ${fname}...
`; if(_CSV_EXTS.test(ref)) return `
${esc(typeof t==='function'?t('csv_loading'):'Loading')} ${fname}...
`; @@ -18087,6 +18091,7 @@ function postProcessRenderedMessages(container) { loadExcalidrawInline(container); loadPdfInline(container); loadHtmlInline(container); + loadMarkdownInline(container); renderMermaidBlocks(container); renderKatexBlocks(container); initTreeViews(container); @@ -18654,6 +18659,34 @@ function loadHtmlInline(container){ }); } +const MD_MAX_SIZE = 256 * 1024; + +function loadMarkdownInline(container){ + const root = container || document; + root.querySelectorAll('.md-inline-load:not([data-loaded])').forEach(el => { + el.setAttribute('data-loaded', '1'); + const path = el.dataset.path; + const fname = path.split('/').pop() || path; + const mediaSessionId = (typeof S !== 'undefined' && S && S.session && S.session.session_id) ? String(S.session.session_id) : ''; + const publicMediaUrl = 'api/media?path=' + encodeURIComponent(path); + const mediaUrl = publicMediaUrl + (mediaSessionId ? '&session_id=' + encodeURIComponent(mediaSessionId) : ''); + const downloadUrl = mediaUrl + '&download=1'; + fetch(mediaUrl) + .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) + .then(text => { + if (text.length > MD_MAX_SIZE) { + el.outerHTML = `
📎 ${esc(fname)}
${esc(typeof t === 'function' ? t('md_too_large') : 'File too large for preview')}
`; + return; + } + const rendered = renderMd(text); + el.outerHTML = `
${esc(fname)}📎 ${esc(fname)}
${rendered}
`; + }) + .catch(() => { + el.outerHTML = `
📎 ${esc(fname)}
${esc(typeof t === 'function' ? t('md_error') : 'Error loading markdown')}
`; + }); + }); +} + function renderMermaidBlocks(container){ const root=container||document; const blocks=root.querySelectorAll('.mermaid-block:not([data-rendered])');