diff --git a/atlas/connectors/web_search.py b/atlas/connectors/web_search.py index 1811a1b..3f9f2f0 100644 --- a/atlas/connectors/web_search.py +++ b/atlas/connectors/web_search.py @@ -136,12 +136,15 @@ def rich(query: str, *, max_results: int | None = None) -> dict[str, Any]: except Exception as exc: rows, text = [], f"Web search failed ({type(exc).__name__})." images = _images(query, max(n + 1, 6)) - article = None - if rows: - title, body, url = rows[0] - article = {"title": title, "url": url, "summary": body, - "image": images[0]["image"] if images else None} - return {"query": query, "text": text, "images": images, "article": article} + # Numbered article results (the Owner can say "open article 2"): each real + # web result gets a thumbnail (best-effort, zipped by index) for the HUD. + results = [] + for i, (title, body, url) in enumerate(rows): + img = images[i]["image"] if i < len(images) else (images[0]["image"] if images else None) + results.append({"n": i + 1, "title": title, "url": url, "summary": body, "image": img}) + article = results[0] if results else None + return {"query": query, "text": text, "images": images, + "results": results, "article": article} # ----- article reader (fetch + extract main text) ------------------------ # diff --git a/atlas/interface/web/app.js b/atlas/interface/web/app.js index ef8d9d9..c019708 100644 --- a/atlas/interface/web/app.js +++ b/atlas/interface/web/app.js @@ -238,6 +238,7 @@ function addMsg(role, text, cls = "") { } async function ask(text, { speak = false } = {}) { if (!text) return; + if (handleArticleCommand(text, speak)) return; // "open article 2" / "close" — handled in-HUD voiceTurn = speak; // voice turns auto-listen for a follow-up reply addMsg("owner", text); setState("thinking"); @@ -320,12 +321,106 @@ function stopSpeaking() { try { speechSynthesis.cancel(); } catch {} if (currentAudio) { try { currentAudio.pause(); } catch {} currentAudio = null; } } + +/* ---------- barge-in: listen WHILE speaking so "Stop" interrupts ---------- */ +/* A dedicated recognizer runs only during speech (the wake/command recognizers + are idle then, so there's no mic contention). Say "stop / quiet / enough" and + ATLAS stops mid-sentence. */ +const bargeSR = SR ? new SR() : null; +let bargeRunning = false; +let speakingText = ""; // what ATLAS is currently saying (to ignore its own voice) +const STOP_RE = /\b(stop|quiet|enough|cancel|shut up|be quiet|silence|nevermind|never mind|halt)\b/i; +/* ATLAS can't literally un-hear the speakers (Web Speech gives us no echo + cancellation), but it KNOWS what it's saying — so ignore any heard words that + are part of its own current utterance. Only genuinely-new speech (your "stop") + gets through. */ +function isSelfEcho(heard) { + if (!speakingText) return false; + const words = heard.toLowerCase().split(/\s+/).filter(w => w.length > 2); + if (!words.length) return false; + const inSpeech = words.filter(w => speakingText.includes(w)).length; + return inSpeech / words.length >= 0.5; // most heard words are ATLAS's own → echo +} +function initBarge() { + if (!bargeSR) return; + bargeSR.continuous = true; bargeSR.interimResults = true; bargeSR.lang = "en-US"; + bargeSR.onresult = (e) => { + const txt = Array.from(e.results).map(r => r[0].transcript).join(" "); + if (STOP_RE.test(txt) && !isSelfEcho(txt)) bargeInterrupt(); + }; + bargeSR.onend = () => { bargeRunning = false; if (orbState === "speaking") setTimeout(startBarge, 150); }; + bargeSR.onerror = () => { bargeRunning = false; }; +} +function startBarge() { + if (!bargeSR || !wakeEnabled || bargeRunning || orbState !== "speaking") return; + stopFrameListen(); // free the mic for the barge listener while speaking + try { bargeSR.start(); bargeRunning = true; } catch {} +} +function stopBarge() { if (bargeSR) { bargeRunning = false; try { bargeSR.stop(); } catch {} } } +function bargeInterrupt() { + stopBarge(); stopSpeaking(); + voiceTurn = false; // don't open a follow-up mic — user wants quiet + clearTimeout(followTimer); + setState("idle"); toast("Stopped ✋", "ok"); + if (!$("#articleFrame").hidden) startFrameListen(); else resumeWake(); +} + +/* ---------- article-frame voice: bare "close/next/reader/summarize" while open ---------- */ +/* While an article is open, listen for navigation commands without needing the + wake word (say "close", "next", "reader", "summarize"). "Hey Atlas " + still works for a general ask. */ +const frameSR = SR ? new SR() : null; +let frameListening = false; +function initFrameListen() { + if (!frameSR) return; + frameSR.continuous = true; frameSR.interimResults = false; frameSR.lang = "en-US"; + frameSR.onresult = (e) => { + const txt = Array.from(e.results).map(r => r[0].transcript).join(" ").toLowerCase().trim(); + if (txt) handleFrameVoice(txt); + }; + frameSR.onend = () => { frameListening = false; if (!$("#articleFrame").hidden && orbState !== "speaking") setTimeout(startFrameListen, 200); }; + frameSR.onerror = () => { frameListening = false; }; +} +function startFrameListen() { + if (!frameSR || !wakeEnabled) return; + if ($("#articleFrame").hidden || frameListening || orbState === "speaking") return; + try { wakeSR && wakeSR.stop(); } catch {} wakeRunning = false; + try { frameSR.start(); frameListening = true; } catch {} +} +function stopFrameListen() { if (frameSR) { frameListening = false; try { frameSR.stop(); } catch {} } } +function navFrame(delta) { + const cur = +($("#articleFrame").dataset.n || 0); + const r = searchResults[cur - 1 + delta]; + if (r) openArticleInFrame(r); else toast(delta > 0 ? "That's the last article" : "That's the first article", "err"); +} +function handleFrameVoice(t) { + if (WAKE_RE.test(t)) { // "Hey Atlas " — general ask + const tail = t.replace(/^[\s\S]*?\batlas\b[\s,.!?-]*/i, "").trim(); + if (tail.split(/\s+/).filter(Boolean).length >= 1) { stopFrameListen(); setState("thinking"); ask(tail, { speak: true }); } + return; + } + if (/\b(close|dismiss|exit|go back|back|done)\b/.test(t)) { closeFrame(); return; } + if (/\bnext\b/.test(t)) { navFrame(1); return; } + if (/\b(previous|prior|last one|back one)\b/.test(t)) { navFrame(-1); return; } + if (/\b(reader|read mode|text mode)\b/.test(t)) { showFrameReader(); return; } + if (/\b(live|the page|the site|website)\b/.test(t)) { showFrameLive(); return; } + if (/\b(tldr|summar|read it|read this|read the article)\b/.test(t)) { + const r = _afCurrent(); stopFrameListen(); setState("thinking"); + ask(`Give me a short TLDR of this article: ${r.url}`, { speak: true }); return; + } + const cmd = parseArticleCommand(t); // "open article 3" while one is open + if (cmd && cmd.action === "open") { const r = searchResults[cmd.n - 1]; if (r) openArticleInFrame(r); } +} /* After ATLAS finishes speaking: on a voice turn, open the mic for a few seconds so the Owner can reply without re-saying the wake word — a fluent back-and-forth. If they stay silent, we drop back to wake-word listening ("wait till I call"). */ function afterSpeak() { + stopBarge(); + speakingText = ""; setState("idle"); - if (voiceTurn) listenFollowUp(); else resumeWake(); + if (!$("#articleFrame").hidden) startFrameListen(); // article open → keep frame commands live + else if (voiceTurn) listenFollowUp(); + else resumeWake(); } function listenFollowUp() { if (!recog) { resumeWake(); return; } @@ -341,7 +436,7 @@ function browserSpeak(text) { const u = new SpeechSynthesisUtterance(text); const v = jarvisVoice(); if (v) u.voice = v; u.rate = 0.97; u.pitch = 0.9; - u.onstart = () => setState("speaking"); + u.onstart = () => { setState("speaking"); speakingText = String(text).toLowerCase(); startBarge(); }; u.onend = () => afterSpeak(); u.onerror = () => afterSpeak(); setState("speaking"); speechSynthesis.speak(u); @@ -376,6 +471,7 @@ async function speakReply(text) { if (!spoken) { setState("idle"); resumeWake(); return; } stopSpeaking(); setState("speaking"); + speakingText = spoken.toLowerCase(); // ATLAS ignores its own words in the barge listener try { const r = await fetch(API + "/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -387,6 +483,7 @@ async function speakReply(text) { audio.onended = () => { URL.revokeObjectURL(url); if (currentAudio === audio) currentAudio = null; afterSpeak(); }; audio.onerror = () => { URL.revokeObjectURL(url); currentAudio = null; browserSpeak(spoken); }; await audio.play(); + startBarge(); // listen for "Stop" while the reply plays } catch { browserSpeak(spoken); } @@ -1018,29 +1115,58 @@ $("#backendTest").addEventListener("click", async () => { finally { btn.disabled = false; btn.textContent = "Test"; } }); -/* ---------- search visuals: floating related photos + article card ---------- */ +/* ---------- search visuals: numbered floating photos + article card + reader ---------- */ +const PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent( + "" + + ""); +let searchResults = []; // numbered results from the last search ("open article N") + function renderSearchMedia(media) { const wrap = $("#searchVisuals"), card = $("#articleCard"); if (!wrap || !card) return; - wrap.innerHTML = ""; // clear previous search's photos - if (!media || (!(media.images && media.images.length) && !media.article)) { card.hidden = true; return; } + wrap.innerHTML = ""; // clear previous search's photos + const results = (media && media.results) || []; + searchResults = results; + if (!results.length && !(media && media.article)) { card.hidden = true; return; } - // floating related photos, scattered around the edges (away from the orb) - const spots = [[6, 16], [80, 12], [9, 58], [83, 54], [40, 6], [63, 70], [22, 82], [72, 84]]; - (media.images || []).slice(0, 6).forEach((im, i) => { - const el = document.createElement("img"); - el.className = "float-photo"; el.loading = "lazy"; - el.src = im.thumbnail || im.image; el.alt = im.title || ""; - if (im.title) el.title = im.title; - const [x, y] = spots[i % spots.length]; - el.style.left = x + "%"; el.style.top = y + "%"; - el.style.animationDelay = `${i * 0.35}s, ${i * 0.6}s`; // photoIn, floaty - el.addEventListener("click", () => window.open(im.source || im.image, "_blank", "noopener")); - el.addEventListener("error", () => el.remove()); - wrap.appendChild(el); + // Numbered photos: they POP in the screen centre, then fly out one-by-one to + // their slot in an organized grid — a large "table in the air", centred on screen. + const shown = results.slice(0, 6); + const vw = window.innerWidth, vh = window.innerHeight; + const compact = vw < 900; + const iw = compact ? 176 : 248, ih = compact ? 124 : 176; // photo size (~2×) + const capH = 40, ch = ih + capH; + const gapX = 22, gapY = 20; + const cols = Math.min(shown.length, 3); + const rows = Math.ceil(shown.length / cols); + const gridW = cols * iw + (cols - 1) * gapX; + const gridH = rows * ch + (rows - 1) * gapY; + const startX = Math.max(12, (vw - gridW) / 2); // centred horizontally + const startY = Math.max(64, (vh - gridH) / 2); // …and vertically + const cx = vw / 2 - iw / 2, cy = vh / 2 - ch / 2; // screen centre (pop origin) + shown.forEach((r, i) => { + const col = i % cols, row = Math.floor(i / cols); + const x = startX + col * (iw + gapX), y = startY + row * (ch + gapY); + const item = document.createElement("div"); item.className = "float-item"; + item.style.width = iw + "px"; + item.style.left = x + "px"; item.style.top = y + "px"; + item.style.setProperty("--dx", (cx - x) + "px"); // fly from centre → + item.style.setProperty("--dy", (cy - y) + "px"); + const fly = i * 0.16; // staggered: each in its turn + item.style.animationDelay = `${fly}s, ${0.7 + fly}s`; // flyIn, then floaty + const num = document.createElement("span"); num.className = "float-num"; num.textContent = r.n || (i + 1); + const img = document.createElement("img"); img.loading = "lazy"; + img.style.width = iw + "px"; img.style.height = ih + "px"; + img.src = r.image || PLACEHOLDER; img.alt = r.title || ""; + img.addEventListener("error", () => { img.src = PLACEHOLDER; }); + const cap = document.createElement("div"); cap.className = "float-cap"; cap.textContent = r.title || r.url; + item.append(num, img, cap); + item.title = `${r.n}. ${r.title || ""} — click, or say “open article ${r.n}”`; + item.addEventListener("click", () => openArticleInFrame(r)); + wrap.appendChild(item); }); - // top article card (hero + title + summary + Open + TLDR) + // top article card (hero + title + summary + Read + TLDR) — Read opens in-HUD const a = media.article; if (a && a.url) { card.innerHTML = ""; @@ -1057,18 +1183,97 @@ function renderSearchMedia(media) { const title = document.createElement("div"); title.className = "artc-title"; title.textContent = a.title || "Article"; const sum = document.createElement("p"); sum.className = "artc-sum"; sum.textContent = a.summary || ""; const actions = document.createElement("div"); actions.className = "artc-actions"; - const open = document.createElement("a"); - open.className = "navbtn small primary"; open.target = "_blank"; open.rel = "noopener"; - open.href = a.url; open.textContent = "Open article ↗"; + const read = document.createElement("button"); + read.className = "navbtn small primary"; read.textContent = "Read ↗"; + read.addEventListener("click", () => openArticleInFrame(a)); const tldr = document.createElement("button"); tldr.className = "navbtn small"; tldr.textContent = "TLDR"; - tldr.addEventListener("click", () => ask(`Give me a short TLDR of this article: ${a.url}`, true)); - actions.append(open, tldr); + tldr.addEventListener("click", () => ask(`Give me a short TLDR of this article: ${a.url}`, { speak: true })); + actions.append(read, tldr); body.append(title, sum, actions); card.appendChild(body); card.hidden = false; } else card.hidden = true; } +/* in-HUD reader: live iframe with a text-Reader fallback (never leaves ATLAS) */ +function openArticleInFrame(r) { + if (!r || !r.url) return; + const f = $("#articleFrame"); + f.dataset.url = r.url; f.dataset.title = r.title || "Article"; + f.dataset.n = r.n || ""; f.dataset.summary = r.summary || ""; + $("#afNum").textContent = r.n || ""; + $("#afNum").style.display = r.n ? "" : "none"; + $("#afTitle").textContent = r.title || "Article"; + f.hidden = false; + showFrameReader(); // Reader first — always shows content in-app (most sites block iframes) + startFrameListen(); // voice: "close / next / reader / live / summarize" while open +} +function _afCurrent() { const f = $("#articleFrame"); return { url: f.dataset.url, title: f.dataset.title, n: f.dataset.n, summary: f.dataset.summary }; } +function _afMode(m) { $("#afLive").classList.toggle("active", m === "live"); $("#afReader").classList.toggle("active", m === "reader"); } +function showFrameLive() { + const r = _afCurrent(), body = $("#afBody"); body.innerHTML = ""; + const fr = document.createElement("iframe"); fr.className = "af-iframe"; + fr.setAttribute("sandbox", "allow-scripts allow-same-origin allow-popups allow-forms"); + fr.setAttribute("referrerpolicy", "no-referrer"); + fr.src = r.url; body.appendChild(fr); + const note = document.createElement("div"); note.className = "af-note"; + note.textContent = "Blank? Some sites block embedding — tap ▤ Reader"; + body.appendChild(note); setTimeout(() => note.remove(), 6000); + _afMode("live"); +} +async function showFrameReader() { + const r = _afCurrent(), body = $("#afBody"); + body.innerHTML = `

