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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user