From a72ec0c1164ec63cd20e7161af4bf3ad2eab7cee Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Tue, 30 Jun 2026 05:17:46 +0000 Subject: [PATCH] Show thumbnails on past research cards --- core/middleware.py | 2 +- routes/research/research_routes.py | 49 ++++++++++++++++++++++++++++++ static/app.js | 2 +- static/index.html | 6 ++-- static/js/research/jobs.js | 23 +++++++++++++- static/js/research/panel.js | 8 ++++- static/style.css | 17 +++++++++++ static/sw.js | 2 +- 8 files changed, 101 insertions(+), 8 deletions(-) diff --git a/core/middleware.py b/core/middleware.py index fe1f30110..0e164e35a 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -117,7 +117,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "font-src 'self' https://cdn.jsdelivr.net; " - "img-src 'self' data: blob:; " + "img-src 'self' data: blob: https:; " "media-src 'self' blob:; " "connect-src 'self'; " "frame-src 'self'; " diff --git a/routes/research/research_routes.py b/routes/research/research_routes.py index 889298f7d..5582f2653 100644 --- a/routes/research/research_routes.py +++ b/routes/research/research_routes.py @@ -31,6 +31,54 @@ _NON_CHAT_MODEL = ( "moderation", "rerank", "reranker", "clip", "stable-diffusion", ) +_RESEARCH_IMAGE_BLOCKLIST = { + "cdn.shopify.com/s/files/1/0179/4388/7926/files/icon.png", +} + + +def _is_research_icon_or_logo_url(url: str) -> bool: + path = url.lower().split("?")[0] + return any(token in path for token in ( + "/logo", "logo_", "-logo", "favicon", "apple-touch-icon", + "sprite", "icon-", "_icon", "/icons/", "badge", + )) + + +def _research_thumbnail(data: dict) -> str: + """Pick the same first visible image the visual report uses as hero.""" + hidden = set(data.get("hidden_images") or []) + seen = set() + + def usable(image: str) -> bool: + image = str(image or "").strip() + if not image or image in seen or image in hidden: + return False + if not image.startswith("https://"): + return False + if image.endswith((".svg", ".ico", ".gif")): + return False + if any(blocked in image for blocked in _RESEARCH_IMAGE_BLOCKLIST): + return False + if _is_research_icon_or_logo_url(image): + return False + return True + + for source in data.get("sources") or []: + if not isinstance(source, dict): + continue + image = str(source.get("image") or source.get("og_image") or "").strip() + if usable(image): + seen.add(image) + return image + for finding in data.get("raw_findings") or data.get("findings") or []: + if not isinstance(finding, dict): + continue + image = str(finding.get("image") or finding.get("og_image") or "").strip() + if usable(image): + seen.add(image) + return image + return "" + def _first_chat_model(models) -> str: """First model that isn't an embedding/tts/etc. — falls back to models[0].""" @@ -290,6 +338,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter: "started_at": d.get("started_at", 0), "completed_at": d.get("completed_at", 0), "archived": bool(d.get("archived")), + "thumbnail": _research_thumbnail(d), }) except Exception: continue diff --git a/static/app.js b/static/app.js index 262ecdb19..be7d3f863 100644 --- a/static/app.js +++ b/static/app.js @@ -39,7 +39,7 @@ import themeModule from './js/theme.js'; // unversioned so this can't recur. import cookbookModule from './js/cookbook.js'; import groupModule from './js/group.js'; -import * as researchPanelModule from './js/research/panel.js'; +import * as researchPanelModule from './js/research/panel.js?v=20260630researchthumb'; import ttsModule from './js/tts-ai.js'; import spinnerModule from './js/spinner.js'; import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js'; diff --git a/static/index.html b/static/index.html index 314e65b67..4515a4fad 100644 --- a/static/index.html +++ b/static/index.html @@ -219,8 +219,8 @@ }, { once: true }); })(); - - + + @@ -2512,7 +2512,7 @@ - + diff --git a/static/js/research/jobs.js b/static/js/research/jobs.js index 55997375b..29ba06e0e 100644 --- a/static/js/research/jobs.js +++ b/static/js/research/jobs.js @@ -99,13 +99,34 @@ async function _syncLibrary(options = {}) { for (const item of (libData.research || [])) { if (item.status !== 'done') continue; if (dismissed.has(item.id)) continue; - if (_jobs.some(j => j.id === item.id)) continue; const elapsed = item.duration ? _parseDuration(item.duration) : 0; + const existing = _jobs.find(j => j.id === item.id); + if (existing) { + let changed = false; + const updates = { + query: item.query || existing.query, + status: 'done', + elapsed: elapsed || existing.elapsed || 0, + sourceCount: item.source_count || existing.sourceCount || 0, + thumbnail: item.thumbnail || existing.thumbnail || '', + category: item.category || existing.category || '', + _fromLibrary: true, + }; + for (const [key, value] of Object.entries(updates)) { + if (existing[key] !== value) { + existing[key] = value; + changed = true; + } + } + if (changed) _notify(); + continue; + } _jobs.push({ id: item.id, query: item.query, status: 'done', progress: {}, startedAt: (item.started_at || 0) * 1000, elapsed, result: null, sources: null, findings: null, sourceCount: item.source_count || 0, + thumbnail: item.thumbnail || '', category: item.category || '', errorMsg: null, avgDuration: null, modelName: null, settings: { max_rounds: item.rounds || 8 }, diff --git a/static/js/research/panel.js b/static/js/research/panel.js index 7555e345b..b249cd320 100644 --- a/static/js/research/panel.js +++ b/static/js/research/panel.js @@ -1,7 +1,7 @@ /** * Deep Research side panel — open/close, form, job rendering, library. */ -import * as jobs from './jobs.js'; +import * as jobs from './jobs.js?v=20260630researchthumb'; import themeModule from '../theme.js'; import createResearchSynapse from '../researchSynapse.js'; import spinnerModule from '../spinner.js'; @@ -994,6 +994,11 @@ function _buildJobCard(job) { const failNote = failed ? `
Couldn't extract anything — try rephrasing the question, or switch the search engine in Settings.
` : ''; + const thumbSource = (job.sources || []).find(s => s && (s.image || s.og_image)); + const thumbUrl = job.thumbnail || thumbSource?.image || thumbSource?.og_image || ''; + const thumbnail = thumbUrl + ? `` + : ''; card.innerHTML = `
${_esc(job.query)}${doneBadge} @@ -1002,6 +1007,7 @@ function _buildJobCard(job) {
${failNote}
+ ${thumbnail} diff --git a/static/style.css b/static/style.css index 88073ab48..b3f10bb4f 100644 --- a/static/style.css +++ b/static/style.css @@ -38270,6 +38270,23 @@ body.research-panel-view #research-divider { display:none; } } .research-job-actions { display:flex; gap:4px; margin-top:6px; + align-items:center; +} +.research-job-thumb { + width: 64px; + height: 40px; + border-radius: 4px; + object-fit: cover; + flex: 0 0 64px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + background: var(--panel, var(--bg)); +} +.research-job-thumb-empty { + display: inline-block; + background: + linear-gradient(135deg, + color-mix(in srgb, var(--fg) 7%, transparent), + color-mix(in srgb, var(--fg) 3%, transparent)); } /* Push the first dim (dismiss/delete) button — and everything after it — to the right edge of the actions row. `:first-of-type` would match the very diff --git a/static/sw.js b/static/sw.js index f927c2b54..54ee41816 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v327'; +const CACHE_NAME = 'odysseus-v328'; // Core shell precached on install so repeat opens are instant without any // network wait. Keep this list in sync with the