This is a sufficiently long paragraph about the topic at hand.
" + "short
Another long and meaningful paragraph with real content here.
" + "diff --git a/atlas/connectors/web_search.py b/atlas/connectors/web_search.py index 553b59b..1811a1b 100644 --- a/atlas/connectors/web_search.py +++ b/atlas/connectors/web_search.py @@ -1,9 +1,10 @@ -"""Web search connector (L7). READ-only — current facts the model can cite. +"""Web search connector (L7). READ-only — current facts the model can cite, plus +RICH results (related images + the top article) for the visual HUD, and an article +reader so ATLAS can summarize / TLDR / read a page aloud on request. -Default provider is **DuckDuckGo** (no API key, works out of the box). Tavily or -Brave can be selected in settings for higher quality; their key is read from the -env or the macOS Keychain (never stored in settings.json). All failures degrade -to an honest message rather than raising into the chat path. +Default provider is DuckDuckGo (no key). Tavily/Brave selectable in settings; keys +come from env or the macOS Keychain. All failures degrade to an honest message +rather than raising into the chat path. """ from __future__ import annotations @@ -12,13 +13,15 @@ from .. import settings as cfg +_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/125 Safari/537.36") + def _web_cfg() -> dict[str, Any]: return cfg.settings().get("web") or {} def _key(web: dict[str, Any], env_name: str) -> str | None: - """API key from env first, then macOS Keychain via the configured ref.""" if os.environ.get(env_name): return os.environ[env_name] from ..orchestration.router import keychain_secret # lazy: avoid import cycle @@ -35,57 +38,150 @@ def _format(rows: list[tuple[str, str, str]], query: str) -> str: return "\n".join(out) -def search(query: str, *, max_results: int | None = None) -> str: - query = (query or "").strip() - if not query: - return "No search query provided." - web = _web_cfg() - n = max_results or web.get("max_results", 5) - provider = (web.get("provider") or "duckduckgo").lower() - try: - if provider == "duckduckgo": - return _ddg(query, n) - if provider == "tavily": - return _tavily(query, n, _key(web, "TAVILY_API_KEY")) - if provider == "brave": - return _brave(query, n, _key(web, "BRAVE_API_KEY")) - return f"Unknown search provider '{provider}'. Use duckduckgo, tavily, or brave." - except Exception as exc: # network / parse / rate-limit — stay graceful - return f"Web search failed ({type(exc).__name__}). Try again shortly." - - -def _ddg(query: str, n: int) -> str: - try: - from ddgs import DDGS - except ImportError: - return "Web search needs the 'ddgs' package (pip install ddgs)." +# ----- provider row-getters → (title, body, url) ------------------------- # +def _ddg_rows(query: str, n: int) -> list[tuple[str, str, str]]: + from ddgs import DDGS with DDGS() as d: res = list(d.text(query, max_results=n)) - return _format([(r.get("title", ""), r.get("body", ""), r.get("href", "")) for r in res], query) + return [(r.get("title", ""), r.get("body", ""), r.get("href", "")) for r in res] -def _tavily(query: str, n: int, key: str | None) -> str: - if not key: - return "Tavily selected but no key found. Add TAVILY_API_KEY (env or Keychain)." +def _tavily_rows(query: str, n: int, key: str) -> list[tuple[str, str, str]]: import httpx r = httpx.post("https://api.tavily.com/search", json={ - "api_key": key, "query": query, "max_results": n, "include_answer": False, - }, timeout=15.0) + "api_key": key, "query": query, "max_results": n, "include_answer": False}, timeout=15.0) r.raise_for_status() - data = r.json() - rows = [(x.get("title", ""), x.get("content", ""), x.get("url", "")) for x in data.get("results", [])] - return _format(rows, query) + return [(x.get("title", ""), x.get("content", ""), x.get("url", "")) for x in r.json().get("results", [])] -def _brave(query: str, n: int, key: str | None) -> str: - if not key: - return "Brave selected but no key found. Add BRAVE_API_KEY (env or Keychain)." +def _brave_rows(query: str, n: int, key: str) -> list[tuple[str, str, str]]: import httpx r = httpx.get("https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, - headers={"X-Subscription-Token": key, "Accept": "application/json"}, - timeout=15.0) + headers={"X-Subscription-Token": key, "Accept": "application/json"}, timeout=15.0) r.raise_for_status() - rows = [(x.get("title", ""), x.get("description", ""), x.get("url", "")) + return [(x.get("title", ""), x.get("description", ""), x.get("url", "")) for x in r.json().get("web", {}).get("results", [])] - return _format(rows, query) + + +def _rows(query: str, n: int, web: dict[str, Any]) -> list[tuple[str, str, str]]: + provider = (web.get("provider") or "duckduckgo").lower() + if provider == "duckduckgo": + try: + import ddgs # noqa: F401 + except ImportError: + raise RuntimeError("ddgs-missing") + return _ddg_rows(query, n) + if provider == "tavily": + key = _key(web, "TAVILY_API_KEY") + if not key: + raise RuntimeError("no-tavily-key") + return _tavily_rows(query, n, key) + if provider == "brave": + key = _key(web, "BRAVE_API_KEY") + if not key: + raise RuntimeError("no-brave-key") + return _brave_rows(query, n, key) + raise RuntimeError(f"unknown-provider:{provider}") + + +def search(query: str, *, max_results: int | None = None) -> str: + """Plain-text results for the model (with source URLs to cite).""" + query = (query or "").strip() + if not query: + return "No search query provided." + web = _web_cfg() + n = max_results or web.get("max_results", 5) + try: + return _format(_rows(query, n, web), query) + except RuntimeError as exc: + m = str(exc) + if m == "ddgs-missing": + return "Web search needs the 'ddgs' package (pip install ddgs)." + if m.startswith("no-"): + return f"Search provider needs an API key ({m})." + return f"Web search unavailable ({m})." + except Exception as exc: # network / parse / rate-limit — stay graceful + return f"Web search failed ({type(exc).__name__}). Try again shortly." + + +# ----- images (for the floating photos on the HUD) ----------------------- # +def _images(query: str, n: int = 6) -> list[dict[str, str]]: + try: + from ddgs import DDGS + with DDGS() as d: + res = list(d.images(query, max_results=n)) + except Exception: + return [] + out: list[dict[str, str]] = [] + for r in res: + img = r.get("image") + if not img: + continue + out.append({"image": img, "thumbnail": r.get("thumbnail") or img, + "source": r.get("url") or img, "title": r.get("title", "")}) + return out + + +def rich(query: str, *, max_results: int | None = None) -> dict[str, Any]: + """Text (for the model) + related images + the top article (for the HUD).""" + query = (query or "").strip() + if not query: + return {"query": query, "text": "No search query provided.", "images": [], "article": None} + web = _web_cfg() + n = max_results or web.get("max_results", 5) + try: + rows = _rows(query, n, web) + text = _format(rows, query) + 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} + + +# ----- article reader (fetch + extract main text) ------------------------ # +def read_article(url: str) -> str: + """Fetch a page and return its readable main text so the model can summarize, + TLDR, or read it aloud. Truncated so it fits the context window.""" + url = (url or "").strip() + if not url.startswith("http"): + return "There's no article URL to read yet — search for something first." + try: + import httpx + r = httpx.get(url, timeout=15.0, follow_redirects=True, headers={"User-Agent": _UA}) + r.raise_for_status() + html = r.text + except Exception as exc: + return f"Couldn't fetch the article ({type(exc).__name__})." + text = _extract_text(html) + if not text: + return "Couldn't extract readable text from that page (it may be paywalled or JS-only)." + return text[:6000] + + +def _extract_text(html: str) -> str: + try: + from lxml import html as lhtml + except ImportError: + return "" + try: + doc = lhtml.fromstring(html) + except Exception: + return "" + for bad in doc.xpath("//script|//style|//noscript|//nav|//footer|//header|//aside|//form"): + parent = bad.getparent() + if parent is not None: + parent.remove(bad) + title = (doc.findtext(".//title") or "").strip() + nodes = doc.xpath("//article//p") or doc.xpath("//main//p") or doc.xpath("//p") + paras = [" ".join(p.text_content().split()).strip() for p in nodes] + paras = [p for p in paras if len(p) > 40] + if not paras: + return "" + body = "\n\n".join(paras) + return (f"{title}\n\n{body}" if title else body).strip() diff --git a/atlas/interface/web/app.js b/atlas/interface/web/app.js index ad813a0..ef8d9d9 100644 --- a/atlas/interface/web/app.js +++ b/atlas/interface/web/app.js @@ -253,6 +253,7 @@ async function ask(text, { speak = false } = {}) { const reply = data.reply || "(no reply)"; addMsg("atlas", reply); setTranscript(`ATLAS: ${reply}`); + renderSearchMedia(data.media); if (speak) speakReply(reply); else setState("idle"); } catch { thinking.remove(); @@ -1017,6 +1018,57 @@ $("#backendTest").addEventListener("click", async () => { finally { btn.disabled = false; btn.textContent = "Test"; } }); +/* ---------- search visuals: floating related photos + article card ---------- */ +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; } + + // 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); + }); + + // top article card (hero + title + summary + Open + TLDR) + const a = media.article; + if (a && a.url) { + card.innerHTML = ""; + const close = document.createElement("button"); + close.className = "artc-close"; close.title = "Close"; close.textContent = "✕"; + close.addEventListener("click", () => { card.hidden = true; }); + card.appendChild(close); + if (a.image) { + const hero = document.createElement("div"); hero.className = "artc-hero"; + hero.style.backgroundImage = `url("${String(a.image).replace(/"/g, "%22")}")`; + card.appendChild(hero); + } + const body = document.createElement("div"); body.className = "artc-body"; + 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 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); + body.append(title, sum, actions); card.appendChild(body); + card.hidden = false; + } else card.hidden = true; +} + /* ---------- stable session id (so conversation history accumulates) ---------- */ function sessionId() { let s = localStorage.getItem("atlas_session"); diff --git a/atlas/interface/web/index.html b/atlas/interface/web/index.html index 6baa51f..366e1c3 100644 --- a/atlas/interface/web/index.html +++ b/atlas/interface/web/index.html @@ -143,6 +143,10 @@
This is a sufficiently long paragraph about the topic at hand.
" + "short
Another long and meaningful paragraph with real content here.
" + "