feat(ai): model profiles, capability discovery, and agentless /ai list|models

Make connecting any model a config step, not a code change:
- models.toml named profiles (api_key_env names an env var, never the key)
- providers gain available_models(); add preflight + --list-models/--check
- /ai list and /ai models in-room; client probes local Ollama for
  /ai models when no agent is running, and /ai list hints to summon one
- docs/providers.md provider guide + examples/echo_provider.py
- README: command table, AI section, layout updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-01 15:25:07 -07:00
parent 4fd1b70cb3
commit 65df12de9e
10 changed files with 592 additions and 21 deletions
+38 -1
View File
@@ -62,7 +62,44 @@ class AgentBridge(Client):
frame = json.dumps({"_ai": "typing", "name": self.name, "on": on})
await ws.send(self.room_fernet.encrypt(frame.encode()).decode())
async def _send_chat(self, ws, text: str) -> None:
await ws.send(self.room_fernet.encrypt(text.encode()).decode())
def _command_reply(self, question: str) -> str | None:
"""Canned reply for a reserved verb, else None.
Handled locally so it never spends a model call:
- ``list`` → this agent's roster line (who's here / what it runs). With
several agents present each answers for itself, forming the roster.
- ``models`` → what the configured backend can serve (in-room
--list-models)."""
verb = question.strip().lower()
if verb == "list":
return (f"{self.name} (ai) here — {self.provider.name}/"
f"{self.provider.model}, context {self.context_window}")
if verb != "models":
return None
discover = getattr(self.provider, "available_models", None)
if discover is None:
return f"{self.provider.name}: model discovery not supported."
try:
models = discover()
except Exception as e: # noqa: BLE001 — report unreachable backend in-room
return f"[ai error: cannot reach {self.provider.name}: {e}]"
if not models:
return f"{self.provider.name}: no models reported."
# One line: the TUI collapses embedded newlines, so bracket the active
# model instead of using a multi-line, marker-prefixed list.
mark = lambda m: f"[{m}]" if m == self.provider.model else m # noqa: E731
listing = ", ".join(mark(m) for m in models)
return f"{self.provider.name} models ([active]): {listing}"
async def _answer(self, ws, question: str, asker: str) -> None:
canned = self._command_reply(question)
if canned is not None:
await self._send_chat(ws, canned)
self.success(f"answered /ai {question.strip().lower()} for {asker}")
return
self.transcript.append(Msg("user", f"{asker}: {question}"))
await self._send_typing(ws, True)
try:
@@ -77,7 +114,7 @@ class AgentBridge(Client):
await self._send_typing(ws, False)
reply = reply.strip() or "[empty reply]"
self.transcript.append(Msg("assistant", reply))
await ws.send(self.room_fernet.encrypt(reply.encode()).decode())
await self._send_chat(ws, reply)
self.success(f"replied to {asker}")
async def run_async(self) -> None: