Files
crank/apps/ui/html/agents.html
T
2026-03-31 16:08:20 +03:00

389 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Agents</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/catalog.css">
<link rel="stylesheet" href="css/wizard.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body x-data="agents">
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link active" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu" @click.stop>
<div class="nav-avatar" @click="openDropdown = openDropdown === 'user' ? null : 'user'" title="Account">AT</div>
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition style="display:none">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role" id="user-ws-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" onclick="window.location.href='settings.html'">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="index.html" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link active" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<!-- ═══════════════════ PAGE ═══════════════════ -->
<div class="page">
<!-- Page header -->
<div class="page-header">
<div>
<h1 class="page-heading">Agents</h1>
<p class="page-subheading">Named MCP endpoints that expose a curated subset of operations to an LLM</p>
</div>
<button class="btn-new" @click="openCreate()">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
New agent
</button>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Total agents</div>
<div class="stat-value" x-text="agents.length"></div>
<div class="stat-delta">across this workspace</div>
</div>
<div class="stat-card">
<div class="stat-label">Active</div>
<div class="stat-value" x-text="activeCount"></div>
<div class="stat-delta" x-text="agents.length ? Math.round(activeCount/agents.length*100)+'% of total' : ''"></div>
</div>
<div class="stat-card">
<div class="stat-label">Operations exposed</div>
<div class="stat-value" x-text="totalOpsExposed"></div>
<div class="stat-delta" x-text="'across ' + agents.length + ' agents'"></div>
</div>
<div class="stat-card">
<div class="stat-label">Calls today</div>
<div class="stat-value" x-text="formatCalls(totalCalls)"></div>
<div class="stat-delta up">all agents combined</div>
</div>
</div>
<!-- Search + filter bar -->
<div class="filter-bar" style="margin-bottom:0;">
<div class="search-box">
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
<input
type="text"
placeholder="Search agents..."
:value="agentSearch"
@input="agentSearch = $event.target.value"
>
</div>
<div class="filter-right">
<span class="results-count" x-show="agentSearch">
<span x-text="filteredAgents.length"></span> of <span x-text="agents.length"></span> agents
</span>
</div>
</div>
<!-- Info callout -->
<div class="agents-info-callout">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 11V8M8 5v-.5"/></svg>
<span>Each agent gets its own MCP endpoint. Connect your LLM client to a specific agent so it only sees the tools it needs — not all <strong x-text="operations.length">?</strong> operations in this workspace.</span>
</div>
<!-- Loading -->
<div class="loading-spinner" x-show="loading">
<div class="spinner"></div>
<span>Loading agents…</span>
</div>
<div class="empty-state" x-show="!loading && loadError">
<div class="empty-state-title">Failed to load agents</div>
<div class="empty-state-sub" x-text="loadError"></div>
</div>
<!-- Empty state: no agents at all -->
<div class="empty-state" x-show="!loading && !loadError && agents.length === 0">
<div class="empty-state-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.35"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
</div>
<div class="empty-state-title">No agents yet</div>
<div class="empty-state-sub">Create your first agent to get a dedicated MCP endpoint with a curated set of tools.</div>
<button class="btn-new" @click="openCreate()" style="margin-top: 16px;">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
New agent
</button>
</div>
<!-- Empty state: search returned nothing -->
<div class="empty-state" x-show="!loading && !loadError && agents.length > 0 && filteredAgents.length === 0">
<div class="empty-state-title">No agents match "<span x-text="agentSearch"></span>"</div>
<div class="empty-state-sub">Try a different search term.</div>
</div>
<!-- Agents grid -->
<div class="agents-grid" x-show="!loading && !loadError && filteredAgents.length > 0">
<template x-for="agent in filteredAgents" :key="agent.id">
<div class="agent-card">
<!-- Card header -->
<div class="agent-card-header">
<div class="agent-card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
</div>
<div class="agent-card-title-wrap">
<div class="agent-card-name" x-text="agent.display_name"></div>
<div class="agent-card-slug" x-text="agent.slug"></div>
</div>
<span :class="statusClass(agent.status)" x-text="agent.status.charAt(0).toUpperCase() + agent.status.slice(1)"></span>
</div>
<!-- Description -->
<div class="agent-card-desc" x-text="agent.description"></div>
<!-- Stats row -->
<div class="agent-card-stats">
<div class="agent-stat">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h12M2 8h8M2 11h5"/></svg>
<span class="agent-stat-value" x-text="agent.operation_count"></span>
<span>tools</span>
</div>
<div class="agent-stat">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2H4a1 1 0 00-1 1v3M7 2l7 7M7 2v5h5"/><path d="M3 9v4a1 1 0 001 1h8a1 1 0 001-1V9"/></svg>
<span class="agent-stat-value" x-text="agent.key_count"></span>
<span>API keys</span>
</div>
<div class="agent-stat" x-show="agent.calls_today > 0">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z"/><circle cx="8" cy="8" r="2"/></svg>
<span class="agent-stat-value" x-text="formatCalls(agent.calls_today)"></span>
<span>calls today</span>
</div>
</div>
<!-- MCP endpoint -->
<div class="agent-endpoint-row">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8h12M10 4l4 4-4 4"/></svg>
<code class="agent-endpoint-url" x-text="mcpEndpoint(agent)"></code>
<button class="agent-endpoint-copy" @click.stop="copyEndpoint(agent)" title="Copy endpoint">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1"/><path d="M5 5V3a1 1 0 011-1h7a1 1 0 011 1v7a1 1 0 01-1 1h-2"/></svg>
</button>
</div>
<!-- Footer -->
<div class="agent-card-footer">
<span class="agent-card-date" x-text="'Created ' + formatDate(agent.created_at)"></span>
<div class="agent-card-actions">
<button class="agent-action-btn" @click.stop="openEdit(agent)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
Edit
</button>
<button class="agent-action-btn danger" @click.stop="deleteAgent(agent.id)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
Delete
</button>
</div>
</div>
</div>
</template>
</div>
</div><!-- /page -->
<!-- ═══════════════════ DRAWER ═══════════════════ -->
<!-- Overlay -->
<div class="drawer-overlay" x-show="drawerOpen" x-transition:enter="transition-opacity" @click="closeDrawer()" style="display:none"></div>
<!-- Drawer panel -->
<div class="drawer" :class="{ open: drawerOpen }" style="display:none" x-show="drawerOpen">
<!-- Drawer header -->
<div class="drawer-header">
<div>
<div class="drawer-title" x-text="drawerMode === 'create' ? 'New agent' : 'Edit agent'"></div>
<div class="drawer-subtitle" x-text="drawerMode === 'create' ? 'Configure a named MCP endpoint for your LLM' : 'Update agent settings and tool selection'"></div>
</div>
<button class="drawer-close" @click="closeDrawer()">
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
<!-- Drawer body -->
<div class="drawer-body">
<!-- Identity section -->
<div class="drawer-section">
<div class="drawer-section-title">Identity</div>
<div class="form-group" style="margin-bottom: 14px;">
<label class="form-label">Display name <span class="form-label-required">required</span></label>
<input class="form-input" type="text" placeholder="Customer Support" autocomplete="off"
:value="form.display_name" @input="onNameInput($event.target.value)">
</div>
<div class="form-group" style="margin-bottom: 14px;">
<label class="form-label">Slug <span class="form-label-required">required</span></label>
<input class="form-input input-mono" type="text" placeholder="customer-support" autocomplete="off"
:value="form.slug" @input="onSlugInput($event.target.value)">
<div class="form-hint" x-show="form.slug">
MCP endpoint: <code style="font-family:monospace;font-size:11px;color:var(--accent)" x-text="'/mcp/v1/' + (localStorage.getItem('crank_workspace_slug') || 'default') + '/' + form.slug"></code>
</div>
</div>
<div class="form-group" style="margin-bottom: 14px;">
<label class="form-label">Description <span class="form-label-optional">(optional)</span></label>
<textarea class="form-textarea" rows="3" placeholder="What does this agent do? What LLM or use-case is it for?"
x-model="form.description"></textarea>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<div class="agent-status-toggle">
<button class="agent-status-opt" :class="{ active: form.status === 'active' }" @click="form.status = 'active'">
<span class="status-dot" style="background: var(--success, #3fb950)"></span> Active
</button>
<button class="agent-status-opt" :class="{ active: form.status === 'draft' }" @click="form.status = 'draft'">
<span class="status-dot" style="background: var(--text-muted)"></span> Draft
</button>
</div>
</div>
</div>
<!-- Operations picker section -->
<div class="drawer-section">
<div class="drawer-section-header">
<div class="drawer-section-title">Operations</div>
<span class="drawer-section-count" x-text="form.selectedOps.length + ' selected'"></span>
</div>
<div class="drawer-section-sub">Select which operations this agent exposes. LLMs connecting to this agent will only see these tools.</div>
<div class="ops-picker">
<!-- Search -->
<div class="ops-picker-search">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round"><circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/></svg>
<input class="ops-picker-search-input" type="text" placeholder="Filter operations…" x-model="opSearch" autocomplete="off" spellcheck="false">
<button class="ops-picker-clear" x-show="opSearch" @click="opSearch = ''" style="display:none">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
<!-- List -->
<div class="ops-picker-list">
<template x-for="op in filteredOps" :key="op.id || op.name">
<div class="ops-picker-item" :class="{ selected: isOpSelected(op.id || op.name) }" @click="toggleOp(op.id || op.name)">
<div class="ops-picker-check">
<svg x-show="isOpSelected(op.id || op.name)" width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
</div>
<div class="ops-picker-info">
<div class="ops-picker-name" x-text="op.display_name || op.name"></div>
<div class="ops-picker-slug" x-text="op.name"></div>
</div>
<span :class="protocolBadge(op)" x-text="protocolLabel(op)" style="font-size:10px; padding: 2px 6px;"></span>
<div class="ops-picker-status-dot" :class="'ops-status-' + op.status"></div>
</div>
</template>
<div class="ops-picker-empty" x-show="filteredOps.length === 0">
No operations match "<span x-text="opSearch"></span>"
</div>
</div>
<!-- Footer count -->
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
<span x-text="form.selectedOps.length"></span> operation<span x-show="form.selectedOps.length !== 1">s</span> selected
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;">Clear all</button>
</div>
</div>
<!-- Recommendation callout -->
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
<span>You've selected <strong x-text="form.selectedOps.length"></strong> tools. LLMs perform best with &lt;15 tools per agent. Consider splitting into separate agents by use-case.</span>
</div>
</div>
</div><!-- /drawer-body -->
<!-- Drawer footer -->
<div class="drawer-footer">
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()">Cancel</button>
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
:disabled="!form.display_name.trim() || !form.slug.trim()"
@click="saveAgent()"
x-text="drawerMode === 'create' ? 'Create agent' : 'Save changes'">
Create agent
</button>
</div>
</div><!-- /drawer -->
<script src="js/vendor/alpine/cdn.min.js" defer></script>
<script src="js/agents.js"></script>
</body>
</html>