diff --git a/docs/changelog.json b/docs/changelog.json index 96f29f0..3f070e4 100644 --- a/docs/changelog.json +++ b/docs/changelog.json @@ -1,4 +1,16 @@ [ + { + "date": "2026-10-13", + "area": "Agents", + "title": "Agent Store: install ready-made agents in one click", + "summary": "The Agent Lab has a Store with ready-made agents from the Shell:B Agents collection on GitHub: Scribe for editing, Tutor for studying, Quartermaster for the household, Navigator for trips, Gamemaster for tabletop games and Chronicler for journaling. Search them or filter by topic, read what each one can use, and press Install: the agent joins your agent menu and runs on your own model, never a cloud one. More agents in the agent menu, and the phone's agent sheet, have a Browse the Agent Store link. Agent repositories added in Settings → Community show up in the Store too." + }, + { + "date": "2026-10-13", + "area": "Chat", + "title": "The agent menu shows the agents you use, with the rest under More agents", + "summary": "The agent menu above the message box now lists your six most-used agents instead of every agent at once. More agents opens the full list with a search box; pin agents there to choose exactly which ones the menu shows, or go back to the most-used ones. Cloud agents stay under Cloud unless you pin them. On a phone, the agent sheet gains a search box. The choice is remembered per browser." + }, { "date": "2026-10-13", "area": "Chat", diff --git a/server/family.py b/server/family.py index bccb2d3..64b6e5e 100644 --- a/server/family.py +++ b/server/family.py @@ -2,9 +2,9 @@ The official repositories (ShellB-App/Shell-B-*) are always listed and can't be removed. Anyone can run their own collection: a repository laid out like the official one of the same kind (folder per item, index.json) that the owner -adds here with its kind. Adding a theme, screensaver or template repository also registers it as a gallery source +adds here with its kind. Adding a theme, screensaver, template or agent repository also registers it as a gallery source (server/gallery.py) and a skills repository as a Community Hub source (server/community.py), so the matching picker -starts browsing it; the kinds without an installer yet (agents, worlds, voices, apps, translations) are listed with +starts browsing it (an agent repository joins the Agent Lab's Store); the kinds without an installer yet (worlds, voices, apps, translations) are listed with where they will show up and a link, and their index is still fetched and counted. Fetching reuses community.fetch_repo (one tarball at a pinned commit, public-internet guard, size cap). Stats per @@ -28,7 +28,7 @@ "screensaver": {"label": "Screensavers", "folder": "screensavers", "where": "Settings → Appearance → Screensaver → Browse community", "installs": True}, "template": {"label": "Studio templates", "folder": "templates", "where": "Studio → New project → Start from a template", "installs": True}, "skill": {"label": "Skills", "folder": "skills", "where": "Tools & Connectors → Community, then Skills", "installs": True}, - "agent": {"label": "Agents", "folder": "agents", "where": "Agent Lab → New agent (one-click install planned)", "installs": False}, + "agent": {"label": "Agents", "folder": "agents", "where": "Agent Lab → Store", "installs": True}, "world": {"label": "Worlds", "folder": "worlds", "where": "Worlds, via a map block in chat (one-click install planned)", "installs": False}, "voice": {"label": "Voices", "folder": "voices", "where": "Settings → Speech → Describe (one-click install planned)", "installs": False}, "app": {"label": "Apps", "folder": "apps", "where": "Studio → New project (one-click install planned)", "installs": False}, @@ -90,7 +90,7 @@ def add(kind: str, repo: str, ref: str = "main", name: str = "") -> dict: def _register(entry: dict): """Hand the repository to the picker that can install from it, when one exists.""" try: - if entry["kind"] in ("theme", "screensaver", "template"): + if entry["kind"] in ("theme", "screensaver", "template", "agent"): import gallery if entry["kind"] in gallery.KINDS: gallery.add_source(entry["kind"], entry["repo"], entry["ref"]) @@ -112,7 +112,7 @@ def remove(rid: str): del stats[rid] db.kv_set("family_stats", stats) try: - if entry["kind"] in ("theme", "screensaver", "template"): + if entry["kind"] in ("theme", "screensaver", "template", "agent"): import gallery gallery.remove_source(re.sub(r"[^a-z0-9]+", "-", entry["repo"].lower()).strip("-")) except (KeyError, ImportError): diff --git a/server/gallery.py b/server/gallery.py index 653f2ad..d4b6957 100644 --- a/server/gallery.py +++ b/server/gallery.py @@ -1,8 +1,10 @@ -"""Community gallery: themes and screensavers other people published on GitHub, browsed and installed from Settings. +"""Community gallery: themes, screensavers, Studio templates and agents other people published on GitHub, browsed and +installed from Settings, the Studio and the Agent Lab's Store. Sources (kv `gallery_sources`, the owner can add more): a GitHub repository per kind, laid out like ShellB-App/Shell-B-Themes (themes//theme.json), ShellB-App/Shell-B-Screensavers (screensavers//index.html -+ saver.json + preview image) and ShellB-App/Shell-B-Studio-Templates (templates//template.json + project files). Each is fetched the way the Community Hub fetches skill repositories: one tarball at a ++ saver.json + preview image), ShellB-App/Shell-B-Studio-Templates (templates//template.json + project files) and +ShellB-App/Shell-B-Agents (agents//agent.json). Each is fetched the way the Community Hub fetches skill repositories: one tarball at a pinned commit (community.fetch_repo, public-internet guard, tarfile "data" filter, size cap), kept under data/community/ repos, so browsing works offline once a source has been fetched. The catalogue (index.json, or the folders when a fork has none) is cached in kv `gallery_` with the commit it came from. @@ -14,6 +16,9 @@ dotfiles) and runs like any Studio screensaver: index.html in a sandboxed iframe, scripts only, no same-origin - a template becomes a new Studio project of its kind (same file rules), its brief in DESIGN.md; every use is a new project, so nothing is marked installed + - an agent goes through clean_agent: only the persona fields the Agent Lab edits, within the format's limits, its + tools cut to the agent tools this Shell:B has, never the adult flag; it runs on the owner's default chat model + unless it names a local model that is installed, and never on a cloud model, so installing can't send chats out Nothing here is reachable by agents: there is no tool, so a chat can't install a look the owner didn't pick. """ import asyncio @@ -33,17 +38,22 @@ import themes router = APIRouter(prefix="/api/gallery") -KINDS = ("theme", "screensaver", "template") +KINDS = ("theme", "screensaver", "template", "agent") DEFAULT_SOURCES = [ {"id": "shellb-themes", "kind": "theme", "repo": "ShellB-App/Shell-B-Themes", "ref": "main"}, {"id": "shellb-screensavers", "kind": "screensaver", "repo": "ShellB-App/Shell-B-Screensavers", "ref": "main"}, {"id": "shellb-templates", "kind": "template", "repo": "ShellB-App/Shell-B-Studio-Templates", "ref": "main"}, + {"id": "shellb-agents", "kind": "agent", "repo": "ShellB-App/Shell-B-Agents", "ref": "main"}, ] INSTALLED_KEY = "gallery_installed" REFRESH_AFTER = 24 * 3600 ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,39}$") REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$") URL_RE = re.compile(r"^https?://[^\s\"'<>]{1,200}$") +HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") +MODEL_RE = re.compile(r"^[A-Za-z0-9][\w.\-/:]{0,99}$") +EFFORTS = ("off", "low", "medium", "high") +NEEDS = ("tools", "vision", "thinking") SAVER_FILES = {".html", ".css", ".js", ".json", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".woff2", ".txt", ".md"} TEMPLATE_FILES = SAVER_FILES | {".mjs", ".ts", ".ico", ".woff", ".ttf", ".otf", ".py", ".toml", ".yaml", ".yml", ".csv", ".glb", ".gltf", ".mp3", ".ogg", ".wav"} TEMPLATE_MAX_FILE, TEMPLATE_MAX_FOLDER = 4 * 2**20, 16 * 2**20 @@ -56,7 +66,10 @@ def sources() -> list[dict]: v = db.kv_get("gallery_sources") if not isinstance(v, list) or not v: return [dict(s) for s in DEFAULT_SOURCES] - return [s for s in v if isinstance(s, dict) and s.get("kind") in KINDS and REPO_RE.match(str(s.get("repo") or ""))] + out = [s for s in v if isinstance(s, dict) and s.get("kind") in KINDS and REPO_RE.match(str(s.get("repo") or ""))] + # a kind added after the owner saved their own list (agents, 2026-10) still gets its official source + have = {s["kind"] for s in out} + return out + [dict(s) for s in DEFAULT_SOURCES if s["kind"] not in have] def add_source(kind: str, repo: str, ref: str = "main") -> dict: @@ -185,12 +198,68 @@ def _template_items(root: Path) -> list[dict]: return items +def clean_agent(spec: dict) -> tuple[dict, list[str], str]: + """An agent.json from the internet → the persona fields the Agent Lab stores, notes on what was changed, and the + model it asks for ("default" or a tag). + Raises ValueError when it isn't an agent at all. Anything outside the format (an id, adult, skills…) is dropped.""" + from tools import TOOLS + if not isinstance(spec, dict): + raise ValueError("not an agent file") + notes = [] + s = lambda k, n: str(spec.get(k) or "").strip()[:n] + name, title, prompt = s("name", 32), s("title", 40), str(spec.get("systemPrompt") or "").strip() + if not name or len(prompt) < 80: + raise ValueError("an agent needs a name and a system prompt of at least 80 characters") + if len(prompt) > 12000: + prompt = prompt[:12000] + notes.append("The system prompt was cut to 12,000 characters.") + color = spec.get("color") + if not (isinstance(color, list) and len(color) == 2 and all(isinstance(c, str) and HEX_RE.match(c) for c in color)): + color = ["#475569", "#1e293b"] + try: + temperature = min(2.0, max(0.0, float(spec.get("temperature", 0.6)))) + except (TypeError, ValueError): + temperature = 0.6 + effort = spec.get("effort") if spec.get("effort") in EFFORTS else "medium" + wanted = [t for t in (spec.get("tools") or []) if isinstance(t, str)][:40] + usable = {tid for tid, t in TOOLS.items() if not t.get("hidden") and t.get("scope", "agent") == "agent"} + tools = list(dict.fromkeys(t for t in wanted if t in usable)) + missing = [t for t in wanted if t not in usable] + if missing: + notes.append(f"Left out tools this Shell:B doesn't have: {', '.join(missing[:8])}.") + model = s("model", 100) or "default" + agent = {"name": name, "title": title or "Specialist", "avatar": s("avatar", 8) or "◆", "color": color, + "description": s("description", 200), "systemPrompt": prompt, "temperature": round(temperature, 2), + "effort": effort, "tools": tools, "builtin": False} + return agent, notes, (model if model == "default" or MODEL_RE.match(model) else "default") + + +def _agent_items(root: Path) -> list[dict]: + items = [] + for f in sorted(root.glob("agents/*/agent.json")): + aid = f.parent.name + if not ID_RE.match(aid): + continue + try: + spec = json.loads(f.read_text()) + agent, _, model = clean_agent(spec) + except (OSError, ValueError): + continue + items.append({"id": aid, "name": agent["name"], "title": agent["title"], "avatar": agent["avatar"], "color": agent["color"], + "blurb": agent["description"], "author": str(spec.get("author") or "")[:80], "authorUrl": _url(spec.get("homepage")), + "license": str(spec.get("license") or "")[:40], "version": str(spec.get("version") or "1.0")[:20], + "tags": [str(t)[:24] for t in (spec.get("tags") or [])[:8] if isinstance(t, str)], + "needs": [n for n in (spec.get("needs") or []) if n in NEEDS], "model": model, "effort": agent["effort"], + "tools": agent["tools"], "path": f"agents/{aid}/agent.json"}) + return items + + async def refresh_source(src: dict) -> dict: """Fetch the source at its current commit and rebuild its part of the catalogue.""" repo = await community.resolve_repo(src["repo"]) sha = await community._ls_remote(repo, src.get("ref") or "main") root = await community.fetch_repo(repo, sha) - items = {"theme": _theme_items, "screensaver": _saver_items, "template": _template_items}[src["kind"]](root) + items = {"theme": _theme_items, "screensaver": _saver_items, "template": _template_items, "agent": _agent_items}[src["kind"]](root) entry = {"repo": repo, "ref": src.get("ref") or "main", "sha": sha, "fetched": time.time(), "items": items} cache = db.kv_get(f"gallery_{src['kind']}") or {} if not isinstance(cache, dict): @@ -247,6 +316,10 @@ async def listing(kind: str, refresh: bool = False) -> dict: it["installed"] = None if kind == "screensaver" and not db.get_project(rec["target"]): it["installed"] = None + if kind == "agent": + from roster import get_agents + if not any(a["id"] == rec["target"] for a in get_agents()): + it["installed"] = None if kind == "template": # a template is a starting point: every use makes a new project, nothing to mark it.pop("installed", None); it.pop("installedVersion", None) return {"kind": kind, "sources": out, "errors": errors} @@ -350,6 +423,47 @@ def install_template(source: str, item_id: str, name: str = "") -> dict: return {"kind": "template", "project": projects.project_summary(db.get_project(pid))} +def install_agent(source: str, item_id: str, local_models: set[str] | None = None) -> dict: + """The community agent joins the roster (a second install updates the same agent). `local_models` are the Ollama + tags installed here: a named local model is used only when it is one of them, otherwise the default agent's model.""" + from roster import get_agents, get_settings + entry, it = _find("agent", source, item_id) + try: + spec = json.loads((community._repo_dir(entry["repo"], entry["sha"]) / it["path"]).read_text()) + except (OSError, ValueError): + raise KeyError("The agent file is missing from the fetched copy; refresh the store") + agent, notes, wanted = clean_agent(spec) + agents = get_agents() + default = next((a for a in agents if a["id"] == get_settings().get("defaultAgent")), None) or next(iter(agents), {}) + base = default.get("model") or "ShellB-Swift" + names = local_models or set() + if wanted != "default" and (wanted in names or f"{wanted}:latest" in names): + model = wanted + else: + model = base + if wanted != "default": + notes.append(f"{wanted} isn't installed here, so it uses {model}; change it in the Agent Lab.") + old = installed().get(f"agent:{source}:{item_id}") + ids = {a["id"] for a in agents} + if old and old["target"] in ids: + aid = old["target"] + else: + aid = item_id if item_id not in ids else next(f"{item_id}-{n}" for n in range(2, 100) if f"{item_id}-{n}" not in ids) + prov = {"repo": entry["repo"], "path": it["path"], "sha": entry["sha"], "author": it["author"], "version": it["version"], + "license": it["license"], "source": source, "item": item_id} + saved = {**agent, "id": aid, "model": model, "community": prov} + idx = next((i for i, a in enumerate(agents) if a["id"] == aid), None) + if idx is None: + agents.append(saved) + else: # an update keeps what the owner tuned that the file doesn't set: context size, skills, model choice + keep = {k: agents[idx][k] for k in ("numCtx", "skills", "model") if k in agents[idx]} + agents[idx] = {**saved, **keep} + saved = agents[idx] + db.kv_set("agents", agents) + _remember("agent", source, item_id, aid, entry["sha"], it["version"]) + return {"kind": "agent", "agent": saved, "notes": notes} + + # ── routes ──────────────────────────────────────────────────────────────────── @router.get("/sources") @@ -384,7 +498,7 @@ async def api_list(kind: str, refresh: int = 0): try: return await listing(kind, refresh=bool(refresh)) except KeyError: - raise HTTPException(404, "kind is theme, screensaver or template") + raise HTTPException(404, "kind is theme, screensaver, template or agent") @router.get("/{kind}/{source}/{item_id}/preview") @@ -413,6 +527,8 @@ async def api_install(req: Request): try: if kind == "template": res = await asyncio.to_thread(install_template, source, item_id, str(b.get("name") or "")) + elif kind == "agent": + res = await asyncio.to_thread(install_agent, source, item_id, await _local_models()) else: res = await asyncio.to_thread(install_theme if kind == "theme" else install_screensaver, source, item_id) except KeyError as e: @@ -420,13 +536,23 @@ async def api_install(req: Request): except ValueError as e: raise HTTPException(400, str(e)) db.audit_log("gallery:install", {"kind": kind, "source": source, "id": item_id, - "as": res.get("theme", {}).get("id") or res.get("project", {}).get("id")}) + "as": res.get("theme", {}).get("id") or res.get("project", {}).get("id") or res.get("agent", {}).get("id")}) if kind in ("screensaver", "template"): import projects projects.cover_later(res["project"]["id"]) # needs the event loop, so here and not in the worker thread return res +async def _local_models() -> set[str]: + """The Ollama tags installed here; empty when Ollama doesn't answer (the agent then uses the default model).""" + from tools import OLLAMA + try: + async with httpx.AsyncClient(timeout=3) as c: + return {m["name"] for m in (await c.get(f"{OLLAMA}/api/tags")).json().get("models", [])} + except (httpx.HTTPError, ValueError, KeyError, TypeError): + return set() + + def _owner_display() -> str: """The owner's display name from their Circles profile, for the author line of an exported theme (blank if unset).""" try: diff --git a/tests/gallery_test.py b/tests/gallery_test.py index 586c7ca..5c5faa4 100644 --- a/tests/gallery_test.py +++ b/tests/gallery_test.py @@ -1,5 +1,6 @@ """Community gallery: a fetched repository becomes a catalogue, themes install through the validator, screensavers -become sandboxed Studio projects with only the folder's allowed files, templates start new projects, installing needs the owner.""" +become sandboxed Studio projects with only the folder's allowed files, templates start new projects, agents join the +roster as plain personas on a local model, installing needs the owner.""" import asyncio import json import os @@ -21,6 +22,11 @@ "inks": ["#0f7f73", "#2c6fb0"], "fonts": {"display": "sora", "body": "sora"}, "shape": "round", "backdrop": {"sky": ["#f4f9f6", "#cfe4dc"], "horizon": "waves", "horizonColor": "#9fcabd"}, "particles": {"kind": "bubbles", "colors": ["#ffffff"], "density": 0.7}} +AGENT = {"name": "Penpal", "title": "Letter writer", "avatar": "✉", "color": ["#7c2d12", "#b45309"], + "description": "Writes letters with you.", "systemPrompt": "You are Penpal. " + "You help people write warm, clear letters. " * 4, + "model": "default", "needs": ["tools"], "temperature": 7, "effort": "low", + "tools": ["web_search", "no_such_tool", "web_search"], "author": "Tester", "license": "MIT", "version": "1.3", + "tags": ["writing"], "adult": True, "id": "shellb", "skills": "all"} SAVER_HTML = "" @@ -50,6 +56,13 @@ def fake_repo(root: Path): (root / "templates" / "nokind").mkdir() (root / "templates" / "nokind" / "template.json").write_text(json.dumps({"name": "x", "kind": "poster", "author": "a", "license": "MIT"})) (root / "templates" / "nokind" / "index.html").write_text("x") + a = root / "agents" / "penpal" + a.mkdir(parents=True) + (a / "agent.json").write_text(json.dumps(AGENT)) + (root / "agents" / "cloudy").mkdir() + (root / "agents" / "cloudy" / "agent.json").write_text(json.dumps({**AGENT, "name": "Cloudy", "model": "anthropic/claude-opus"})) + (root / "agents" / "short").mkdir() + (root / "agents" / "short" / "agent.json").write_text(json.dumps({**AGENT, "systemPrompt": "Too short."})) (root / ".complete").write_text("1") @@ -64,10 +77,12 @@ def setUpClass(cls): fake_repo(root) db.kv_set("gallery_sources", [{"id": "test-themes", "kind": "theme", "repo": cls.repo, "ref": cls.sha}, {"id": "test-savers", "kind": "screensaver", "repo": cls.repo, "ref": cls.sha}, - {"id": "test-templates", "kind": "template", "repo": cls.repo, "ref": cls.sha}]) + {"id": "test-templates", "kind": "template", "repo": cls.repo, "ref": cls.sha}, + {"id": "test-agents", "kind": "agent", "repo": cls.repo, "ref": cls.sha}]) db.kv_set("gallery_theme", {}) db.kv_set("gallery_screensaver", {}) db.kv_set("gallery_template", {}) + db.kv_set("gallery_agent", {}) db.kv_set(gallery.INSTALLED_KEY, {}) def test_listing_reads_the_pinned_repo(self): @@ -157,6 +172,45 @@ def test_template_starts_a_new_project_each_time(self): for r in (a, b): client.delete(f"/api/projects/{r['project']['id']}") + def test_agent_joins_the_roster_cleaned(self): + from roster import get_agents + res = asyncio.run(gallery.listing("agent", refresh=True)) + items = res["sources"][0]["items"] + self.assertEqual([i["id"] for i in items], ["cloudy", "penpal"]) # the one with a too-short prompt is skipped + pen = next(i for i in items if i["id"] == "penpal") + self.assertEqual((pen["name"], pen["title"], pen["tools"], pen["needs"]), ("Penpal", "Letter writer", ["web_search"], ["tools"])) + self.assertEqual(client.post("/api/gallery/install", json={"kind": "agent", "source": "test-agents", "id": "penpal"}).status_code, 403) + + res = gallery.install_agent("test-agents", "penpal") + a = res["agent"] + self.assertEqual(a["id"], "penpal") + self.assertEqual(a["model"], next(x for x in get_agents() if x["id"] == "shellb")["model"]) # "default" → the default agent's model + self.assertEqual(a["tools"], ["web_search"]) + self.assertEqual(a["temperature"], 2.0) + for k in ("adult", "skills", "needs"): + self.assertNotIn(k, a) + self.assertIn("no_such_tool", " ".join(res["notes"])) + self.assertEqual(a["community"]["version"], "1.3") + self.assertTrue(any(x["id"] == "penpal" for x in get_agents())) + self.assertFalse(next(x for x in get_agents() if x["id"] == "shellb").get("community")) # the file's id can't overwrite another agent + + # an update keeps the owner's model and replaces the same agent + agents = get_agents() + next(x for x in agents if x["id"] == "penpal")["model"] = "my-own:7b" + db.kv_set("agents", agents) + again = gallery.install_agent("test-agents", "penpal")["agent"] + self.assertEqual((again["id"], again["model"]), ("penpal", "my-own:7b")) + self.assertEqual(sum(1 for x in get_agents() if x["id"] == "penpal"), 1) + self.assertEqual(asyncio.run(gallery.listing("agent"))["sources"][0]["items"][1]["installed"], "penpal") + + # a cloud model is never taken from a file; a named local model only when it is installed + cloudy = gallery.install_agent("test-agents", "cloudy")["agent"] + self.assertFalse(cloudy["model"].startswith("anthropic/")) + self.assertEqual(cloudy["id"], "cloudy") + client.delete("/api/agents/cloudy") + client.delete("/api/agents/penpal") + self.assertIsNone(asyncio.run(gallery.listing("agent"))["sources"][0]["items"][1]["installed"]) + def test_sources(self): src = gallery.add_source("theme", "https://github.com/Some-One/Their-Themes.git") self.assertEqual(src["repo"], "Some-One/Their-Themes") diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1b6e18a..2199993 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,7 @@ import { Composer } from './components/Composer'; import { findMention } from './components/ComposerShortcuts'; import { ArtifactPanel } from './components/ArtifactPanel'; import { AgentLab } from './components/AgentLab'; +import { OPEN_AGENT_STORE } from './agentPicks'; import { CommandPalette, SettingsModal } from './components/Panels'; import { IncognitoMark, IncognitoNote, IncognitoStrip } from './components/Incognito'; import { IntegrationsModal, type ToolsTab } from './components/Integrations'; @@ -192,6 +193,12 @@ export function App() { // ?shot&settings= opens Settings on that tab, for headless screenshots (index.html) const shotSettings = /[?&]shot\b/.test(location.search) ? /[?&]settings=(\w+)/.exec(location.search)?.[1] : undefined; const [modal, setModal] = useState(shotSettings ? 'settings' : null); + // "Browse the Agent Store" in the agent menu: open the Agent Lab, which switches itself to the Store + useEffect(() => { + const on = () => setModal('agents'); + window.addEventListener(OPEN_AGENT_STORE, on); + return () => window.removeEventListener(OPEN_AGENT_STORE, on); + }, []); const [formFactorOpen, setFormFactorOpen] = useState(false); const [settingsTab, setSettingsTab] = useState((shotSettings as SettingsTabId) || 'general'); const [labAgent, setLabAgent] = useState(); @@ -605,7 +612,8 @@ export function App() { <> setModal(null)} agents={chatAgents} conversations={convs} theme={theme} actions={paletteActions} settings={settings} /> setModal(null)} agents={agents} tools={tools} models={models} initialId={labAgent} - onChanged={id => { loadAgents(); if (id) setLabAgent(id); }} /> + onChanged={id => { loadAgents(); if (id) setLabAgent(id); }} + onChat={id => { const a = agents.find(x => x.id === id); if (a) selectAgent(a); }} /> { setModal(null); setToolsTab(undefined); }} tools={tools} settings={settings} onChanged={loadIntegrations} tab={toolsTab} /> setNewProject(false)} diff --git a/ui/src/agentPicks.ts b/ui/src/agentPicks.ts new file mode 100644 index 0000000..94d5732 --- /dev/null +++ b/ui/src/agentPicks.ts @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import type { Agent } from './types'; + +// Which agents the composer's agent dropdown lists (components/composer/ComposerMenus.tsx). Per browser, like the +// ambience prefs: `pinned` is the person's own choice from "More agents" (null = not chosen yet, so the dropdown +// shows the most-used ones), `uses` counts picks from the dropdown and the phone's agent sheet. +export interface AgentPicks { + pinned: string[] | null; + uses: Record; +} + +const KEY = 'shellb.agentPicks'; +const EVENT = 'shellb-agent-picks'; +/** How many agents the dropdown shows before the person pins their own. */ +export const QUICK_COUNT = 6; + +export function loadAgentPicks(): AgentPicks { + try { + const p = JSON.parse(localStorage.getItem(KEY) || '{}'); + return { pinned: Array.isArray(p.pinned) ? p.pinned : null, uses: p.uses && typeof p.uses === 'object' ? p.uses : {} }; + } catch { + return { pinned: null, uses: {} }; + } +} + +function save(next: AgentPicks) { + try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* private mode */ } + window.dispatchEvent(new CustomEvent(EVENT, { detail: next })); +} + +export function recordAgentPick(id: string) { + const p = loadAgentPicks(); + save({ ...p, uses: { ...p.uses, [id]: (p.uses[id] ?? 0) + 1 } }); +} + +/** Pin or unpin one agent. The first pin starts from what the dropdown showed, so nothing disappears unasked. */ +export function toggleAgentPin(id: string, shown: string[]) { + const p = loadAgentPicks(); + const pinned = p.pinned ?? shown; + save({ ...p, pinned: pinned.includes(id) ? pinned.filter(x => x !== id) : [...pinned, id] }); +} + +/** Put an agent in the menu (a fresh install from the Agent Store), keeping what the menu showed. */ +export function pinAgent(id: string, shown: string[]) { + const p = loadAgentPicks(); + const pinned = p.pinned ?? shown; + if (!pinned.includes(id)) save({ ...p, pinned: [...pinned, id] }); +} + +/** Opens the Agent Lab on its Store; the app shells open the Lab and the Lab switches to the Store. */ +export const OPEN_AGENT_STORE = 'shellb-agent-store'; +export const openAgentStore = () => window.dispatchEvent(new Event(OPEN_AGENT_STORE)); + +/** Back to the most-used agents. */ +export function resetAgentPins() { + save({ ...loadAgentPicks(), pinned: null }); +} + +export function useAgentPicks(): AgentPicks { + const [picks, setPicks] = useState(loadAgentPicks); + useEffect(() => { + const on = (e: Event) => setPicks((e as CustomEvent).detail); + const onStorage = (e: StorageEvent) => { if (e.key === KEY) setPicks(loadAgentPicks()); }; + window.addEventListener(EVENT, on); + window.addEventListener('storage', onStorage); + return () => { window.removeEventListener(EVENT, on); window.removeEventListener('storage', onStorage); }; + }, []); + return picks; +} + +/** The dropdown's agents: the pinned ones in pin order, or else the most-used local agents (roster order breaks ties). + * Cloud agents only appear here when pinned; otherwise they stay in the Cloud submenu. */ +export function quickAgents(agents: Agent[], picks: AgentPicks): Agent[] { + if (picks.pinned) { + const byId = new Map(agents.map(a => [a.id, a])); + return picks.pinned.map(id => byId.get(id)).filter((a): a is Agent => !!a); + } + return agents + .filter(a => !a.cloud) + .map((a, i) => ({ a, i, n: picks.uses[a.id] ?? 0 })) + .sort((x, y) => y.n - x.n || x.i - y.i) + .slice(0, QUICK_COUNT) + .sort((x, y) => x.i - y.i) + .map(x => x.a); +} + +/** Case-insensitive match on name, title and description. */ +export const matchesAgent = (a: Agent, q: string) => + !q || `${a.name} ${a.title} ${a.description}`.toLowerCase().includes(q.trim().toLowerCase()); diff --git a/ui/src/components/AgentLab.tsx b/ui/src/components/AgentLab.tsx index 50a5ac8..0cf6983 100644 --- a/ui/src/components/AgentLab.tsx +++ b/ui/src/components/AgentLab.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react'; import clsx from 'clsx'; -import { BrainCircuit, Cloud, Copy, Cpu, Network, Plus, RotateCcw, Save, Sparkles, Trash2 } from 'lucide-react'; +import { BrainCircuit, Cloud, Copy, Cpu, Network, Plus, RotateCcw, Save, Sparkles, Store, Trash2 } from 'lucide-react'; import type { Agent, Effort, ModelInfo, Skill, ToolInfo } from '../types'; import { api, fmtBytes } from '../api'; import { AgentAvatar, Modal, btn, toolIcon } from './common'; import { connectorIcon } from './Integrations'; import { t as tx } from '../i18n'; +import { AgentStore } from './Gallery'; +import { OPEN_AGENT_STORE, loadAgentPicks, pinAgent, quickAgents } from '../agentPicks'; const isCloudModel = (m: string) => /^(anthropic|gemini|openai|claude-code|antigravity|codex)\//.test(m); @@ -31,9 +33,19 @@ interface Props { models: ModelInfo[]; initialId?: string; onChanged: (focusId?: string) => void; + /** start chatting with an agent (the Store's Chat button); the Lab closes */ + onChat?: (id: string) => void; } -export function AgentLab({ open, onClose, agents, tools, models, initialId, onChanged }: Props) { +export function AgentLab({ open, onClose, agents, tools, models, initialId, onChanged, onChat }: Props) { + // the Store (community agents) in place of the editor; "Browse the Agent Store" in the agent menu opens the Lab on it + const [store, setStore] = useState(false); + useEffect(() => { + const on = () => setStore(true); + window.addEventListener(OPEN_AGENT_STORE, on); + return () => window.removeEventListener(OPEN_AGENT_STORE, on); + }, []); + useEffect(() => { if (!open) setStore(false); }, [open]); const [selId, setSelId] = useState(initialId ?? agents[0]?.id); const [draft, setDraft] = useState(null); const [ratings, setRatings] = useState>({}); // thumbs per agent, last 30 days (server/feedback.py) @@ -94,9 +106,9 @@ export function AgentLab({ open, onClose, agents, tools, models, initialId, onCh
{agents.map(a => ( - ))} - +
+ {store ? ( +
+ { pinAgent(id, quickAgents(agents, loadAgentPicks()).map(a => a.id)); onChanged(); }} + onOpen={id => { setStore(false); setIsNew(false); setSelId(id); }} + onChat={onChat ? id => { onChat(id); onClose(); } : undefined} /> +
+ ) : (<> +
@@ -279,9 +304,10 @@ export function AgentLab({ open, onClose, agents, tools, models, initialId, onCh {error &&
{error}
}
+ )}
-
+ {!store &&
{!isNew && } {!isNew && draft.builtin && ( @@ -298,7 +324,7 @@ export function AgentLab({ open, onClose, agents, tools, models, initialId, onCh {isNew && }
-
+
} ); } diff --git a/ui/src/components/Gallery.tsx b/ui/src/components/Gallery.tsx index b1104f6..272571e 100644 --- a/ui/src/components/Gallery.tsx +++ b/ui/src/components/Gallery.tsx @@ -1,15 +1,15 @@ -// Community gallery (server/gallery.py): themes and screensavers published on GitHub, browsed from Settings → -// Appearance and installed with one click. Themes arrive as data and go through the server's validator; screensavers +// Community gallery (server/gallery.py): themes, screensavers, templates and agents published on GitHub, browsed from +// Settings → Appearance, the Studio and the Agent Lab's Store, and installed with one click. Themes arrive as data and go through the server's validator; screensavers // become Screensaver projects in the Studio. Installing needs the owner (or the admin key), browsing doesn't. import { useCallback, useEffect, useState } from 'react'; import clsx from 'clsx'; -import { Check, Download, ExternalLink, Globe, MonitorCheck, MonitorPlay, RefreshCw } from 'lucide-react'; +import { Check, Download, ExternalLink, Globe, MessageSquare, MonitorCheck, MonitorPlay, RefreshCw, Search } from 'lucide-react'; import { api, req } from '../api'; -import type { Settings } from '../types'; +import type { Agent, Settings, ToolInfo } from '../types'; import { customInfo, loadCustomThemes, setTheme, useTheme, type CustomTheme, type Mode } from '../themes'; import { PREVIEW_SAVER, loadAmbience, saveAmbience, useAmbience } from '../ambience'; import { Thumb } from './ThemePicker'; -import { Modal, btn } from './common'; +import { AgentAvatar, Modal, btn } from './common'; import { toast } from './toast'; import { t as tx } from '../i18n'; @@ -18,10 +18,12 @@ interface Item { id: string; name: string; blurb: string; author: string; author export interface GalleryTheme extends Item { mode: Mode; theme: CustomTheme } export interface GallerySaver extends Item { clockSafe: boolean; preview: string | null; files: string[] } export interface GalleryTemplate extends Item { kind: string; entry: string; brief: string; preview: string | null; files: string[] } +export interface GalleryAgent extends Item { title: string; avatar: string; color: [string, string]; needs: string[]; model: string; + effort: string; tools: string[] } interface Source { source: string; repo: string; sha: string; fetched: number; url: string; items: T[] } interface Listing { kind: string; sources: Source[]; errors: string[] } -function useListing(kind: 'theme' | 'screensaver' | 'template', open = true) { +function useListing(kind: 'theme' | 'screensaver' | 'template' | 'agent', open = true) { const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -272,3 +274,97 @@ export function TemplatePicker({ kind, name, onCreated }: { kind: string; name:
); } + +/** Agent Lab → Store: ready-made agents from the community repositories. Installing adds the agent to the roster and + * to the agent menu; it runs on this Shell:B's own model (server/gallery.py install_agent). Written for someone who has + * never opened the Agent Lab: what the agent is for, what it can use, one button. */ +export function AgentStore({ agents, tools, onInstalled, onOpen, onChat }: { + agents: Agent[]; + tools: ToolInfo[]; + onInstalled: (id: string) => void; + onOpen: (id: string) => void; + onChat?: (id: string) => void; +}) { + const { data, loading, error, load } = useListing('agent'); + const [busy, setBusy] = useState(''); + const [query, setQuery] = useState(''); + const [tag, setTag] = useState(''); + const toolName = (id: string) => tools.find(t => t.id === id)?.displayName ?? id.replace(/_/g, ' '); + const all = (data?.sources ?? []).flatMap(s => s.items.map(it => ({ source: s.source, it }))); + const tags = [...new Set(all.flatMap(x => x.it.tags))].sort(); + const q = query.trim().toLowerCase(); + const shown = all.filter(({ it }) => (!tag || it.tags.includes(tag)) + && (!q || `${it.name} ${it.title} ${it.blurb} ${it.tags.join(' ')}`.toLowerCase().includes(q))); + const install = async (source: string, it: GalleryAgent) => { + setBusy(`${source}:${it.id}`); + try { + const res = await req<{ agent: Agent; notes: string[] }>('/api/gallery/install', { method: 'POST', body: JSON.stringify({ kind: 'agent', source, id: it.id }) }); + onInstalled(res.agent.id); + toast(res.notes.length ? `${tx('{name} is in your agent menu.', { name: it.name })} ${res.notes.join(' ')}` : tx('{name} is in your agent menu.', { name: it.name })); + load(); + } catch (e) { installError(e); } finally { setBusy(''); } + }; + return ( +
+
load(true)} /> +

+ {tx('Each agent is a set of instructions that runs on your own model, here on this machine. Installing one adds it to your agent menu; you can change or delete it any time.')} +

+
+ + {tags.length > 1 && ['', ...tags].map(tg => ( + + ))} +
+ + {data && all.length > 0 && shown.length === 0 &&
{tx('No agent matches')}
} +
+ {shown.map(({ source, it }) => { + const key = `${source}:${it.id}`; + const mine = it.installed ? agents.find(a => a.id === it.installed) : undefined; + const update = !!mine && it.installedVersion !== it.version; + return ( +
+
+ +
+
{it.name} · {it.title}
+
{it.blurb}
+
+
+ {it.tools.length > 0 && ( +
+ {tx('Can use: {tools}', { tools: it.tools.map(toolName).join(', ') })} +
+ )} +
+ +
+ {mine && onChat && !update && ( + + )} + {mine && !update && ( + + )} + {(!mine || update) && ( + + )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/ui/src/components/composer/ComposerMenus.tsx b/ui/src/components/composer/ComposerMenus.tsx index 5579271..1b459e1 100644 --- a/ui/src/components/composer/ComposerMenus.tsx +++ b/ui/src/components/composer/ComposerMenus.tsx @@ -10,9 +10,14 @@ import { Cloud, Cpu, ListChecks, + MoreHorizontal, + Pin, + Search, + Store, } from 'lucide-react'; import type { Agent, Effort, ModelInfo } from '../../types'; import { AgentAvatar } from '../common'; +import { matchesAgent, openAgentStore, quickAgents, recordAgentPick, resetAgentPins, toggleAgentPin, useAgentPicks } from '../../agentPicks'; export const EFFORTS: { id: Effort; label: string; hint: string }[] = [ { id: 'off', label: 'Instant', hint: 'No deliberation' }, @@ -50,23 +55,39 @@ export function ComposerMenus({ }: ComposerMenusProps) { const [menu, setMenu] = useState<'agent' | 'effort' | 'model' | null>(null); const [cloudOpen, setCloudOpen] = useState(false); + // 'quick': the pinned or most-used agents; 'all': every agent, searchable, with pins + const [view, setView] = useState<'quick' | 'all'>('quick'); + const [query, setQuery] = useState(''); + const picks = useAgentPicks(); useEffect(() => { - if (menu !== 'agent') setCloudOpen(false); + if (menu !== 'agent') { + setCloudOpen(false); + setView('quick'); + setQuery(''); + } }, [menu]); - const localAgents = agents.filter(a => !a.cloud); - const cloudAgents = agents.filter(a => a.cloud); + const quick = quickAgents(agents, picks); + // the agent in use always shows, even when it isn't pinned (a cloud one already shows on the Cloud row) + if (!agent.cloud && !quick.some(a => a.id === agent.id) && agents.some(a => a.id === agent.id)) quick.unshift(agent); + const quickIds = quick.map(a => a.id); + const cloudAgents = agents.filter(a => a.cloud && !quickIds.includes(a.id)); + const hidden = agents.length - quick.length - cloudAgents.length; + const found = [...agents.filter(a => !a.cloud), ...agents.filter(a => a.cloud)].filter(a => matchesAgent(a, query)); + + const choose = (a: Agent) => { + recordAgentPick(a.id); + onAgent(a); + setMenu(null); + }; const agentItem = (a: Agent) => ( ); + const pinnedRow = (a: Agent) => { + const pinned = quickIds.includes(a.id); + return ( +
+
{agentItem(a)}
+ +
+ ); + }; + return ( <>
@@ -102,12 +145,66 @@ export function ComposerMenus({
6 && 'sm:w-[36rem]' // two columns, so the whole roster fits without scrolling + view === 'all' && 'sm:w-80' )} role="menu" > -
6 && 'sm:grid sm:grid-cols-2 sm:gap-x-0.5')}> - {localAgents.map(agentItem)} + {view === 'all' ? ( + <> +
+ + +
+
+ {found.map(pinnedRow)} + {found.length === 0 && ( +
{t('No agent matches')}
+ )} +
+
+ + {picks.pinned ? t('Pinned agents show in the menu') : t('The menu shows your most-used agents; pin some to choose')} + + {picks.pinned && ( + + )} +
+ + + ) : ( + <> +
+ {quick.map(agentItem)}
{cloudAgents.length > 0 && (
setCloudOpen(true)} onMouseLeave={() => setCloudOpen(false)} > - {localAgents.length > 0 &&
} + {quick.length > 0 &&
}
)} +
+ + + )}
)}
diff --git a/ui/src/i18n/fr.json b/ui/src/i18n/fr.json index 663213d..37d491f 100644 --- a/ui/src/i18n/fr.json +++ b/ui/src/i18n/fr.json @@ -4035,5 +4035,23 @@ "Checks finished": "Vérifications terminées", "Run checks": "Lancer les vérifications", "Passes checks": "passent les vérifications", - "Every string passes the checks": "Toutes les chaînes passent les vérifications" + "Every string passes the checks": "Toutes les chaînes passent les vérifications", + "Search agents": "Rechercher un agent", + "No agent matches": "Aucun agent ne correspond", + "More agents": "Plus d’agents", + "{n} more": "{n} de plus", + "Remove from the menu": "Retirer du menu", + "Show in the menu": "Afficher dans le menu", + "Remove {name} from the menu": "Retirer {name} du menu", + "Show {name} in the menu": "Afficher {name} dans le menu", + "Pinned agents show in the menu": "Les agents épinglés s’affichent dans le menu", + "The menu shows your most-used agents; pin some to choose": "Le menu affiche vos agents les plus utilisés ; épinglez-en pour choisir", + "Use most-used": "Les plus utilisés", + "Agent Store": "Boutique d’agents", + "Browse the Agent Store": "Parcourir la boutique d’agents", + "Ready-made agents for writing, studying, planning, games and more": "Des agents prêts à l’emploi pour écrire, étudier, organiser, jouer et plus encore", + "Each agent is a set of instructions that runs on your own model, here on this machine. Installing one adds it to your agent menu; you can change or delete it any time.": "Chaque agent est un ensemble d’instructions qui tourne sur votre propre modèle, ici sur cette machine. L’installer l’ajoute à votre menu d’agents ; vous pouvez le modifier ou le supprimer à tout moment.", + "No agents published yet. Make one in the Agent Lab and share it on GitHub.": "Aucun agent publié pour l’instant. Créez-en un dans le Labo des agents et partagez-le sur GitHub.", + "Can use: {tools}": "Peut utiliser : {tools}", + "{name} is in your agent menu.": "{name} est dans votre menu d’agents." } diff --git a/ui/src/kiosk/KioskApp.tsx b/ui/src/kiosk/KioskApp.tsx index fa8b520..9cf77a7 100644 --- a/ui/src/kiosk/KioskApp.tsx +++ b/ui/src/kiosk/KioskApp.tsx @@ -48,6 +48,7 @@ import { Feeds } from '../components/Feeds'; import { MapsApp } from '../maps/MapsApp'; import { RadioApp } from '../radio/RadioApp'; import { AgentLab } from '../components/AgentLab'; +import { OPEN_AGENT_STORE } from '../agentPicks'; import { FormFactorSwitcher } from '../mobile/FormFactorSwitcher'; import { isThemeId, nextTheme, setTheme as applyTheme } from '../themes'; @@ -142,6 +143,12 @@ export function KioskApp() { const [kioskLocked, setKioskLocked] = useState(() => /[?&]lock=1/.test(location.search)); const [isFullscreen, setIsFullscreen] = useState(false); const [modal, setModal] = useState(null); + // "Browse the Agent Store" in the agent menu: open the Agent Lab, which switches itself to the Store + useEffect(() => { + const on = () => setModal('agents'); + window.addEventListener(OPEN_AGENT_STORE, on); + return () => window.removeEventListener(OPEN_AGENT_STORE, on); + }, []); const [settingsTab, setSettingsTab] = useState(() => { if (typeof window !== 'undefined') { const t = new URLSearchParams(window.location.search).get('tab'); diff --git a/ui/src/mobile/MobileAgentPicker.tsx b/ui/src/mobile/MobileAgentPicker.tsx index 7729200..4b5e066 100644 --- a/ui/src/mobile/MobileAgentPicker.tsx +++ b/ui/src/mobile/MobileAgentPicker.tsx @@ -1,8 +1,10 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import clsx from 'clsx'; -import { Check, X, Sparkles, Cloud } from 'lucide-react'; +import { Check, X, Sparkles, Cloud, Search, Store } from 'lucide-react'; import type { Agent } from '../types'; import { AgentAvatar } from '../components/common'; +import { matchesAgent, openAgentStore, recordAgentPick } from '../agentPicks'; +import { t } from '../i18n'; interface Props { open: boolean; @@ -13,8 +15,9 @@ interface Props { } export function MobileAgentPicker({ open, onClose, agents, activeId, onSelect }: Props) { + const [query, setQuery] = useState(''); useEffect(() => { - if (!open) return; + if (!open) { setQuery(''); return; } const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -45,13 +48,27 @@ export function MobileAgentPicker({ open, onClose, agents, activeId, onSelect }: + {agents.length > 6 && ( + + )} +
- {agents.map(a => { + {agents.filter(a => matchesAgent(a, query)).map(a => { const active = a.id === activeId; return (
+ +
); diff --git a/ui/src/mobile/MobileApp.tsx b/ui/src/mobile/MobileApp.tsx index aaa3680..1bbbb65 100644 --- a/ui/src/mobile/MobileApp.tsx +++ b/ui/src/mobile/MobileApp.tsx @@ -46,6 +46,7 @@ import { Secretary } from '../components/Secretary'; import { MapsApp } from '../maps/MapsApp'; import { RadioApp } from '../radio/RadioApp'; import { AgentLab } from '../components/AgentLab'; +import { OPEN_AGENT_STORE } from '../agentPicks'; import { isThemeId, nextTheme, setTheme as applyTheme } from '../themes'; import { isCell, type GuestPanel } from '../cell'; import { GuestPanelHost } from '../components/CellGuest'; @@ -198,6 +199,12 @@ export function MobileApp() { const [doc, setDoc] = useState(null); useEffect(() => onDocView(setDoc), []); const [modal, setModal] = useState(null); + // "Browse the Agent Store" in the agent menu: open the Agent Lab, which switches itself to the Store + useEffect(() => { + const on = () => setModal('agents'); + window.addEventListener(OPEN_AGENT_STORE, on); + return () => window.removeEventListener(OPEN_AGENT_STORE, on); + }, []); const [guestPanel, setGuestPanel] = useState(null); // a guest's cell: the gateway's panels (CellGuest.tsx) const [settingsTab, setSettingsTab] = useState('general'); const [labAgent, setLabAgent] = useState(); @@ -1172,6 +1179,10 @@ export function MobileApp() { loadAgents(); if (id) setLabAgent(id); }} + onChat={id => { + const a = agents.find(x => x.id === id); + if (a) selectAgent(a); + }} />