Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 142 additions & 46 deletions atlas/connectors/web_search.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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()
52 changes: 52 additions & 0 deletions atlas/interface/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ async function ask(text, { speak = false } = {}) {
const reply = data.reply || "(no reply)";
addMsg("atlas", reply);
setTranscript(`<span class="atlas">ATLAS:</span> ${reply}`);
renderSearchMedia(data.media);
if (speak) speakReply(reply); else setState("idle");
} catch {
thinking.remove();
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions atlas/interface/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ <h2>Settings</h2>
</div>
</div>

<!-- search visuals: floating related photos + the top article card -->
<div class="search-visuals" id="searchVisuals" aria-hidden="true"></div>
<aside class="article-card glass" id="articleCard" hidden></aside>

<!-- toasts -->
<div class="toasts" id="toasts" aria-live="polite"></div>

Expand Down
22 changes: 22 additions & 0 deletions atlas/interface/web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,28 @@ 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{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}}
.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)}
.artc-body{padding:14px 16px;display:flex;flex-direction:column;gap:8px}
.artc-title{font-weight:600;font-size:15px;line-height:1.35}
.artc-sum{margin:0;font-size:12.5px;color:var(--muted);line-height:1.5;
display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
.artc-actions{display:flex;gap:8px;align-items:center;margin-top:2px}
.artc-close{position:absolute;top:8px;right:8px;z-index:2;background:rgba(2,4,10,.55);
border:1px solid var(--line);color:var(--text);width:26px;height:26px;border-radius:50%;
cursor:pointer;font-size:12px;line-height:1}
.artc-close:hover{border-color:var(--accent);color:var(--accent)}
.code-hint code{display:inline-block;background:rgba(0,0,0,.4);padding:5px 9px;border-radius:6px;
border:1px solid var(--line);margin-top:4px;white-space:pre-wrap;word-break:break-all;color:var(--accent)}
.status-pill{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:1px;
Expand Down
9 changes: 8 additions & 1 deletion atlas/orchestration/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,15 @@
"name": "web_search",
"risk": "READ",
"connector": "web",
"description": "Search the web for current information. Summarize results with sources.",
"description": "Search the web for current information. Summarize results with sources. The top related images and article are shown to the Owner on the HUD automatically.",
"input_schema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] }
},
{
"name": "read_article",
"risk": "READ",
"connector": "web",
"description": "Fetch and read the full text of an article so you can summarize it, give a TLDR, or read it aloud. Use when the Owner says things like 'read that article', 'summarize it', or 'TLDR'. Omit url to use the most recent search's top article.",
"input_schema": { "type": "object", "properties": { "url": { "type": "string" } } }
}
],
"risk_policy": {
Expand Down
4 changes: 3 additions & 1 deletion atlas/orchestration/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ def _history(self, session: str) -> list[dict[str, Any]]:
def chat(self, text: str, *, session: str = "s_000", turn: int = 1,
channel: str = "chat", confirmed: bool = False) -> dict[str, Any]:
t0 = time.monotonic()
self.tools.last_media = None # cleared each turn; set if web_search runs
tier = None
if self.routing_enabled:
tier = classify_tier(text)
Expand Down Expand Up @@ -208,7 +209,8 @@ def chat(self, text: str, *, session: str = "s_000", turn: int = 1,
backend=(f"{tier}:{backend}" if tier else backend),
tools=used, session=session, turn=turn)
return {"reply": reply, "backend": backend, "tier": tier,
"latency_ms": latency_ms, "tools_used": used}
"latency_ms": latency_ms, "tools_used": used,
"media": self.tools.last_media}

def _dispatch_mode(self, text: str, *, confirmed: bool,
session: str = "") -> tuple[str, list[str], int | None]:
Expand Down
14 changes: 12 additions & 2 deletions atlas/orchestration/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def __init__(self, vault: VaultStore | None = None,
registry: ConnectorRegistry | None = None):
self.vault = vault or VaultStore()
self.registry = registry or ConnectorRegistry()
self.last_media: dict[str, Any] | None = None # visuals from the last web search

def dispatch(self, name: str, args: dict[str, Any], *,
confirmed: bool = False) -> str:
Expand Down Expand Up @@ -84,8 +85,17 @@ def _t_forget(self, args: dict[str, Any]) -> str:
return self.vault.forget(args.get("fact"), args.get("entity"))

def _t_web_search(self, args: dict[str, Any]) -> str:
from ..connectors.web_search import search
return search(args.get("query", ""))
from ..connectors.web_search import rich
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")}
return data.get("text", "")

def _t_read_article(self, args: dict[str, Any]) -> str:
from ..connectors.web_search import read_article
url = args.get("url") or ((self.last_media or {}).get("article") or {}).get("url", "")
return read_article(url)

def _t_atlas_status(self, args: dict[str, Any]) -> str:
"""Introspect ATLAS's own layers/state so the model can answer questions
Expand Down
Loading
Loading