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:
Eric Jungbauer
2026-04-20 19:01:50 +00:00
parent 043aa18f3f
commit eac7b64a90
14 changed files with 1474 additions and 3 deletions
+449 -2
View File
@@ -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()
+52
View File
@@ -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")
+60 -1
View File
@@ -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>
+1
View File
@@ -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>
+223
View File
@@ -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,'&lt;')}</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,'&lt;').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,'&lt;')}</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,'&lt;')}</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>