Loading readable text…

`; + _afMode("reader"); + const d = await getJSON("/api/read?url=" + encodeURIComponent(r.url), null); + if (d && d.ok) { + const div = document.createElement("div"); div.className = "af-reader"; + const h = document.createElement("h1"); h.textContent = d.title || r.title || "Article"; div.appendChild(h); + (d.text || "").split(/\n{2,}/).forEach(p => { p = p.trim(); if (p) { const el = document.createElement("p"); el.textContent = p; div.appendChild(el); } }); + body.innerHTML = ""; body.appendChild(div); + } else { + // JS-only / unreadable page: show what we know (title + search summary) in-app. + const div = document.createElement("div"); div.className = "af-reader"; + const h = document.createElement("h1"); h.textContent = r.title || "Article"; div.appendChild(h); + if (r.summary) { const p = document.createElement("p"); p.textContent = r.summary; div.appendChild(p); } + const note = document.createElement("p"); note.className = "muted"; + note.textContent = "This page renders with JavaScript, so I can't extract the full text. Ask me for a TLDR, or tap ◐ Live to try the page."; + div.appendChild(note); + body.innerHTML = ""; body.appendChild(div); + } +} +function closeFrame() { + const f = $("#articleFrame"); f.hidden = true; $("#afBody").innerHTML = ""; + stopFrameListen(); resumeWake(); +} +function initArticleFrame() { + $("#afLive").addEventListener("click", showFrameLive); + $("#afReader").addEventListener("click", showFrameReader); + $("#afClose").addEventListener("click", closeFrame); + $("#afTldr").addEventListener("click", () => { const r = _afCurrent(); ask(`Give me a short TLDR of this article: ${r.url}`, { speak: true }); }); +} + +/* "open article 2" / "close article" — resolved in-HUD (voice or chat) */ +const _NUMWORD = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; +function handleArticleCommand(text, speak) { + const t = (text || "").toLowerCase().trim(); + if (t === "close" || /\b(close|dismiss|hide)\b.*\b(article|reader|frame|page|window)\b/.test(t)) { + if (!$("#articleFrame").hidden) { closeFrame(); return true; } + return false; + } + const m = t.match(/\b(?:open|show|read|pull up|bring up|display|go to)\b\s+(?:the\s+)?(?:(?:article|result|number|link|photo|image|story|item|#|no\.?)\s+)?(?:the\s+)?(\d{1,2}|first|second|third|fourth|fifth|sixth|one|two|three|four|five|six)\b/); + if (!m) return false; + const n = /^\d+$/.test(m[1]) ? +m[1] : _NUMWORD[m[1]]; + if (!n) return false; + const r = searchResults[n - 1]; + if (!r) { toast(`No article ${n} yet — search first`, "err"); return true; } + addMsg("owner", `open article ${n}`); + addMsg("atlas", `Opening “${r.title || r.url}”.`); + openArticleInFrame(r); + if (speak) resumeWake(); + return true; +} + /* ---------- stable session id (so conversation history accumulates) ---------- */ function sessionId() { let s = localStorage.getItem("atlas_session"); @@ -1093,6 +1298,7 @@ const typing = () => ["INPUT", "TEXTAREA"].includes(document.activeElement?.tagN addEventListener("keydown", (e) => { if (e.key === "Escape") { if (!$("#settingsModal").hidden) return closeSettings(); + if (!$("#articleFrame").hidden) return closeFrame(); if (!$("#chatPop").hidden) return closeChat(); if ($("#logsDrawer").dataset.open === "true") return toggleLogs(false); } @@ -1110,6 +1316,9 @@ setState("idle"); waveform(); initVoice(); initWake(); +initBarge(); +initFrameListen(); +initArticleFrame(); if (localStorage.getItem("atlas_wake") === "on") setWake(true); else updateWakeUI(); renderStatus(); renderMetrics(); renderMemory(); renderUpgrade(); renderScheduler(); renderCredits(); connectWS(); diff --git a/atlas/interface/web/index.html b/atlas/interface/web/index.html index 366e1c3..640c158 100644 --- a/atlas/interface/web/index.html +++ b/atlas/interface/web/index.html @@ -143,10 +143,25 @@

Settings

- + + + +
diff --git a/atlas/interface/web/styles.css b/atlas/interface/web/styles.css index d2a3d29..f61d757 100644 --- a/atlas/interface/web/styles.css +++ b/atlas/interface/web/styles.css @@ -274,16 +274,45 @@ code{font-family:var(--mono);color:var(--accent);font-size:12px} background:rgba(0,0,0,.35);border:1px solid var(--line);border-radius:8px; color:var(--text);padding:7px 10px;font-family:var(--mono);font-size:13px;outline:none} .tier-row select:focus,.tier-row input:focus{border-color:var(--line-bright)} -/* ---------- search visuals: floating related photos + article card ---------- */ +/* ---------- search visuals: floating numbered photos + article card ---------- */ .search-visuals{position:fixed;inset:0;z-index:6;pointer-events:none;overflow:hidden} -.float-photo{position:absolute;width:118px;height:118px;object-fit:cover;border-radius:14px; - border:1px solid var(--line-bright);box-shadow:0 10px 34px rgba(0,0,0,.5),0 0 0 1px rgba(0,224,255,.15); - pointer-events:auto;cursor:pointer;opacity:0; - animation:photoIn .5s ease forwards,floaty 7s ease-in-out infinite;transition:transform .2s,box-shadow .2s} -.float-photo:hover{transform:scale(1.07);box-shadow:0 14px 44px rgba(0,0,0,.6),0 0 0 1px var(--accent);z-index:2} -@keyframes photoIn{to{opacity:.92}} -@keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-12px)}} -@media (max-width:900px){.float-photo{width:82px;height:82px}} +.float-item{position:absolute;width:132px;pointer-events:auto;cursor:pointer;opacity:0; + animation:flyIn .7s cubic-bezier(.2,.85,.25,1) both,floaty 7s ease-in-out infinite;transition:transform .2s} +.float-item:hover{transform:scale(1.06);z-index:3} +/* pop in the screen centre, then glide to the grid slot (--dx/--dy set per item) */ +@keyframes flyIn{ + 0%{transform:translate(var(--dx,0),var(--dy,0)) scale(1.45);opacity:0} + 14%{opacity:1} + 100%{transform:translate(0,0) scale(1);opacity:1}} +.float-item img{display:block;width:132px;height:96px;object-fit:cover;border-radius:13px; + border:1px solid var(--line-bright);box-shadow:0 10px 34px rgba(0,0,0,.5),0 0 0 1px rgba(0,224,255,.15)} +.float-item:hover img{box-shadow:0 14px 44px rgba(0,0,0,.6),0 0 0 1px var(--accent)} +.float-num{position:absolute;top:-9px;left:-9px;width:24px;height:24px;border-radius:50%; + background:var(--accent);color:#04121a;font-family:var(--mono);font-weight:700;font-size:13px; + display:flex;align-items:center;justify-content:center;box-shadow:0 3px 10px rgba(0,0,0,.5);z-index:2} +.float-cap{margin-top:5px;font-size:11px;line-height:1.3;color:var(--text); + background:rgba(2,4,10,.6);border-radius:7px;padding:3px 7px; + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +@keyframes photoIn{to{opacity:1}} +@keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-10px)}} +@media (max-width:900px){.float-item,.float-item img{width:104px}.float-item img{height:74px}} + +/* in-HUD article reader (iframe + text Reader fallback) */ +.article-frame{position:fixed;z-index:11;left:50%;top:50%;transform:translate(-50%,-50%); + width:min(920px,92vw);height:min(80vh,760px);display:flex;flex-direction:column; + border-radius:16px;overflow:hidden;opacity:0;animation:photoIn .3s ease forwards} +.af-head{display:flex;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line)} +.af-num{flex:0 0 auto;min-width:24px;height:24px;padding:0 6px;border-radius:12px;background:var(--accent); + color:#04121a;font-family:var(--mono);font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center} +.af-title{flex:1;min-width:0;font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.af-actions{display:flex;gap:8px;align-items:center} +.af-body{flex:1;min-height:0;position:relative;background:#0a0e16} +.af-iframe{width:100%;height:100%;border:0;background:#fff} +.af-reader{width:100%;height:100%;overflow:auto;padding:22px 30px;line-height:1.7;font-size:15px;color:var(--text)} +.af-reader h1{font-size:20px;margin:0 0 14px} +.af-reader p{margin:0 0 13px;color:#cdd6e4} +.af-note{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--muted); + background:rgba(2,4,10,.7);border:1px solid var(--line);border-radius:20px;padding:5px 12px;pointer-events:none} .article-card{position:fixed;right:22px;bottom:22px;z-index:8;width:344px;max-width:calc(100vw - 32px); border-radius:16px;overflow:hidden;display:flex;flex-direction:column;opacity:0;animation:photoIn .4s ease forwards} .artc-hero{height:150px;background-size:cover;background-position:center;border-bottom:1px solid var(--line)} diff --git a/atlas/orchestration/tools.py b/atlas/orchestration/tools.py index 7ff7350..eb2b8ec 100644 --- a/atlas/orchestration/tools.py +++ b/atlas/orchestration/tools.py @@ -89,7 +89,9 @@ def _t_web_search(self, args: dict[str, Any]) -> str: data = rich(args.get("query", "")) # Stash visuals (images + top article) for the HUD; the model gets the text. self.last_media = {"type": "search", "query": data.get("query"), - "images": data.get("images") or [], "article": data.get("article")} + "images": data.get("images") or [], + "results": data.get("results") or [], + "article": data.get("article")} return data.get("text", "") def _t_read_article(self, args: dict[str, Any]) -> str: diff --git a/atlas/server/app.py b/atlas/server/app.py index 2d47030..57b9b1a 100644 --- a/atlas/server/app.py +++ b/atlas/server/app.py @@ -408,6 +408,19 @@ def tts(body: TTSIn) -> Response: return Response(content=wav, media_type="audio/wav") +@app.get("/api/read") +def read_page(url: str) -> dict[str, Any]: + """Extract an article's readable text so the HUD reader panel can show it + in-app when a site blocks iframe embedding. Keeps the Owner inside ATLAS.""" + from ..connectors.web_search import read_article + text = read_article(url) + bad = text.startswith(("Couldn't", "There's no", "Web search")) + title, body = "", text + if not bad and "\n\n" in text: + title, body = text.split("\n\n", 1) + return {"ok": not bad, "url": url, "title": title.strip(), "text": body.strip()} + + @app.get("/api/logs") def logs(limit: int = 200) -> list[dict[str, Any]]: """Recent structured events for the Logs drawer (oldest→newest).""" diff --git a/atlas/tests/test_web_search.py b/atlas/tests/test_web_search.py index ea705b0..7d528f4 100644 --- a/atlas/tests/test_web_search.py +++ b/atlas/tests/test_web_search.py @@ -37,6 +37,18 @@ def test_rich_shapes_media(monkeypatch): assert "Web results" in d["text"] +def test_rich_numbers_results_for_open_article_n(monkeypatch): + rows = [("A", "sa", "https://a"), ("B", "sb", "https://b"), ("C", "sc", "https://c")] + monkeypatch.setattr(ws, "_rows", lambda q, n, web: rows) + monkeypatch.setattr(ws, "_images", lambda q, n=6: [ + {"image": f"https://img/{i}.jpg", "thumbnail": "", "source": "", "title": ""} for i in range(3)]) + d = ws.rich("news") + assert [r["n"] for r in d["results"]] == [1, 2, 3] # numbered for "open article N" + assert d["results"][1]["url"] == "https://b" # article 2 → second result + assert d["results"][2]["image"] == "https://img/2.jpg" # thumbnail zipped by index + assert d["article"]["url"] == "https://a" # top result + + def test_toolbox_web_search_sets_last_media(monkeypatch): monkeypatch.setattr(ws, "rich", lambda q: { "query": q, "text": "the text for the model",