The Pirate — Phase 1.a: conversational read-only media agent
Ships a chat-based agent at /pirate that LLM-routes user questions to media-stack tools and returns natural-language answers grounded in real data. Foundation built on top of the existing API-tokens + dual-auth infrastructure so other apps (Open WebUI, HA voice, Synap) can consume the same Pirate API. New subsystem (not the standard trigger/result pattern): - pirate_conversations + pirate_messages tables - service_configs table (admin-wide creds shared by media agents) - /api/pirate/chat + /api/pirate/conversations/* (dual-auth: user session OR Bearer token scoped to user's pirate instance) - /api/internal/pirate/* endpoints used by runtime subprocess - /api/admin/services + Services tab in admin.html for cred management - Auto-seeded service_configs on startup from Media Stack Reference defaults (never overwrite admin edits) - Auto-seeded pirate catalog entry + per-user pirate instance on startup Pirate package (agents/pirate/): - prompts.py: system prompt, enforces read-only in Phase 1 - runtime.py: Anthropic-native tool-use loop (max 8 iterations, persists every turn) - tools/_common.py: service_configs fetch + qBit session auth - tools/sonarr.py: queue, upcoming, series_search, library_stats - tools/radarr.py: queue, movie_search, library_stats - tools/qbittorrent.py: torrents, transfer_stats, categories - tools/storage.py: disk_space (via Sonarr diskspace API) - Default model: claude-sonnet-4-5 (Haiku fumbles multi-step chains) Dashboard: - static/pirate.html — full chat UI with conversation sidebar, suggestion chips, inline tool-call visualization, 24h idle reset + New Chat button - Pirate button added to main dashboard header Wiki reorg: Agents / Developer Guides / Plans parent docs, per-agent reference docs, The Pirate doc. API Clients + Calling Agents docs moved under Developer Guides. Working folder: PIRATE_PHASE_1A.md + NEXT_SESSION_PROMPT.md for fast bootstrap. Smoke tested end-to-end: real tool calls against qBittorrent (13 active torrents correctly reported) and Sonarr disk-space; multi-turn conversation state preserved across follow-up questions. On deck: Phase 1.b (Lidarr/Whisparr/Overseerr/Plex tools), then 1.d (OWUI pipeline), then 1.c (HA voice).
This commit is contained in:
+449
-2
@@ -14,7 +14,11 @@ from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from database import get_db, init_db, SessionLocal
|
||||
from models import User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog, APIClient, APIClientScope, APIClientCall
|
||||
from models import (
|
||||
User, AgentCatalog, AgentInstance, Run, LLMProvider, Bridge, RouteLog,
|
||||
APIClient, APIClientScope, APIClientCall,
|
||||
ServiceConfig, PirateConversation, PirateMessage,
|
||||
)
|
||||
|
||||
app = FastAPI(title="Agent Command Center", version="2026.04.12.01")
|
||||
|
||||
@@ -226,6 +230,18 @@ class APIClientUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
instance_ids: Optional[list[int]] = None # replaces existing scopes if provided
|
||||
|
||||
class ServiceConfigUpsert(BaseModel):
|
||||
service_name: str
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
extra: dict = {}
|
||||
|
||||
class PirateChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: Optional[int] = None # None = auto (pick latest non-stale thread, or start new)
|
||||
|
||||
|
||||
# --- Auth Routes ---
|
||||
|
||||
@@ -1020,6 +1036,105 @@ def admin_api_client_calls(client_id: int, limit: int = 50, admin: dict = Depend
|
||||
} for c in calls]
|
||||
|
||||
|
||||
# --- Admin: Service Configs (system-wide creds shared by agents) ---
|
||||
|
||||
# Services The Pirate agent knows how to talk to. Admin fills in URLs + keys.
|
||||
KNOWN_SERVICES = [
|
||||
("sonarr", "Sonarr", "TV show management"),
|
||||
("radarr", "Radarr", "Movie management"),
|
||||
("lidarr", "Lidarr", "Music management"),
|
||||
("whisparr", "Whisparr", "Adult content management"),
|
||||
("prowlarr", "Prowlarr", "Indexer aggregator"),
|
||||
("bazarr", "Bazarr", "Subtitle management"),
|
||||
("overseerr", "Overseerr", "Media request portal"),
|
||||
("qbittorrent", "qBittorrent", "Torrent client"),
|
||||
("plex", "Plex", "Media server (watch history, on-deck)"),
|
||||
]
|
||||
|
||||
|
||||
def _serialize_service(s: ServiceConfig, include_secrets: bool = False) -> dict:
|
||||
return {
|
||||
"service_name": s.service_name,
|
||||
"base_url": s.base_url or "",
|
||||
"api_key": (s.api_key or "") if include_secrets else ("set" if s.api_key else ""),
|
||||
"username": s.username or "",
|
||||
"password": ("set" if s.password else "") if not include_secrets else (s.password or ""),
|
||||
"extra": s.extra or {},
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
"configured": bool(s.base_url),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/services")
|
||||
def admin_list_services(admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
"""List all known services + whether admin has filled them in. Secrets are masked."""
|
||||
existing = {s.service_name: s for s in db.query(ServiceConfig).all()}
|
||||
out = []
|
||||
for slug, label, desc in KNOWN_SERVICES:
|
||||
s = existing.get(slug)
|
||||
if s:
|
||||
row = _serialize_service(s)
|
||||
else:
|
||||
row = {
|
||||
"service_name": slug, "base_url": "", "api_key": "", "username": "",
|
||||
"password": "", "extra": {}, "updated_at": None, "configured": False,
|
||||
}
|
||||
row["label"] = label
|
||||
row["description"] = desc
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
@app.put("/api/admin/services/{service_name}")
|
||||
def admin_upsert_service(service_name: str, data: ServiceConfigUpsert,
|
||||
admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
known = {s for s, _, _ in KNOWN_SERVICES}
|
||||
if service_name not in known:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown service: {service_name}")
|
||||
existing = db.query(ServiceConfig).filter(ServiceConfig.service_name == service_name).first()
|
||||
if existing:
|
||||
existing.base_url = data.base_url
|
||||
# Preserve existing secrets if field is empty (lets you edit url without re-typing keys)
|
||||
if data.api_key:
|
||||
existing.api_key = data.api_key
|
||||
if data.password:
|
||||
existing.password = data.password
|
||||
existing.username = data.username or existing.username
|
||||
if data.extra:
|
||||
existing.extra = data.extra
|
||||
else:
|
||||
db.add(ServiceConfig(
|
||||
service_name=service_name,
|
||||
base_url=data.base_url,
|
||||
api_key=data.api_key,
|
||||
username=data.username,
|
||||
password=data.password,
|
||||
extra=data.extra or {},
|
||||
))
|
||||
db.commit()
|
||||
return {"service_name": service_name, "status": "saved"}
|
||||
|
||||
|
||||
@app.delete("/api/admin/services/{service_name}")
|
||||
def admin_delete_service(service_name: str, admin: dict = Depends(require_admin), db: Session = Depends(get_db)):
|
||||
existing = db.query(ServiceConfig).filter(ServiceConfig.service_name == service_name).first()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404)
|
||||
db.delete(existing)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@app.get("/api/internal/services/{service_name}")
|
||||
def internal_get_service(service_name: str, db: Session = Depends(get_db)):
|
||||
"""Internal endpoint used by agents running in subprocess. No auth — same pattern as
|
||||
the existing /api/instances/{id}/config endpoint. Returns full creds."""
|
||||
s = db.query(ServiceConfig).filter(ServiceConfig.service_name == service_name).first()
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{service_name}' not configured")
|
||||
return _serialize_service(s, include_secrets=True)
|
||||
|
||||
|
||||
# --- Admin: Catalog Management ---
|
||||
|
||||
class CatalogCreate(BaseModel):
|
||||
@@ -1495,6 +1610,249 @@ def root(session: Optional[str] = Cookie(None)):
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.get("/pirate")
|
||||
def pirate_page(session: Optional[str] = Cookie(None)):
|
||||
user = get_current_user(session)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return FileResponse("static/pirate.html")
|
||||
|
||||
|
||||
# --- The Pirate: conversational media agent ---
|
||||
|
||||
PIRATE_IDLE_RESET_HOURS = 24
|
||||
|
||||
|
||||
def _caller_user_id_for_pirate(caller: dict, db: Session) -> int:
|
||||
"""Pirate is a per-user agent. Resolve the target user from the caller.
|
||||
- user session: the logged-in user
|
||||
- api token: the user_id of the pirate instance the token is scoped to
|
||||
Tokens scoped to zero or multiple pirate instances are rejected.
|
||||
"""
|
||||
if caller["kind"] == "user":
|
||||
return caller["user_id"]
|
||||
# API token — find pirate instances it can reach
|
||||
pirate_scopes = db.query(AgentInstance).filter(
|
||||
AgentInstance.id.in_(caller["allowed_instance_ids"]),
|
||||
AgentInstance.catalog_id == "pirate",
|
||||
).all()
|
||||
if not pirate_scopes:
|
||||
raise HTTPException(status_code=403, detail="Token is not scoped to a Pirate instance")
|
||||
if len(pirate_scopes) > 1:
|
||||
raise HTTPException(status_code=400, detail="Token scoped to multiple Pirate instances; ambiguous")
|
||||
return pirate_scopes[0].user_id
|
||||
|
||||
|
||||
def _pick_or_create_conversation(db: Session, user_id: int, conversation_id: Optional[int]) -> PirateConversation:
|
||||
"""If conversation_id is provided, load it (and authorize). Otherwise find the user's
|
||||
most recent conversation; reuse it if active (< idle window), else start a new one."""
|
||||
if conversation_id:
|
||||
conv = db.query(PirateConversation).filter(PirateConversation.id == conversation_id).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
if conv.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Not your conversation")
|
||||
return conv
|
||||
# Auto-pick: latest active thread for this user, else new
|
||||
now = datetime.now(timezone.utc)
|
||||
latest = db.query(PirateConversation).filter(
|
||||
PirateConversation.user_id == user_id,
|
||||
).order_by(PirateConversation.last_message_at.desc()).first()
|
||||
if latest:
|
||||
last = latest.last_message_at
|
||||
if last and last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
if last and (now - last) < timedelta(hours=PIRATE_IDLE_RESET_HOURS):
|
||||
return latest
|
||||
conv = PirateConversation(user_id=user_id, title="")
|
||||
db.add(conv)
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
def _serialize_pirate_message(m: PirateMessage) -> dict:
|
||||
return {
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content or "",
|
||||
"tool_calls": m.tool_calls,
|
||||
"tool_call_id": m.tool_call_id or "",
|
||||
"tool_name": m.tool_name or "",
|
||||
"tool_result": m.tool_result,
|
||||
"model": m.model or "",
|
||||
"input_tokens": m.input_tokens or 0,
|
||||
"output_tokens": m.output_tokens or 0,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_conversation(conv: PirateConversation, include_messages: bool = False) -> dict:
|
||||
out = {
|
||||
"id": conv.id,
|
||||
"user_id": conv.user_id,
|
||||
"title": conv.title or "",
|
||||
"created_at": conv.created_at.isoformat() if conv.created_at else None,
|
||||
"last_message_at": conv.last_message_at.isoformat() if conv.last_message_at else None,
|
||||
}
|
||||
if include_messages:
|
||||
out["messages"] = [_serialize_pirate_message(m) for m in conv.messages]
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/pirate/chat")
|
||||
def pirate_chat(
|
||||
data: PirateChatRequest,
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Send a message to The Pirate and get a response. Runs the LLM tool-use loop synchronously
|
||||
(Pirate conversations need the response immediately — no async polling pattern here)."""
|
||||
user_id = _caller_user_id_for_pirate(caller, db)
|
||||
conv = _pick_or_create_conversation(db, user_id, data.conversation_id)
|
||||
|
||||
# Persist the user turn
|
||||
user_msg = PirateMessage(conversation_id=conv.id, role="user", content=data.message)
|
||||
db.add(user_msg)
|
||||
if not conv.title:
|
||||
conv.title = data.message.strip()[:80]
|
||||
conv.last_message_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
# Invoke the Pirate runtime as a subprocess so it runs in the agent container's Python env
|
||||
# (where the tool package + LLM client live). Use a helper entry point.
|
||||
import subprocess
|
||||
agent_dir = "/app/agents"
|
||||
env = {
|
||||
**dict(os.environ),
|
||||
"PYTHONPATH": agent_dir,
|
||||
"PIRATE_CONVERSATION_ID": str(conv.id),
|
||||
"PIRATE_USER_ID": str(user_id),
|
||||
}
|
||||
result = subprocess.run(
|
||||
["python3", "-c",
|
||||
"import sys; sys.path.insert(0, '/app/agents'); "
|
||||
"from pirate.runtime import chat_turn; chat_turn()"],
|
||||
env=env, cwd=agent_dir, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = (result.stderr or result.stdout or "")[-2000:]
|
||||
err_msg = PirateMessage(
|
||||
conversation_id=conv.id, role="assistant",
|
||||
content=f"[Pirate error] {err[-500:]}",
|
||||
)
|
||||
db.add(err_msg)
|
||||
conv.last_message_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=500, detail=f"Pirate runtime failed: {err[-500:]}")
|
||||
|
||||
# Reload conversation to return fresh state (runtime appended assistant + tool messages)
|
||||
db.refresh(conv)
|
||||
log_api_client_call(db, caller, "POST /api/pirate/chat", None, 200)
|
||||
return _serialize_conversation(conv, include_messages=True)
|
||||
|
||||
|
||||
@app.get("/api/pirate/conversations")
|
||||
def pirate_list_conversations(
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = _caller_user_id_for_pirate(caller, db)
|
||||
convs = db.query(PirateConversation).filter(
|
||||
PirateConversation.user_id == user_id,
|
||||
).order_by(PirateConversation.last_message_at.desc()).limit(50).all()
|
||||
return [_serialize_conversation(c, include_messages=False) for c in convs]
|
||||
|
||||
|
||||
@app.get("/api/pirate/conversations/{conv_id}")
|
||||
def pirate_get_conversation(
|
||||
conv_id: int,
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = _caller_user_id_for_pirate(caller, db)
|
||||
conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404)
|
||||
if conv.user_id != user_id:
|
||||
raise HTTPException(status_code=403)
|
||||
return _serialize_conversation(conv, include_messages=True)
|
||||
|
||||
|
||||
@app.post("/api/pirate/conversations/new")
|
||||
def pirate_new_conversation(
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Force-start a new conversation thread (user clicked 'New Chat')."""
|
||||
user_id = _caller_user_id_for_pirate(caller, db)
|
||||
conv = PirateConversation(user_id=user_id, title="")
|
||||
db.add(conv)
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return _serialize_conversation(conv, include_messages=True)
|
||||
|
||||
|
||||
@app.delete("/api/pirate/conversations/{conv_id}")
|
||||
def pirate_delete_conversation(
|
||||
conv_id: int,
|
||||
caller: dict = Depends(require_user_or_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_id = _caller_user_id_for_pirate(caller, db)
|
||||
conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first()
|
||||
if not conv or conv.user_id != user_id:
|
||||
raise HTTPException(status_code=404)
|
||||
db.delete(conv)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# Internal endpoints used by the pirate runtime subprocess ------------------
|
||||
|
||||
@app.get("/api/internal/pirate/conversation/{conv_id}")
|
||||
def internal_get_conversation(conv_id: int, db: Session = Depends(get_db)):
|
||||
conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404)
|
||||
return _serialize_conversation(conv, include_messages=True)
|
||||
|
||||
|
||||
class InternalMessageCreate(BaseModel):
|
||||
role: str
|
||||
content: str = ""
|
||||
tool_calls: Optional[list] = None
|
||||
tool_call_id: str = ""
|
||||
tool_name: str = ""
|
||||
tool_result: Optional[dict] = None
|
||||
model: str = ""
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
|
||||
|
||||
@app.post("/api/internal/pirate/conversation/{conv_id}/messages")
|
||||
def internal_append_message(conv_id: int, data: InternalMessageCreate, db: Session = Depends(get_db)):
|
||||
conv = db.query(PirateConversation).filter(PirateConversation.id == conv_id).first()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404)
|
||||
msg = PirateMessage(
|
||||
conversation_id=conv_id,
|
||||
role=data.role,
|
||||
content=data.content,
|
||||
tool_calls=data.tool_calls,
|
||||
tool_call_id=data.tool_call_id,
|
||||
tool_name=data.tool_name,
|
||||
tool_result=data.tool_result,
|
||||
model=data.model,
|
||||
input_tokens=data.input_tokens,
|
||||
output_tokens=data.output_tokens,
|
||||
)
|
||||
db.add(msg)
|
||||
conv.last_message_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"id": msg.id}
|
||||
|
||||
|
||||
# --- Result schemas (what each agent's structured result looks like) ---
|
||||
|
||||
RESULT_SCHEMAS = {
|
||||
@@ -1557,9 +1915,96 @@ RESULT_SCHEMAS = {
|
||||
"generated_at": "ISO datetime string",
|
||||
},
|
||||
},
|
||||
"pirate": {
|
||||
"description": "Conversational read-only media agent. Chat with it about Sonarr, Radarr, qBittorrent, Plex. Phase 1 is read-only; Phase 2 adds media request + torrent control writes.",
|
||||
"shape": {
|
||||
"note": "Pirate does not use the run/result pattern. It lives behind /api/pirate/chat. Each call returns a full conversation history (messages with role, content, tool_calls, tool_result).",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Catalog entries that should exist even if admin never adds them manually.
|
||||
# Seeded on startup if missing.
|
||||
SEEDED_CATALOG_ENTRIES = [
|
||||
{
|
||||
"id": "pirate",
|
||||
"name": "The Pirate",
|
||||
"description": "Conversational read-only media agent (Phase 1). Chat about TV, movies, music, and downloads; no write actions yet.",
|
||||
"category": "intelligence",
|
||||
"config_schema": {
|
||||
"services": "Services the Pirate can query (configured system-wide in /admin → Services)",
|
||||
},
|
||||
"default_config": {},
|
||||
"supports_schedule": False,
|
||||
"is_sub_agent": False,
|
||||
"requires_llm": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed_catalog(db: Session):
|
||||
"""Insert baseline catalog entries for agents the platform itself depends on
|
||||
(like The Pirate). Idempotent — only inserts when id is missing."""
|
||||
for entry in SEEDED_CATALOG_ENTRIES:
|
||||
existing = db.query(AgentCatalog).filter(AgentCatalog.id == entry["id"]).first()
|
||||
if existing:
|
||||
continue
|
||||
db.add(AgentCatalog(**entry))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_pirate_instances(db: Session):
|
||||
"""Every non-admin user gets one Pirate instance automatically. Idempotent."""
|
||||
users = db.query(User).filter(User.role != "admin").all()
|
||||
for u in users:
|
||||
existing = db.query(AgentInstance).filter(
|
||||
AgentInstance.user_id == u.id, AgentInstance.catalog_id == "pirate",
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
db.add(AgentInstance(
|
||||
user_id=u.id,
|
||||
catalog_id="pirate",
|
||||
name=f"{u.display_name or u.username}'s Pirate",
|
||||
config={},
|
||||
schedule="manual",
|
||||
status="active",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
# Defaults loaded from the Media Stack Reference wiki page. Seeded only when the
|
||||
# service_configs table has no row for that slug — admin-entered values are never overwritten.
|
||||
_SEED_SERVICE_DEFAULTS = {
|
||||
"sonarr": {"base_url": "http://192.168.1.203:8989", "api_key": "d494ea4c9ec74d3793a9a84dfae7c4c8"},
|
||||
"radarr": {"base_url": "http://192.168.1.203:7878", "api_key": "4df49af333574d1d989e221375b928ef"},
|
||||
"lidarr": {"base_url": "http://192.168.1.203:8686", "api_key": "58ad42ac15e44001927226461d606c34"},
|
||||
"whisparr": {"base_url": "http://192.168.1.203:6969", "api_key": "99dee8e33f63470bad8b4e41bed6af4a"},
|
||||
"prowlarr": {"base_url": "http://192.168.1.203:9696", "api_key": "35bb6983a11d4decbcf4422be3218568"},
|
||||
"bazarr": {"base_url": "http://192.168.1.203:6767", "api_key": "4bc3869b8fef0b38c09f3da2754d5595"},
|
||||
"overseerr": {"base_url": "http://192.168.1.203:5055", "api_key": "MTc2OTI2OTIwNzU0MDdkYmNhMTg1LTgxZTMtNDdjOC04MTBhLTE2YzFlNjJiNzZhYw=="},
|
||||
"qbittorrent": {"base_url": "http://192.168.1.239:8080", "api_key": ""}, # LAN no-auth today
|
||||
# plex: no public token yet, leave empty for admin to fill in
|
||||
}
|
||||
|
||||
|
||||
def _seed_service_configs(db: Session):
|
||||
for slug, defaults in _SEED_SERVICE_DEFAULTS.items():
|
||||
existing = db.query(ServiceConfig).filter(ServiceConfig.service_name == slug).first()
|
||||
if existing:
|
||||
continue
|
||||
db.add(ServiceConfig(
|
||||
service_name=slug,
|
||||
base_url=defaults.get("base_url", ""),
|
||||
api_key=defaults.get("api_key", ""),
|
||||
username=defaults.get("username", ""),
|
||||
password=defaults.get("password", ""),
|
||||
extra=defaults.get("extra", {}),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_result_schemas(db: Session):
|
||||
"""Populate agent_catalog.result_schema for known agents. Idempotent — only fills empty."""
|
||||
for catalog_id, schema in RESULT_SCHEMAS.items():
|
||||
@@ -1574,9 +2019,11 @@ def _seed_result_schemas(db: Session):
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
init_db()
|
||||
# Seed result schemas for catalog entries that don't have them yet
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_seed_catalog(db)
|
||||
_seed_result_schemas(db)
|
||||
_seed_pirate_instances(db)
|
||||
_seed_service_configs(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -152,3 +152,55 @@ class APIClientCall(Base):
|
||||
endpoint = Column(String, default="") # e.g. "POST /api/instances/2/trigger"
|
||||
status_code = Column(Integer, default=0)
|
||||
called_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ServiceConfig(Base):
|
||||
"""System-wide service credentials used by agents (Sonarr, Radarr, qBit, Plex, etc.).
|
||||
Keyed by service name (e.g. 'sonarr'). Admin manages these centrally, shared across users."""
|
||||
__tablename__ = "service_configs"
|
||||
|
||||
service_name = Column(String, primary_key=True) # 'sonarr', 'radarr', 'qbittorrent', 'plex', ...
|
||||
base_url = Column(String, default="") # e.g. http://192.168.1.203:8989
|
||||
api_key = Column(String, default="") # API key or token (varies by service)
|
||||
username = Column(String, default="") # some services need both (qBit, Plex)
|
||||
password = Column(String, default="")
|
||||
extra = Column(JSON, default=dict) # any service-specific extras
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class PirateConversation(Base):
|
||||
"""A chat thread with The Pirate agent. Auto-resets 24h after last message unless
|
||||
user explicitly picks an old thread (client decides which conversation_id to use)."""
|
||||
__tablename__ = "pirate_conversations"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
title = Column(String, default="") # auto-set from first user message
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
last_message_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
messages = relationship("PirateMessage", back_populates="conversation",
|
||||
cascade="all, delete-orphan", order_by="PirateMessage.created_at")
|
||||
|
||||
|
||||
class PirateMessage(Base):
|
||||
"""One turn in a Pirate conversation. Role is 'user', 'assistant', or 'tool'.
|
||||
For 'assistant' turns with tool calls, tool_calls holds the LLM's requested calls.
|
||||
For 'tool' turns, tool_name + tool_result hold the execution output."""
|
||||
__tablename__ = "pirate_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id = Column(Integer, ForeignKey("pirate_conversations.id", ondelete="CASCADE"), nullable=False)
|
||||
role = Column(String, nullable=False) # 'user', 'assistant', 'tool'
|
||||
content = Column(Text, default="") # text content (user or assistant message)
|
||||
tool_calls = Column(JSON, nullable=True) # list of {id, name, input} for assistant turns
|
||||
tool_call_id = Column(String, default="") # matches an assistant.tool_calls[*].id on 'tool' turns
|
||||
tool_name = Column(String, default="") # which tool was called on 'tool' turns
|
||||
tool_result = Column(JSON, nullable=True) # structured result of the tool call
|
||||
model = Column(String, default="") # LLM model that produced this assistant turn
|
||||
input_tokens = Column(Integer, default=0)
|
||||
output_tokens = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
conversation = relationship("PirateConversation", back_populates="messages")
|
||||
|
||||
@@ -66,6 +66,7 @@ tr:hover td{background:var(--surface2)}
|
||||
<div class="tab" onclick="switchTab('llm')">LLM Providers</div>
|
||||
<div class="tab" onclick="switchTab('bridges')">Bridges</div>
|
||||
<div class="tab" onclick="switchTab('api-clients')">API Clients</div>
|
||||
<div class="tab" onclick="switchTab('services')">Services</div>
|
||||
<div class="tab" onclick="switchTab('system')">System</div>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +180,21 @@ tr:hover td{background:var(--surface2)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Services (system-wide creds used by agents like The Pirate) -->
|
||||
<div class="panel" id="panel-services">
|
||||
<div class="form-card">
|
||||
<h3>Service Credentials</h3>
|
||||
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
|
||||
Admin-level URLs + API keys shared across all users. The Pirate agent (and future media agents) use these to query Sonarr, Radarr, qBittorrent, Plex, etc.
|
||||
Leaving <code>api_key</code> / <code>password</code> blank preserves the existing stored value (useful when editing just the URL).
|
||||
</p>
|
||||
</div>
|
||||
<table id="services-table">
|
||||
<thead><tr><th>Service</th><th>Base URL</th><th>API Key / Password</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="panel" id="panel-system">
|
||||
<div class="stat-grid" id="sys-stats"></div>
|
||||
@@ -396,6 +412,49 @@ async function editApiClient(id){
|
||||
else{const e=await res2.json();alert(e.detail||'Error')}
|
||||
}
|
||||
|
||||
// --- Services ---
|
||||
async function loadServices(){
|
||||
const res=await fetch(API+'/api/admin/services');
|
||||
if(!res.ok)return;
|
||||
const services=await res.json();
|
||||
document.querySelector('#services-table tbody').innerHTML=services.map(s=>{
|
||||
const needsCreds=['qbittorrent','plex'].includes(s.service_name);
|
||||
const secretField=needsCreds
|
||||
? `<input placeholder="password" type="password" id="svc-pw-${s.service_name}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem">`
|
||||
: `<input placeholder="${s.api_key==='set'?'(stored — leave blank to keep)':'api key'}" type="password" id="svc-key-${s.service_name}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem">`;
|
||||
const userField=needsCreds
|
||||
? `<input placeholder="username" id="svc-user-${s.service_name}" value="${s.username||''}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem;margin-bottom:.25rem">`
|
||||
: '';
|
||||
const statusColor=s.configured?'var(--green)':'var(--yellow)';
|
||||
return `<tr>
|
||||
<td><strong>${s.label||s.service_name}</strong> <span style="color:${statusColor};font-size:.7rem">${s.configured?'●':'○'}</span>
|
||||
<div style="font-size:.7rem;color:var(--text-dim)">${s.description||''}</div></td>
|
||||
<td><input value="${s.base_url||''}" id="svc-url-${s.service_name}" placeholder="http://host:port" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem"></td>
|
||||
<td>${userField}${secretField}</td>
|
||||
<td style="font-size:.7rem;color:var(--text-dim)">${s.updated_at?new Date(s.updated_at).toLocaleDateString():'-'}</td>
|
||||
<td><button class="btn btn-sm btn-primary" onclick="saveService('${s.service_name}')">Save</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function saveService(slug){
|
||||
const base_url=document.getElementById('svc-url-'+slug).value.trim();
|
||||
const keyEl=document.getElementById('svc-key-'+slug);
|
||||
const pwEl=document.getElementById('svc-pw-'+slug);
|
||||
const userEl=document.getElementById('svc-user-'+slug);
|
||||
const body={
|
||||
service_name:slug,
|
||||
base_url,
|
||||
api_key:keyEl?keyEl.value:'',
|
||||
username:userEl?userEl.value:'',
|
||||
password:pwEl?pwEl.value:'',
|
||||
extra:{},
|
||||
};
|
||||
const res=await fetch(API+'/api/admin/services/'+slug,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
if(res.ok){loadServices()}
|
||||
else{const e=await res.json();alert(e.detail||'Error')}
|
||||
}
|
||||
|
||||
// --- System ---
|
||||
async function loadSystem(){
|
||||
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
|
||||
@@ -408,7 +467,7 @@ async function loadSystem(){
|
||||
}
|
||||
|
||||
// Init
|
||||
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadSystem();
|
||||
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadServices();loadSystem();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -121,6 +121,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
<div class="dot"></div>
|
||||
<span id="agent-count">0 agents</span>
|
||||
<span class="user-name" id="user-display"></span>
|
||||
<button class="small-btn" onclick="location.href='/pirate'" title="The Pirate — conversational media agent">Pirate</button>
|
||||
<button class="small-btn" onclick="showLLMSettings()">LLM</button>
|
||||
<button class="small-btn" id="admin-btn" style="display:none" onclick="location.href='/admin'">Admin</button>
|
||||
<button class="small-btn" onclick="logout()">Logout</button>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Pirate — Agent Command Center</title>
|
||||
<style>
|
||||
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232733;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--green:#00b894;--red:#e17055;--yellow:#fdcb6e;--blue:#74b9ff}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
||||
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:.75rem 1.5rem;display:flex;align-items:center;justify-content:space-between}
|
||||
.header h1{font-size:1.1rem;font-weight:600}
|
||||
.header-right{display:flex;gap:.5rem;align-items:center;font-size:.85rem;color:var(--text-dim)}
|
||||
.small-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:.35rem .75rem;border-radius:6px;font-size:.8rem;cursor:pointer}
|
||||
.small-btn:hover{border-color:var(--text-dim);color:var(--text)}
|
||||
.btn-primary{background:var(--accent);color:#fff;border:none;padding:.5rem 1rem;border-radius:6px;font-size:.85rem;cursor:pointer}
|
||||
.btn-primary:hover{background:var(--accent-hover)}
|
||||
|
||||
.layout{flex:1;display:grid;grid-template-columns:260px 1fr;min-height:0}
|
||||
.sidebar{background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0}
|
||||
.sidebar-header{padding:.75rem 1rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
.sidebar-header h2{font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim)}
|
||||
.convo-list{flex:1;overflow-y:auto}
|
||||
.convo-item{padding:.65rem 1rem;border-bottom:1px solid var(--border);cursor:pointer;font-size:.85rem}
|
||||
.convo-item:hover{background:var(--surface2)}
|
||||
.convo-item.active{background:var(--surface2);border-left:3px solid var(--accent);padding-left:calc(1rem - 3px)}
|
||||
.convo-item .title{color:var(--text);line-height:1.3;max-height:2.6em;overflow:hidden;margin-bottom:.2rem}
|
||||
.convo-item .when{color:var(--text-dim);font-size:.7rem}
|
||||
|
||||
.chat{display:flex;flex-direction:column;min-height:0}
|
||||
.messages{flex:1;overflow-y:auto;padding:1.25rem 1.5rem}
|
||||
.msg-group{margin-bottom:1.25rem}
|
||||
.msg-role{font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim);margin-bottom:.25rem}
|
||||
.msg-role.user{color:var(--blue)}
|
||||
.msg-role.assistant{color:var(--accent)}
|
||||
.msg-role.tool{color:var(--yellow)}
|
||||
.msg-body{font-size:.9rem;line-height:1.55;white-space:pre-wrap}
|
||||
.msg-body code{background:var(--surface2);padding:.1rem .35rem;border-radius:4px;font-size:.85em;font-family:'SF Mono',Monaco,Consolas,monospace}
|
||||
.msg-tool{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.65rem .85rem;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:.75rem;color:var(--text-dim);margin-top:.4rem}
|
||||
.msg-tool .tool-head{color:var(--yellow);font-weight:600;margin-bottom:.4rem}
|
||||
.msg-tool pre{white-space:pre-wrap;word-break:break-word;max-height:260px;overflow-y:auto}
|
||||
|
||||
.empty{text-align:center;color:var(--text-dim);margin-top:3rem;font-size:.95rem}
|
||||
.empty .suggestion{display:inline-block;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:.4rem .9rem;margin:.3rem;cursor:pointer;font-size:.85rem;color:var(--text)}
|
||||
.empty .suggestion:hover{border-color:var(--accent)}
|
||||
|
||||
.composer{border-top:1px solid var(--border);padding:1rem 1.5rem;background:var(--surface)}
|
||||
.composer-row{display:flex;gap:.5rem;align-items:flex-end}
|
||||
.composer textarea{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:.65rem .85rem;font-size:.9rem;resize:none;outline:none;font-family:inherit;min-height:44px;max-height:200px}
|
||||
.composer textarea:focus{border-color:var(--accent)}
|
||||
.composer .hint{font-size:.7rem;color:var(--text-dim);margin-top:.4rem}
|
||||
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.thinking{color:var(--text-dim);font-size:.85rem;padding:.5rem 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>The Pirate <span style="color:var(--text-dim);font-weight:400;font-size:.85rem;margin-left:.5rem">Phase 1 — read-only</span></h1>
|
||||
<div class="header-right">
|
||||
<span id="user-display"></span>
|
||||
<button class="small-btn" onclick="newChat()">+ New Chat</button>
|
||||
<button class="small-btn" onclick="location.href='/'">Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Conversations</h2>
|
||||
</div>
|
||||
<div class="convo-list" id="convo-list"></div>
|
||||
</div>
|
||||
<div class="chat">
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="composer">
|
||||
<div class="composer-row">
|
||||
<textarea id="input" placeholder="Ask about downloads, shows, movies, storage..." rows="1"
|
||||
onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();send()}"
|
||||
oninput="autoGrow(this)"></textarea>
|
||||
<button class="btn-primary" id="send-btn" onclick="send()">Send</button>
|
||||
</div>
|
||||
<div class="hint">Enter to send · Shift+Enter for newline · Phase 1 is read-only (no writes yet)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='';
|
||||
let currentConv=null;
|
||||
let sending=false;
|
||||
|
||||
function autoGrow(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px'}
|
||||
|
||||
function timeAgo(s){if(!s)return'';const d=new Date(s+(s.endsWith('Z')?'':'Z')),sec=Math.floor((new Date()-d)/1000);if(sec<60)return'just now';if(sec<3600)return Math.floor(sec/60)+'m ago';if(sec<86400)return Math.floor(sec/3600)+'h ago';return Math.floor(sec/86400)+'d ago'}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch(API+'/api/me');
|
||||
if(r.status===401){location.href='/login';return}
|
||||
const u=await r.json();
|
||||
document.getElementById('user-display').textContent=u.display_name||u.username;
|
||||
}
|
||||
|
||||
async function loadConvos(){
|
||||
const r=await fetch(API+'/api/pirate/conversations');
|
||||
if(!r.ok)return;
|
||||
const convs=await r.json();
|
||||
const list=document.getElementById('convo-list');
|
||||
if(!convs.length){list.innerHTML='<div style="padding:1rem;color:var(--text-dim);font-size:.85rem">No conversations yet. Start below.</div>';return}
|
||||
list.innerHTML=convs.map(c=>`
|
||||
<div class="convo-item ${currentConv===c.id?'active':''}" onclick="openConvo(${c.id})">
|
||||
<div class="title">${(c.title||'Untitled').replace(/</g,'<')}</div>
|
||||
<div class="when">${timeAgo(c.last_message_at)}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderMessages(messages){
|
||||
const container=document.getElementById('messages');
|
||||
if(!messages || !messages.length){
|
||||
container.innerHTML=`<div class="empty">
|
||||
<div style="font-size:1rem;margin-bottom:1rem">Ask The Pirate about your media.</div>
|
||||
<div class="suggestion" onclick="fillInput('What\\'s downloading right now?')">What's downloading right now?</div>
|
||||
<div class="suggestion" onclick="fillInput('Any new TV episodes in the queue?')">Any new TV episodes in the queue?</div>
|
||||
<div class="suggestion" onclick="fillInput('How much space is left?')">How much space is left?</div>
|
||||
<div class="suggestion" onclick="fillInput('Do we have Dune 2?')">Do we have Dune 2?</div>
|
||||
<div class="suggestion" onclick="fillInput('How big is our TV library?')">How big is our TV library?</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML=messages.map(m=>{
|
||||
if(m.role==='tool'){
|
||||
const result=m.tool_result;
|
||||
const body=typeof result==='object'?JSON.stringify(result,null,2):String(result||'');
|
||||
return `<div class="msg-group">
|
||||
<div class="msg-tool">
|
||||
<div class="tool-head">→ tool: ${m.tool_name}</div>
|
||||
<pre>${body.replace(/</g,'<').substring(0,4000)}</pre>
|
||||
</div></div>`;
|
||||
}
|
||||
const label=m.role==='user'?'You':(m.role==='assistant'?'Pirate':m.role);
|
||||
const tools=(m.tool_calls||[]).map(t=>`<div class="msg-tool"><div class="tool-head">← calling ${t.name}(${JSON.stringify(t.input).substring(0,120)})</div></div>`).join('');
|
||||
return `<div class="msg-group">
|
||||
<div class="msg-role ${m.role}">${label}</div>
|
||||
<div class="msg-body">${(m.content||'').replace(/</g,'<')}</div>
|
||||
${tools}
|
||||
</div>`;
|
||||
}).join('');
|
||||
container.scrollTop=container.scrollHeight;
|
||||
}
|
||||
|
||||
async function openConvo(id){
|
||||
currentConv=id;
|
||||
const r=await fetch(API+'/api/pirate/conversations/'+id);
|
||||
if(!r.ok)return;
|
||||
const conv=await r.json();
|
||||
renderMessages(conv.messages);
|
||||
loadConvos();
|
||||
}
|
||||
|
||||
async function newChat(){
|
||||
const r=await fetch(API+'/api/pirate/conversations/new',{method:'POST'});
|
||||
if(!r.ok)return;
|
||||
const c=await r.json();
|
||||
currentConv=c.id;
|
||||
renderMessages([]);
|
||||
loadConvos();
|
||||
document.getElementById('input').focus();
|
||||
}
|
||||
|
||||
function fillInput(t){document.getElementById('input').value=t;autoGrow(document.getElementById('input'));document.getElementById('input').focus()}
|
||||
|
||||
async function send(){
|
||||
if(sending)return;
|
||||
const input=document.getElementById('input');
|
||||
const msg=input.value.trim();
|
||||
if(!msg)return;
|
||||
sending=true;
|
||||
document.getElementById('send-btn').disabled=true;
|
||||
|
||||
// Optimistic: show user message + thinking indicator
|
||||
const container=document.getElementById('messages');
|
||||
const placeholder=document.createElement('div');
|
||||
placeholder.innerHTML=`<div class="msg-group"><div class="msg-role user">You</div><div class="msg-body">${msg.replace(/</g,'<')}</div></div>
|
||||
<div class="msg-group"><div class="msg-role assistant">Pirate</div><div class="thinking"><span class="spinner"></span> thinking...</div></div>`;
|
||||
if(container.querySelector('.empty'))container.innerHTML='';
|
||||
container.appendChild(placeholder);
|
||||
container.scrollTop=container.scrollHeight;
|
||||
|
||||
input.value='';
|
||||
autoGrow(input);
|
||||
|
||||
try{
|
||||
const r=await fetch(API+'/api/pirate/chat',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({message:msg,conversation_id:currentConv})});
|
||||
if(!r.ok){
|
||||
const err=await r.json().catch(()=>({detail:r.statusText}));
|
||||
placeholder.querySelector('.thinking').innerHTML=`<span style="color:var(--red)">Error: ${err.detail||'unknown'}</span>`;
|
||||
return;
|
||||
}
|
||||
const conv=await r.json();
|
||||
currentConv=conv.id;
|
||||
renderMessages(conv.messages);
|
||||
loadConvos();
|
||||
}catch(e){
|
||||
placeholder.querySelector('.thinking').innerHTML=`<span style="color:var(--red)">Error: ${e.message}</span>`;
|
||||
}finally{
|
||||
sending=false;
|
||||
document.getElementById('send-btn').disabled=false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
loadMe();
|
||||
loadConvos().then(()=>{
|
||||
// Auto-open most recent conversation if any
|
||||
fetch(API+'/api/pirate/conversations').then(r=>r.json()).then(convs=>{
|
||||
if(convs.length){openConvo(convs[0].id)} else {renderMessages([])}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user