d93385b373
CI / Check / macos-latest (push) Waiting to run
CI / Check / ubuntu-latest (push) Waiting to run
CI / Check / windows-latest (push) Waiting to run
CI / Test / macos-latest (push) Waiting to run
CI / Test / ubuntu-latest (push) Waiting to run
CI / Test / windows-latest (push) Waiting to run
CI / Clippy (push) Waiting to run
CI / Format (push) Waiting to run
CI / Security Audit (push) Waiting to run
CI / Secrets Scan (push) Waiting to run
CI / Install Script Smoke Test (push) Waiting to run
5412 lines
375 KiB
HTML
5412 lines
375 KiB
HTML
<body x-data="app" :data-theme="theme">
|
|
|
|
<!-- Auth Prompt (API Key or Username/Password) -->
|
|
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '', loginUser: '', loginPass: '' }">
|
|
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
|
<!-- Session login mode -->
|
|
<template x-if="$store.app.authMode === 'session'">
|
|
<div>
|
|
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
|
|
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
|
|
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.5rem">
|
|
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
|
<button @click="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Sign In</button>
|
|
</div>
|
|
</template>
|
|
<!-- API key mode -->
|
|
<template x-if="$store.app.authMode === 'apikey'">
|
|
<div>
|
|
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
|
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
|
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
|
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
|
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
|
|
<!-- Sidebar — x-cloak prevents Firefox flash-hidden during Alpine init -->
|
|
<nav class="sidebar" x-cloak :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
|
|
<div class="sidebar-header">
|
|
<div class="sidebar-header-text">
|
|
<div class="sidebar-logo">
|
|
<img src="/logo.png" alt="OpenFang" width="28" height="28">
|
|
<div>
|
|
<h1>OPENFANG</h1>
|
|
<div class="version" x-text="'v' + version"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="theme-switcher">
|
|
<button class="theme-opt" :class="{ active: themeMode === 'light' }" @click="setTheme('light')" title="Light">☼</button>
|
|
<button class="theme-opt" :class="{ active: themeMode === 'system' }" @click="setTheme('system')" title="System">○</button>
|
|
<button class="theme-opt" :class="{ active: themeMode === 'dark' }" @click="setTheme('dark')" title="Dark">☾</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar-status" :class="{ offline: !connected && !$store.app.booting }">
|
|
<span class="status-dot"></span>
|
|
<span class="sidebar-label" x-show="connected" x-text="agentCount + ' agent(s) running'"></span>
|
|
<span class="sidebar-label" x-show="$store.app.booting && !connected" class="conn-reconnecting">Connecting...</span>
|
|
<span class="sidebar-label" x-show="!connected && !$store.app.booting && $store.app.connectionState === 'reconnecting'" class="conn-reconnecting">Reconnecting...</span>
|
|
<span class="sidebar-label" x-show="!connected && !$store.app.booting && $store.app.connectionState !== 'reconnecting'" x-text="'disconnected' + ($store.app.lastError ? ' — ' + $store.app.lastError : '')">disconnected</span>
|
|
<span class="conn-badge sidebar-label" :class="wsConnected ? 'ws' : 'http'" x-text="wsConnected ? 'WS' : 'HTTP'" x-show="connected"></span>
|
|
</div>
|
|
|
|
<div class="sidebar-nav" role="navigation" aria-label="Main navigation">
|
|
<!-- Overview — primary action as first screen -->
|
|
<a class="nav-item" :class="{ active: page === 'overview' }" @click="navigate('overview')" :aria-current="page === 'overview' ? 'page' : false" style="margin-bottom: 4px;">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg></span>
|
|
<span class="nav-label">Overview</span>
|
|
</a>
|
|
|
|
<!-- Agents -->
|
|
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Agents">
|
|
<div class="nav-section-title" @click="collapsed = !collapsed">
|
|
<span class="nav-label">Agents</span>
|
|
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
|
</div>
|
|
<!-- x-show + x-cloak: Firefox-safe replacement for nested <template x-if> which has render quirks. -->
|
|
<div x-show="!collapsed" x-cloak x-transition>
|
|
<a class="nav-item" :class="{ active: page === 'agents' }" @click="navigate('agents')" :aria-current="page === 'agents' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
|
<span class="nav-label">Chat</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'sessions' }" @click="navigate('sessions')" :aria-current="page === 'sessions' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m12 2-10 5 10 5 10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg></span>
|
|
<span class="nav-label">Sessions</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
|
<span class="nav-label">Approvals</span>
|
|
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
|
<span class="nav-label">Comms</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Automation -->
|
|
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Automation">
|
|
<div class="nav-section-title" @click="collapsed = !collapsed">
|
|
<span class="nav-label">Automation</span>
|
|
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
|
</div>
|
|
<div x-show="!collapsed" x-cloak x-transition>
|
|
<a class="nav-item" :class="{ active: page === 'workflows' }" @click="navigate('workflows')" :aria-current="page === 'workflows' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/></svg></span>
|
|
<span class="nav-label">Workflows</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'scheduler' }" @click="navigate('scheduler')" :aria-current="page === 'scheduler' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span>
|
|
<span class="nav-label">Scheduler</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Extensions -->
|
|
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Extensions">
|
|
<div class="nav-section-title" @click="collapsed = !collapsed">
|
|
<span class="nav-label">Extensions</span>
|
|
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
|
</div>
|
|
<div x-show="!collapsed" x-cloak x-transition>
|
|
<a class="nav-item" :class="{ active: page === 'channels' }" @click="navigate('channels')" :aria-current="page === 'channels' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 9h16M4 15h16M10 3l-2 18M16 3l-2 18"/></svg></span>
|
|
<span class="nav-label">Channels</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'skills' }" @click="navigate('skills')" :aria-current="page === 'skills' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></span>
|
|
<span class="nav-label">Skills</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'hands' }" @click="navigate('hands')" :aria-current="page === 'hands' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v6"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.9-5.7-2.4L3.4 16a2 2 0 0 1 3.2-2.4L8 15"/></svg></span>
|
|
<span class="nav-label">Hands</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Monitor -->
|
|
<div class="nav-section" x-data="{ collapsed: false }" aria-label="Monitor">
|
|
<div class="nav-section-title" @click="collapsed = !collapsed">
|
|
<span class="nav-label">Monitor</span>
|
|
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
|
</div>
|
|
<div x-show="!collapsed" x-cloak x-transition>
|
|
<a class="nav-item" :class="{ active: page === 'analytics' }" @click="navigate('analytics')" :aria-current="page === 'analytics' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
|
|
<span class="nav-label">Analytics</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'logs' }" @click="navigate('logs')" :aria-current="page === 'logs' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg></span>
|
|
<span class="nav-label">Logs</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- System -->
|
|
<div class="nav-section" x-data="{ collapsed: false }" aria-label="System">
|
|
<div class="nav-section-title" @click="collapsed = !collapsed">
|
|
<span class="nav-label">System</span>
|
|
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
|
</div>
|
|
<div x-show="!collapsed" x-cloak x-transition>
|
|
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
|
|
<span class="nav-label">Runtime</span>
|
|
</a>
|
|
<a class="nav-item" :class="{ active: page === 'settings' }" @click="navigate('settings')" :aria-current="page === 'settings' ? 'page' : false">
|
|
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
|
<span class="nav-label">Settings</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar-footer">
|
|
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
|
|
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
|
|
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
|
|
</div>
|
|
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
|
|
</div>
|
|
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
|
|
</nav>
|
|
|
|
<div class="sidebar-overlay" x-cloak @click="mobileMenuOpen = false"></div>
|
|
|
|
<!-- Main Content -->
|
|
<main class="main-content">
|
|
<!-- Mobile menu button -->
|
|
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
</button>
|
|
|
|
<!-- Page: Overview -->
|
|
<template x-if="page === 'overview'">
|
|
<div x-data="overviewPage" x-init="loadOverview().then(() => startAutoRefresh())" @page-leave.window="stopAutoRefresh()">
|
|
<div class="page-header">
|
|
<h2>Overview</h2>
|
|
<div class="flex items-center gap-2">
|
|
<span class="health-indicator" :class="health.status === 'ok' ? 'health-ok' : 'health-down'" x-text="health.status === 'ok' ? 'Healthy' : 'Unreachable'"></span>
|
|
<button class="btn btn-ghost btn-sm" @click="silentRefresh()" title="Refresh" style="padding:4px 8px">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="page-body">
|
|
<!-- Loading skeleton state -->
|
|
<div x-show="loading" style="animation:fadeIn 0.2s">
|
|
<div class="stats-row stats-row-lg" style="margin-bottom:20px">
|
|
<div class="stat-card stat-card-lg"><div class="skeleton skeleton-heading" style="width:60px;height:28px"></div><div class="skeleton skeleton-text" style="width:100px;height:12px;margin-top:8px"></div></div>
|
|
<div class="stat-card stat-card-lg"><div class="skeleton skeleton-heading" style="width:40px;height:28px"></div><div class="skeleton skeleton-text" style="width:120px;height:12px;margin-top:8px"></div></div>
|
|
<div class="stat-card stat-card-lg"><div class="skeleton skeleton-heading" style="width:50px;height:28px"></div><div class="skeleton skeleton-text" style="width:80px;height:12px;margin-top:8px"></div></div>
|
|
<div class="stat-card stat-card-lg"><div class="skeleton skeleton-heading" style="width:40px;height:28px"></div><div class="skeleton skeleton-text" style="width:60px;height:12px;margin-top:8px"></div></div>
|
|
</div>
|
|
<div class="card" style="margin-bottom:16px"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:12px"></div><div style="display:flex;gap:8px"><div class="skeleton" style="width:80px;height:24px;border-radius:20px"></div><div class="skeleton" style="width:70px;height:24px;border-radius:20px"></div><div class="skeleton" style="width:90px;height:24px;border-radius:20px"></div></div></div>
|
|
<div class="overview-grid"><div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div></div>
|
|
</div>
|
|
<!-- Error state -->
|
|
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
|
|
<div class="empty-state-icon">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--error)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
|
|
</div>
|
|
<h3 style="color:var(--error)">Connection Error</h3>
|
|
<p class="text-xs text-dim" x-text="loadError"></p>
|
|
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
|
|
</div>
|
|
<!-- Setup Checklist -->
|
|
<div class="setup-checklist" x-show="!loading && !loadError && setupProgress < 100 && !checklistDismissed" style="animation:slideUp 0.3s var(--ease-spring)">
|
|
<div class="card" style="border-left:4px solid var(--accent)">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div>
|
|
<div class="card-header" style="margin:0">Getting Started</div>
|
|
<div class="text-xs text-dim" x-text="setupDoneCount + ' of 5 steps completed'"></div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary btn-sm" @click="location.hash='wizard'">Setup Wizard</button>
|
|
<button class="btn btn-ghost btn-sm" @click="dismissChecklist()">Dismiss</button>
|
|
</div>
|
|
</div>
|
|
<div class="progress-bar mb-2" style="margin-top:8px">
|
|
<div class="progress-bar-fill" :style="'width:' + setupProgress + '%'"></div>
|
|
</div>
|
|
<template x-for="item in setupChecklist" :key="item.key">
|
|
<div class="setup-checklist-item">
|
|
<div class="setup-checklist-icon" :class="{ done: item.done }">
|
|
<span x-show="item.done">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</span>
|
|
<span x-show="!item.done" style="width:10px;height:10px;border-radius:50%;border:1.5px solid var(--border);display:block"></span>
|
|
</div>
|
|
<span style="flex:1" :style="item.done ? 'text-decoration:line-through;opacity:0.6' : ''" x-text="item.label"></span>
|
|
<a x-show="!item.done" :href="item.action" class="btn btn-ghost btn-sm" style="font-size:10px;padding:3px 8px">Go</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<!-- Onboarding banner -->
|
|
<div class="onboarding-banner" x-show="!loading && !loadError && $store.app.showOnboarding && checklistDismissed">
|
|
<h3>Welcome to OpenFang</h3>
|
|
<p style="font-size:12px;color:var(--text-dim)">Get started quickly with the guided Setup Wizard, or configure manually:</p>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary" @click="location.hash='wizard'">Launch Setup Wizard</button>
|
|
<button class="btn btn-ghost" @click="location.hash='settings'">Configure Manually</button>
|
|
<button class="btn btn-ghost" @click="$store.app.dismissOnboarding()">Dismiss</button>
|
|
</div>
|
|
</div>
|
|
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
|
|
|
|
<!-- Primary stats row with icons + stagger animation -->
|
|
<div class="stats-row stats-row-lg">
|
|
<div class="stat-card stat-card-lg animate-entry stagger-1" @click="location.hash='agents'" style="cursor:pointer">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div style="width:36px;height:36px;border-radius:var(--radius-md);background:var(--accent-subtle);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="stat-value" :class="(status.agent_count || 0) > 0 ? 'stat-value-success' : ''" x-text="status.agent_count || 0"></div>
|
|
<div class="stat-label">Agents Running</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card stat-card-lg animate-entry stagger-2">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div style="width:36px;height:36px;border-radius:var(--radius-md);background:rgba(96,165,250,0.1);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#60A5FA" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2-10 5 10 5 10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="stat-value" x-text="formatNumber(usageSummary.total_tokens)"></div>
|
|
<div class="stat-label">Tokens Used</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card stat-card-lg animate-entry stagger-3">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div style="width:36px;height:36px;border-radius:var(--radius-md);background:rgba(74,222,128,0.1);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4ADE80" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="stat-value stat-value-accent" x-text="formatCost(usageSummary.total_cost)"></div>
|
|
<div class="stat-label">Total Cost</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card stat-card-lg animate-entry stagger-4">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div style="width:36px;height:36px;border-radius:var(--radius-md);background:rgba(168,85,247,0.1);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A855F7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="stat-value" x-text="formatUptime(status.uptime_seconds)"></div>
|
|
<div class="stat-label">Uptime</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Secondary stats row -->
|
|
<div class="stats-row animate-entry stagger-5" style="margin-bottom:16px">
|
|
<div class="stat-card" @click="location.hash='channels'" style="cursor:pointer">
|
|
<div class="stat-value" style="font-size:18px" x-text="channels.length"></div>
|
|
<div class="stat-label">Channels</div>
|
|
</div>
|
|
<div class="stat-card" @click="location.hash='skills'" style="cursor:pointer">
|
|
<div class="stat-value" style="font-size:18px" x-text="skillCount"></div>
|
|
<div class="stat-label">Skills</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" style="font-size:18px" x-text="connectedMcp.length"></div>
|
|
<div class="stat-label">MCP Servers</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" style="font-size:18px" x-text="formatNumber(usageSummary.total_tools)"></div>
|
|
<div class="stat-label">Tool Calls</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" :class="configuredProviders.length > 0 ? 'stat-value-success' : ''" style="font-size:18px" x-text="configuredProviders.length"></div>
|
|
<div class="stat-label">Providers</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Provider status badges with health indicators -->
|
|
<div class="card mb-4" x-show="providers.length" style="overflow:hidden">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
|
LLM Providers
|
|
</div>
|
|
<span class="text-xs text-dim" x-text="configuredProviders.length + '/' + providers.length + ' configured'"></span>
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px">
|
|
<template x-for="p in providers" :key="p.id">
|
|
<div class="badge" :class="providerBadgeClass(p)" :title="providerTooltip(p)" style="cursor:pointer;transition:all 0.15s var(--ease-spring);padding:4px 10px" @click="location.hash='settings'" @mouseenter="$el.style.transform='translateY(-1px)'" @mouseleave="$el.style.transform=''">
|
|
<span x-show="p.auth_status === 'configured'" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px" :style="p.health === 'cooldown' || p.health === 'open' ? 'background:var(--warning);animation:pulse-ring 1.5s infinite' : 'background:var(--success)'"></span>
|
|
<span x-text="p.display_name"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overview-grid">
|
|
<!-- System Health -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
|
System Health
|
|
</div>
|
|
<div class="detail-grid" style="margin-top:8px">
|
|
<div class="detail-row"><span class="detail-label">Status</span><span class="badge" :class="health.status === 'ok' ? 'badge-running' : 'badge-crashed'" x-text="health.status || 'unknown'"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Version</span><span class="detail-value font-mono" x-text="status.version || '-'"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="status.default_provider || '-'"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value font-mono" style="font-size:11px" x-text="status.default_model || '-'"></span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Security Systems -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
|
Security Systems
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:8px">
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">Merkle Audit</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">Taint Tracking</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">WASM Sandbox</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">GCRA Rate Limit</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">Ed25519 Signing</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">SSRF Protection</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">Secret Zeroize</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">Loop Guard</span>
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 8px">Session Repair</span>
|
|
</div>
|
|
<div class="text-xs text-dim" style="margin-top:8px">9 defense-in-depth systems active</div>
|
|
</div>
|
|
|
|
<!-- Connected Channels -->
|
|
<div class="card" x-show="channels.length">
|
|
<div class="card-header">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M4 9h16M4 15h16M10 3l-2 18M16 3l-2 18"/></svg>
|
|
Connected Channels
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:8px">
|
|
<template x-for="ch in channels" :key="ch.name">
|
|
<span class="badge badge-info" style="font-size:9px;text-transform:capitalize;padding:2px 8px" x-text="ch.name"></span>
|
|
</template>
|
|
</div>
|
|
<div class="text-xs text-dim" style="margin-top:8px" x-text="channels.length + ' channel(s) connected'"></div>
|
|
</div>
|
|
|
|
<!-- MCP Servers -->
|
|
<div class="card" x-show="mcpServers.length">
|
|
<div class="card-header">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><path d="M6 6h.01M6 18h.01"/></svg>
|
|
MCP Servers
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:8px">
|
|
<template x-for="s in mcpServers" :key="s.name">
|
|
<div class="badge" :class="s.status === 'connected' ? 'badge-success' : 'badge-dim'" style="font-size:9px;padding:2px 8px">
|
|
<span style="display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:3px" :style="s.status === 'connected' ? 'background:var(--success)' : 'background:var(--text-dim)'"></span>
|
|
<span x-text="s.name"></span>
|
|
<span class="text-xs text-dim" x-show="s.tool_count" x-text="'(' + s.tool_count + ' tools)'"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="card mt-4" style="background:var(--bg-elevated)">
|
|
<div class="card-header" style="margin-bottom:8px">Quick Actions</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
|
<button class="btn btn-ghost btn-sm" @click="location.hash='agents'" style="display:flex;align-items:center;gap:4px">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
|
|
New Agent
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="location.hash='skills'" style="display:flex;align-items:center;gap:4px">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg>
|
|
Browse Skills
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="location.hash='channels'" style="display:flex;align-items:center;gap:4px">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9h16M4 15h16M10 3l-2 18M16 3l-2 18"/></svg>
|
|
Add Channel
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="location.hash='workflows'" style="display:flex;align-items:center;gap:4px">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/></svg>
|
|
Create Workflow
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="location.hash='settings'" style="display:flex;align-items:center;gap:4px">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg>
|
|
Settings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Activity Feed -->
|
|
<div class="card mt-4" x-show="recentAudit.length">
|
|
<div class="flex justify-between items-center">
|
|
<div class="card-header" style="margin:0">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
Recent Activity
|
|
</div>
|
|
<a class="btn btn-ghost btn-sm" @click="location.hash='logs'" style="font-size:10px;padding:2px 8px">View All</a>
|
|
</div>
|
|
<div style="margin-top:12px;display:flex;flex-direction:column;gap:1px">
|
|
<template x-for="(e, idx) in recentAudit" :key="e.seq">
|
|
<div class="detail-row" style="padding:8px 0;border-bottom:1px solid var(--border-subtle);animation:slideUp 0.2s var(--ease-spring)" :style="'animation-delay:' + (idx * 30) + 'ms'">
|
|
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
|
<div style="width:24px;height:24px;border-radius:var(--radius-sm);background:var(--surface-2);display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--text-dim)" x-html="actionIcon(e.action)"></div>
|
|
<div style="min-width:0;flex:1">
|
|
<div style="display:flex;align-items:center;gap:6px">
|
|
<span class="badge" :class="actionBadgeClass(e.action)" style="font-size:9px;padding:1px 6px" x-text="friendlyAction(e.action)"></span>
|
|
<span class="text-xs text-dim truncate" style="max-width:100px" x-text="agentName(e.agent_id)" :title="e.agent_id"></span>
|
|
</div>
|
|
<div class="text-xs text-dim truncate" style="margin-top:2px;max-width:300px" x-show="e.detail" x-text="e.detail" :title="e.detail"></div>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs text-dim font-mono" style="white-space:nowrap;flex-shrink:0" x-text="timeAgo(e.timestamp)" :title="new Date(e.timestamp).toLocaleString()"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty activity state -->
|
|
<div class="card mt-4" x-show="!recentAudit.length && !loading">
|
|
<div style="text-align:center;padding:24px 16px">
|
|
<div class="empty-state-icon" style="margin:0 auto 8px">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
</div>
|
|
<h3 style="color:var(--text-secondary);margin-bottom:4px">No Recent Activity</h3>
|
|
<p class="text-xs text-dim">Activity will appear here once agents start processing.</p>
|
|
<button class="btn btn-primary btn-sm" @click="location.hash='agents'" style="margin-top:12px">Chat with an Agent</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="animate-entry stagger-6" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:12px;margin-top:20px">
|
|
<div class="quick-action-card" @click="location.hash='agents'">
|
|
<div class="quick-action-icon" style="background:var(--accent-subtle)">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="quick-action-label">Create Agent</div>
|
|
<div class="quick-action-desc">Spawn a new agent</div>
|
|
</div>
|
|
</div>
|
|
<div class="quick-action-card" @click="location.hash='settings'">
|
|
<div class="quick-action-icon" style="background:var(--success-subtle)">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="quick-action-label">Configure Provider</div>
|
|
<div class="quick-action-desc">Set up an LLM provider</div>
|
|
</div>
|
|
</div>
|
|
<div class="quick-action-card" @click="location.hash='skills'">
|
|
<div class="quick-action-icon" style="background:var(--info-subtle)">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--info)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="quick-action-label">Browse Skills</div>
|
|
<div class="quick-action-desc">Explore available skills</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Agents -->
|
|
<template x-if="page === 'agents'">
|
|
<div x-data="agentsPage" @close-chat.window="closeChat()">
|
|
|
|
<!-- MODE 1: Inline Chat (agent selected) -->
|
|
<template x-if="activeChatAgent">
|
|
<div x-data="chatPage" class="chat-wrapper">
|
|
<!-- Chat header with back button -->
|
|
<div class="page-header" x-show="currentAgent">
|
|
<div class="flex items-center gap-2" style="min-width:0">
|
|
<button class="btn btn-ghost btn-sm" @click="$dispatch('close-chat')" title="Back to Agents" style="padding:4px 8px;margin-right:4px">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
|
</button>
|
|
<span x-show="currentAgent && currentAgent.identity && currentAgent.identity.emoji" x-text="currentAgent && currentAgent.identity && currentAgent.identity.emoji" style="font-size:20px;line-height:1"></span>
|
|
<div style="min-width:0">
|
|
<div class="font-bold" x-text="currentAgent ? currentAgent.name : ''"></div>
|
|
<div class="text-xs text-dim truncate" style="font-family:var(--font-mono);font-size:11px" x-text="currentAgent ? currentAgent.model_provider + ':' + currentAgent.model_name : ''"></div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<span class="badge badge-running" x-show="currentAgent && !sending" style="animation:fadeIn 0.2s">
|
|
<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--success);margin-right:2px"></span> Ready
|
|
</span>
|
|
<span class="badge badge-suspended" x-show="sending" style="animation:fadeIn 0.2s">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2.5" style="animation:spin 1s linear infinite"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
|
Generating...
|
|
</span>
|
|
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="'+' + messageQueue.length + ' queued'"></span>
|
|
<!-- Session switcher -->
|
|
<div style="position:relative">
|
|
<button class="btn btn-ghost btn-sm" @click="sessionsOpen = !sessionsOpen" title="Sessions" style="position:relative">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
|
|
<span class="session-count-badge" x-show="sessions.length > 1" x-text="sessions.length"></span>
|
|
</button>
|
|
<div class="session-dropdown" x-show="sessionsOpen" @click.outside="sessionsOpen = false" x-transition>
|
|
<div class="session-dropdown-header">
|
|
<span class="text-xs font-bold">Sessions</span>
|
|
<button class="btn btn-ghost btn-sm" @click="createSession()" style="padding:2px 6px;font-size:11px">+ New</button>
|
|
</div>
|
|
<template x-for="s in sessions" :key="s.session_id">
|
|
<div class="session-item" :class="{ active: s.active }" @click="if (!s.active) { switchSession(s.session_id); sessionsOpen = false; }">
|
|
<span class="session-dot" :class="s.active ? 'active' : ''"></span>
|
|
<div style="flex:1;min-width:0">
|
|
<div class="text-xs font-bold truncate" x-text="s.label || 'Session ' + s.session_id.substring(0, 8)"></div>
|
|
<div class="text-xs text-dim" x-text="s.message_count + ' messages'"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="text-xs text-dim" style="padding:8px 12px;text-align:center" x-show="!sessions.length">No sessions</div>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm" @click="toggleSearch()" title="Search messages (Ctrl+F)">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="$store.app.toggleFocusMode()" title="Ctrl+Shift+F">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
|
|
</button>
|
|
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
|
|
<button class="btn btn-danger btn-sm" @click="uninstallAgent()" title="Stop and remove agent files from workspace">Uninstall</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search bar -->
|
|
<div class="chat-search-bar" x-show="searchOpen" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform -translate-y-2" x-transition:enter-end="opacity-100 transform translate-y-0">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
<input type="text" id="chat-search-input" x-model="searchQuery" x-ref="searchInput" placeholder="Search messages..." class="chat-search-input" @keydown.escape="toggleSearch()">
|
|
<span class="text-xs text-dim" x-show="searchQuery.trim()" x-text="filteredMessages.length + ' of ' + messages.length"></span>
|
|
<button class="btn btn-ghost btn-sm" @click="toggleSearch()" style="padding:2px 6px">×</button>
|
|
</div>
|
|
|
|
<!-- Messages area -->
|
|
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
|
|
<!-- Empty state: no agent selected -->
|
|
<template x-if="!currentAgent">
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
|
|
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
|
|
</div>
|
|
</template>
|
|
<!-- Message list -->
|
|
<template x-if="currentAgent">
|
|
<div>
|
|
<template x-for="(msg, $index) in filteredMessages" :key="msg.id">
|
|
<div class="message" :class="msg.role + (msg.thinking ? ' thinking' : '') + (msg.streaming ? ' streaming' : '') + (isGrouped($index) ? ' grouped' : '')">
|
|
<div class="message-avatar" x-show="msg.role === 'agent'">
|
|
<img src="/logo.png" alt="OpenFang">
|
|
</div>
|
|
<div class="message-body">
|
|
<!-- Copy button on hover -->
|
|
<div class="message-actions" x-show="msg.text && msg.text.trim() && !msg.thinking && !msg.streaming">
|
|
<button class="message-action-btn" :class="{ copied: msg._copied }" @click="copyMessage(msg)" :title="msg._copied ? 'Copied!' : 'Copy message'">
|
|
<svg x-show="!msg._copied" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
|
<svg x-show="msg._copied" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
</button>
|
|
</div>
|
|
<!-- Thinking indicator with animated dots -->
|
|
<div class="message-bubble" x-show="msg.thinking" style="background:transparent;border:none;padding:4px 0">
|
|
<div class="typing-dots"><span></span><span></span><span></span></div>
|
|
</div>
|
|
<!-- Normal message content -->
|
|
<div class="message-bubble" :class="{ 'markdown-body': (msg.role === 'agent' || msg.role === 'system') && !msg.thinking && !msg.isHtml }" x-show="msg.text && msg.text.trim() && !msg.thinking" x-html="highlightSearch(msg.isHtml ? msg.text : ((msg.role === 'agent' || msg.role === 'system') && !msg.thinking ? renderMarkdown(msg.text) : escapeHtml(msg.text)))"></div>
|
|
<!-- Inline images from uploads (shown above tool cards) -->
|
|
<template x-if="msg.images && msg.images.length">
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin:8px 0">
|
|
<template x-for="img in msg.images" :key="img.file_id">
|
|
<a :href="'/api/uploads/' + img.file_id" target="_blank" style="display:block">
|
|
<img :src="'/api/uploads/' + img.file_id" :alt="img.filename || 'uploaded image'" style="max-width:320px;max-height:320px;border-radius:8px;border:1px solid var(--border);cursor:pointer" loading="lazy">
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<template x-for="tool in (msg.tools || [])" :key="tool.id">
|
|
<div class="tool-card" :class="{ 'tool-card-error': tool.is_error }" :data-tool="tool.name">
|
|
<div class="tool-card-header" @click="tool.expanded = !tool.expanded">
|
|
<template x-if="tool.running"><div class="tool-card-spinner"></div></template>
|
|
<template x-if="!tool.running && !tool.is_error"><span class="tool-icon-ok">✓</span></template>
|
|
<template x-if="!tool.running && tool.is_error"><span class="tool-icon-err">✗</span></template>
|
|
<span class="tool-card-icon" x-html="toolIcon(tool.name)"></span>
|
|
<span class="tool-card-name" x-text="tool.name"></span>
|
|
<span class="text-xs text-dim" x-text="tool.running ? 'running...' : (tool.is_error ? 'error' : (tool.result ? (tool.result.length > 500 ? Math.round(tool.result.length/1024) + 'KB' : 'done') : 'done'))" style="margin-left:auto"></span>
|
|
<span class="tool-expand-chevron" :style="tool.expanded ? 'transform:rotate(90deg)' : ''" style="transition:transform 0.15s">▶</span>
|
|
</div>
|
|
<!-- Render generated images above the raw result -->
|
|
<div x-show="tool._imageUrls && tool._imageUrls.length" style="padding:8px 12px;display:flex;flex-wrap:wrap;gap:8px">
|
|
<template x-for="iurl in (tool._imageUrls || [])" :key="iurl">
|
|
<a :href="iurl" target="_blank" style="display:block">
|
|
<img :src="iurl" alt="Generated image" style="max-width:320px;max-height:320px;border-radius:8px;border:1px solid var(--border);cursor:pointer" loading="lazy">
|
|
</a>
|
|
</template>
|
|
</div>
|
|
<!-- Audio player for TTS results -->
|
|
<div x-show="tool._audioFile" style="padding:8px 12px">
|
|
<div class="audio-player">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
|
|
<span class="text-xs" x-text="'Audio: ' + tool._audioFile.split('/').pop()"></span>
|
|
<span class="text-xs text-dim" x-show="tool._audioDuration" x-text="'~' + Math.round((tool._audioDuration || 0) / 1000) + 's'"></span>
|
|
</div>
|
|
</div>
|
|
<div class="tool-card-body" x-show="tool.expanded" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
|
<div x-show="tool.input" style="margin-bottom:6px">
|
|
<div class="tool-section-label">Input</div>
|
|
<pre class="tool-pre" x-text="formatToolJson(tool.input)"></pre>
|
|
</div>
|
|
<div x-show="tool.result">
|
|
<div class="tool-section-label">Result <span class="text-xs text-muted" x-show="tool.result && tool.result.length > 200" x-text="'(' + tool.result.length + ' chars)'"></span></div>
|
|
<pre class="tool-pre" :class="{ 'tool-pre-error': tool.is_error, 'tool-pre-short': !tool.is_error && tool.result && tool.result.length < 100, 'tool-pre-medium': !tool.is_error && tool.result && tool.result.length >= 100 && tool.result.length < 500 }" x-text="formatToolJson(tool.result)"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Timestamp + meta row -->
|
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
<div class="message-time" x-text="formatTime(msg.ts)" x-show="msg.ts && !msg.thinking"></div>
|
|
<div class="message-meta" x-text="msg.meta" x-show="msg.meta"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Drop zone overlay -->
|
|
<div x-show="dragOver" style="position:absolute;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:var(--surface);opacity:0.92;border:2px dashed var(--accent);border-radius:8px;pointer-events:none">
|
|
<span style="font-size:16px;color:var(--accent);font-weight:600">Drop files here</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input area -->
|
|
<div class="input-area" x-show="currentAgent" style="position:relative">
|
|
<!-- Attachment previews -->
|
|
<div x-show="attachments.length > 0" style="display:flex;gap:8px;flex-wrap:wrap;padding:0 0 8px 0">
|
|
<template x-for="(att, aidx) in attachments" :key="aidx">
|
|
<div style="position:relative;border:1px solid var(--border);border-radius:6px;padding:4px;display:flex;align-items:center;gap:6px;background:var(--surface2);max-width:180px">
|
|
<img x-show="att.preview" :src="att.preview" style="width:32px;height:32px;object-fit:cover;border-radius:4px">
|
|
<span x-show="!att.preview" style="font-size:18px;width:32px;text-align:center">📄</span>
|
|
<span class="text-xs truncate" style="max-width:100px" x-text="att.file.name"></span>
|
|
<span x-show="att.uploading" class="spinner" style="width:12px;height:12px;border-width:2px"></span>
|
|
<button @click="removeAttachment(aidx)" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:var(--danger);color:#fff;border:none;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;line-height:1">×</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<!-- Slash command menu -->
|
|
<div x-show="showSlashMenu && filteredSlashCommands.length" class="slash-menu">
|
|
<template x-for="(cmd, idx) in filteredSlashCommands" :key="cmd.cmd">
|
|
<div class="slash-menu-item" :class="{ 'slash-active': idx === slashIdx }" @click="executeSlashCommand(cmd.cmd)" @mouseenter="slashIdx = idx">
|
|
<span class="font-bold" style="font-size:13px" x-text="cmd.cmd"></span>
|
|
<span class="text-xs text-dim" x-text="cmd.desc"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<!-- Model autocomplete picker -->
|
|
<div x-show="showModelPicker && filteredModelPicker.length" class="slash-menu" style="max-height:280px;overflow-y:auto">
|
|
<div class="text-xs text-dim" style="padding:4px 10px;border-bottom:1px solid var(--border)">Available models — pick one or keep typing</div>
|
|
<template x-for="(m, idx) in filteredModelPicker" :key="m.id">
|
|
<div class="slash-menu-item" :class="{ 'slash-active': idx === modelPickerIdx }" @click="pickModel(m.id)" @mouseenter="modelPickerIdx = idx">
|
|
<span class="font-bold" style="font-size:12px;font-family:var(--font-mono)" x-text="m.id"></span>
|
|
<span class="text-xs text-dim" x-text="m.provider + (m.display_name && m.display_name !== m.id ? ' · ' + m.display_name : '')"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<!-- Input row -->
|
|
<div class="input-row">
|
|
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()" title="Attach file" style="padding:6px 8px;flex-shrink:0">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
</button>
|
|
<input type="file" x-ref="fileInput" multiple accept="image/*,.txt,.pdf,.md,.json,.csv,.mp3,.wav,.ogg,.webm,.m4a,.flac" @change="addFiles($event.target.files); $event.target.value = ''" style="display:none">
|
|
<!-- Mic button -->
|
|
<button class="btn btn-ghost btn-sm" :class="{ 'btn-recording': recording }" @mousedown="startRecording()" @mouseup="stopRecording()" @mouseleave="if(recording) stopRecording()" title="Hold to record voice" style="padding:6px 8px;flex-shrink:0">
|
|
<svg x-show="!recording" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
|
|
<span x-show="recording" class="recording-dot"></span>
|
|
</button>
|
|
<!-- Recording indicator -->
|
|
<div class="recording-indicator" x-show="recording" x-transition>
|
|
<span class="recording-dot"></span>
|
|
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
|
</div>
|
|
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
|
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){$event.preventDefault();if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
|
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
|
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
|
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
|
@input="$el.style.height='auto';$el.style.height=Math.min($el.scrollHeight,150)+'px'"
|
|
x-model="inputText"
|
|
:class="{ 'streaming-active': sending }"></textarea>
|
|
<!-- Send button (normal) or Stop button (during streaming) -->
|
|
<template x-if="!sending">
|
|
<button class="btn-send" @click="sendMessage()" :disabled="!inputText.trim() && !attachments.length">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
</button>
|
|
</template>
|
|
<template x-if="sending">
|
|
<button class="btn-stop" @click="stopAgent()" title="Stop generating">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
<!-- Footer: model switcher + tokens + queue + tips -->
|
|
<div class="input-footer">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Model Switcher -->
|
|
<div style="position:relative" x-show="currentAgent" @click.outside="showModelSwitcher = false" @keydown.escape.window="showModelSwitcher = false">
|
|
<button class="model-switcher-btn" @click="toggleModelSwitcher()" :disabled="sending" title="Switch model (Ctrl+M)">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
|
<span class="model-switcher-label" x-text="modelDisplayName || 'Model'"></span>
|
|
<svg class="model-switcher-chevron" :class="{'open': showModelSwitcher}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
|
</button>
|
|
<!-- Dropdown -->
|
|
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
<div class="model-switcher-search">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
|
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
|
|
<option value="">All</option>
|
|
<template x-for="pn in switcherProviders" :key="pn">
|
|
<option :value="pn" x-text="pn"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
|
<div class="tool-card-spinner"></div>
|
|
<span class="text-xs text-dim">Switching...</span>
|
|
</div>
|
|
<div class="model-switcher-list" x-show="!modelSwitching">
|
|
<template x-if="groupedSwitcherModels.length === 0">
|
|
<div style="padding:16px;text-align:center" class="text-xs text-dim">No models found</div>
|
|
</template>
|
|
<template x-for="group in groupedSwitcherModels" :key="group.provider">
|
|
<div>
|
|
<div class="model-switcher-group-header" x-text="group.provider"></div>
|
|
<template x-for="(m, mi) in group.models" :key="m.id">
|
|
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
|
|
<div style="flex:1;min-width:0">
|
|
<div style="display:flex;align-items:center;gap:6px">
|
|
<span class="model-switcher-item-name" x-text="m.provider + ':' + (m.display_name || m.id)"></span>
|
|
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
|
<span class="text-xs text-dim" x-text="m.id" style="font-family:var(--font-mono)"></span>
|
|
<span class="text-xs text-dim" x-show="m.context_window" x-text="m.context_window >= 1000000 ? (m.context_window/1000000).toFixed(1)+'M' : Math.round(m.context_window/1000)+'K'"></span>
|
|
<svg x-show="m.supports_vision" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Vision"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
<svg x-show="m.supports_tools" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Tools"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
|
</div>
|
|
</div>
|
|
<svg x-show="currentAgent && m.id === currentAgent.model_name" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs text-dim" x-text="tokenCount > 0 ? '~' + tokenCount + ' tokens' : (attachments.length ? attachments.length + ' file(s)' : '')"></span>
|
|
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="messageQueue.length + ' queued'"></span>
|
|
</div>
|
|
<div class="tip-bar" x-show="currentTip && !sending">
|
|
<span class="text-xs" x-text="currentTip"></span>
|
|
<button class="tip-bar-dismiss" @click="dismissTips()" title="Dismiss">×</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- MODE 2: Agent picker + Templates (no agent chatting) -->
|
|
<div x-show="!activeChatAgent" class="chat-wrapper">
|
|
<div class="page-header">
|
|
<h2>Chat</h2>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" x-show="agents.length" @click="openSpawnWizard()">+ New Agent</button>
|
|
<button class="btn btn-danger btn-sm" x-show="agents.length > 1" @click="killAllAgents()">Stop All</button>
|
|
</div>
|
|
</div>
|
|
<div class="page-body" style="overflow-y:auto;padding:24px">
|
|
|
|
<!-- Running agents — click to chat -->
|
|
<div x-show="agents.length" style="margin-bottom:28px">
|
|
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase">Your Agents</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:10px">
|
|
<template x-for="agent in agents" :key="agent.id">
|
|
<div class="agent-chip" @click="chatWithAgent(agent)" style="cursor:pointer;display:flex;align-items:center;gap:10px;padding:12px 18px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);transition:all 0.2s var(--ease-spring, ease);min-width:200px;box-shadow:var(--shadow-xs)" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.transform='translateY(-2px)';$el.style.boxShadow='var(--shadow-md)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.transform='';$el.style.boxShadow='var(--shadow-xs)'">
|
|
<div style="width:36px;height:36px;border-radius:50%;background:var(--accent-subtle);display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
|
<span x-show="agent.identity && agent.identity.emoji" x-text="agent.identity && agent.identity.emoji" style="font-size:18px"></span>
|
|
<svg x-show="!agent.identity || !agent.identity.emoji" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
</div>
|
|
<div style="min-width:0;flex:1">
|
|
<div class="font-bold" style="font-size:13px">
|
|
<span x-text="agent.name"></span>
|
|
<!-- Issue #1026: live inferencing indicator -->
|
|
<span x-show="agent.is_inferencing" class="agent-inferencing-dot" title="Agent is calling the LLM right now" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;animation:agent-inferencing-pulse 1.2s ease-in-out infinite"></span>
|
|
</div>
|
|
<div class="text-xs text-dim font-mono" style="font-size:11px">
|
|
<span x-show="!agent.is_inferencing" x-text="agent.model_name"></span>
|
|
<span x-show="agent.is_inferencing" style="color:var(--accent);font-weight:600">Inferencing…</span>
|
|
</div>
|
|
</div>
|
|
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
|
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick-start templates — spawn + chat in one click -->
|
|
<div>
|
|
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase" x-text="agents.length ? 'Or Start a New Agent' : 'Start Chatting'"></div>
|
|
<div class="card-grid">
|
|
<template x-for="t in builtinTemplates" :key="t.name">
|
|
<div class="card" style="cursor:pointer" @click="t.manifest_toml ? spawnFromTemplate(t) : spawnBuiltin(t)">
|
|
<div class="flex justify-between items-center mb-1">
|
|
<div class="card-header" style="margin:0;font-size:14px;font-weight:600" x-text="t.name"></div>
|
|
<span class="badge badge-dim" x-text="t.category"></span>
|
|
</div>
|
|
<div class="text-sm" style="line-height:1.5;color:var(--text-dim)" x-text="t.description"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom spawn modal still available -->
|
|
</div>
|
|
|
|
<!-- Agent detail modal with tabs (Info / Files / Config) -->
|
|
<template x-if="showDetailModal && detailAgent">
|
|
<div class="modal-overlay" @click.self="showDetailModal = false" @keydown.escape.window="showDetailModal = false">
|
|
<div class="modal" style="max-width:700px">
|
|
<div class="modal-header">
|
|
<h3>
|
|
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
|
|
<span x-text="detailAgent.name"></span>
|
|
</h3>
|
|
<button class="modal-close" @click="showDetailModal = false">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
<div class="tabs" style="margin-bottom:16px">
|
|
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
|
|
<div class="tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'; loadAgentFiles()">Files</div>
|
|
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'; loadToolFilters()">Config</div>
|
|
</div>
|
|
|
|
<!-- Tab: Info -->
|
|
<div x-show="detailTab === 'info'">
|
|
<div class="detail-grid">
|
|
<div class="detail-row"><span class="detail-label">ID</span><span class="detail-value text-xs" x-text="detailAgent.id" style="word-break:break-all"></span></div>
|
|
<div class="detail-row"><span class="detail-label">State</span><span class="badge" :class="'badge-' + detailAgent.state.toLowerCase()" x-text="detailAgent.state"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Mode</span>
|
|
<select class="form-select" style="width:140px" :value="detailAgent.mode || 'full'" @change="setMode(detailAgent, $event.target.value)">
|
|
<option value="observe">Observe</option><option value="assist">Assist</option><option value="full">Full</option>
|
|
</select>
|
|
</div>
|
|
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Provider</span>
|
|
<template x-if="!editingProvider">
|
|
<span>
|
|
<span class="detail-value" x-text="detailAgent.model_provider"></span>
|
|
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingProvider = true; newProviderValue = detailAgent.model_provider">Change</button>
|
|
</span>
|
|
</template>
|
|
<template x-if="editingProvider">
|
|
<span class="flex gap-1" style="align-items:center">
|
|
<input class="form-input" style="width:160px;font-size:12px" x-model="newProviderValue" placeholder="provider" @keydown.enter="changeProvider()" @keydown.escape="editingProvider = false">
|
|
<button class="btn btn-primary btn-sm" @click="changeProvider()" :disabled="modelSaving" style="padding:2px 10px">
|
|
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="editingProvider = false" style="padding:2px 8px">Cancel</button>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div class="detail-row"><span class="detail-label">Model</span>
|
|
<template x-if="!editingModel">
|
|
<span>
|
|
<span class="detail-value" x-text="detailAgent.model_name"></span>
|
|
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingModel = true; newModelValue = detailAgent.model_provider + '/' + detailAgent.model_name">Change</button>
|
|
</span>
|
|
</template>
|
|
<template x-if="editingModel">
|
|
<span class="flex gap-1" style="align-items:center">
|
|
<input class="form-input" style="width:240px;font-size:12px" x-model="newModelValue" placeholder="provider/model" @keydown.enter="changeModel()" @keydown.escape="editingModel = false">
|
|
<button class="btn btn-primary btn-sm" @click="changeModel()" :disabled="modelSaving" style="padding:2px 10px">
|
|
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="editingModel = false" style="padding:2px 8px">Cancel</button>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
|
|
|
<!-- Fallback Model Chain -->
|
|
<div class="detail-row" style="align-items:flex-start">
|
|
<span class="detail-label">Fallbacks</span>
|
|
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;margin-left:16px">
|
|
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
|
<div style="display:flex;flex-direction:column;gap:4px">
|
|
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
|
<div class="flex gap-1 items-center" style="min-width:0">
|
|
<span class="badge" style="font-size:11px;font-family:var(--font-mono);word-break:break-all;white-space:normal" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
|
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger);flex-shrink:0" @click="removeFallback(idx)">×</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
|
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
|
</template>
|
|
<template x-if="!editingFallback">
|
|
<div>
|
|
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="editingFallback">
|
|
<div class="flex gap-1" style="align-items:center">
|
|
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
|
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
|
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mt-4">
|
|
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
|
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
|
|
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
|
|
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
|
|
<button class="btn btn-danger" @click="uninstallAgent(detailAgent)" title="Stop and remove agent files from workspace">Uninstall</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Files -->
|
|
<div x-show="detailTab === 'files'">
|
|
<div x-show="filesLoading" class="text-center text-dim" style="padding:24px">Loading files...</div>
|
|
<div x-show="!filesLoading && !editingFile">
|
|
<template x-for="file in agentFiles" :key="file.name">
|
|
<div class="file-list-item" @click="openFile(file)">
|
|
<span class="font-mono" style="font-size:13px" x-text="file.name"></span>
|
|
<span class="text-xs text-dim" x-text="file.exists ? (file.size_bytes + ' bytes') : 'Not created'"></span>
|
|
</div>
|
|
</template>
|
|
<div x-show="!agentFiles.length && !filesLoading" class="text-center text-dim" style="padding:24px">No workspace files found</div>
|
|
</div>
|
|
<div x-show="editingFile">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<span class="font-bold font-mono" x-text="editingFile"></span>
|
|
<button class="btn btn-ghost btn-sm" @click="closeFileEditor()">← Back</button>
|
|
</div>
|
|
<textarea class="file-editor" x-model="fileContent" style="width:100%;height:280px;resize:vertical"></textarea>
|
|
<div class="flex gap-2 mt-2">
|
|
<button class="btn btn-primary btn-sm" @click="saveFile()" :disabled="fileSaving"><span x-show="!fileSaving">Save</span><span x-show="fileSaving">Saving...</span></button>
|
|
<button class="btn btn-ghost btn-sm" @click="closeFileEditor()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Config (inline editable) -->
|
|
<div x-show="detailTab === 'config'">
|
|
<div class="form-group"><label>Name</label><input class="form-input" x-model="configForm.name"></div>
|
|
<div class="form-group"><label>System Prompt</label><textarea class="form-textarea" x-model="configForm.system_prompt" style="height:80px"></textarea></div>
|
|
<div class="form-group"><label>Emoji</label>
|
|
<div class="emoji-grid">
|
|
<template x-for="em in emojiOptions" :key="em">
|
|
<button class="emoji-grid-item" :class="{ active: configForm.emoji === em }" @click="configForm.emoji = em" x-text="em"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="form-group"><label>Color</label><input type="color" x-model="configForm.color" style="width:48px;height:32px;border:none;cursor:pointer;background:none"></div>
|
|
<div class="form-group"><label>Archetype</label>
|
|
<select class="form-select" x-model="configForm.archetype">
|
|
<option value="">None</option>
|
|
<template x-for="a in archetypeOptions" :key="a"><option :value="a.toLowerCase()" x-text="a"></option></template>
|
|
</select>
|
|
</div>
|
|
<div class="form-group"><label>Vibe</label>
|
|
<select class="form-select" x-model="configForm.vibe">
|
|
<option value="">None</option>
|
|
<option value="professional">Professional</option><option value="friendly">Friendly</option>
|
|
<option value="technical">Technical</option><option value="creative">Creative</option>
|
|
<option value="concise">Concise</option><option value="mentor">Mentor</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary mt-4" @click="saveConfig()" :disabled="configSaving">
|
|
<span x-show="!configSaving">Save Config</span><span x-show="configSaving">Saving...</span>
|
|
</button>
|
|
|
|
<!-- Tool Filters -->
|
|
<div class="mt-4" style="border-top:1px solid var(--border);padding-top:16px">
|
|
<h4 style="margin-bottom:8px;font-size:13px">Tool Filters</h4>
|
|
<p class="text-xs text-dim" style="margin-bottom:12px">Allowlist: only these tools available (empty = all). Blocklist: these tools excluded.</p>
|
|
<div class="form-group">
|
|
<label style="font-size:12px">Allowlist <span class="text-dim" x-text="'(' + toolFilters.tool_allowlist.length + ')'"></span></label>
|
|
<div class="flex flex-wrap gap-1 mb-1">
|
|
<template x-for="(t, i) in toolFilters.tool_allowlist" :key="'al-'+i">
|
|
<span class="badge" style="cursor:pointer" @click="removeAllowTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
|
</template>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<input class="form-input" style="font-size:12px;flex:1" x-model="newAllowTool" placeholder="tool name" @keydown.enter="addAllowTool()">
|
|
<button class="btn btn-ghost btn-sm" @click="addAllowTool()">Add</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label style="font-size:12px">Blocklist <span class="text-dim" x-text="'(' + toolFilters.tool_blocklist.length + ')'"></span></label>
|
|
<div class="flex flex-wrap gap-1 mb-1">
|
|
<template x-for="(t, i) in toolFilters.tool_blocklist" :key="'bl-'+i">
|
|
<span class="badge badge-danger" style="cursor:pointer" @click="removeBlockTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
|
</template>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<input class="form-input" style="font-size:12px;flex:1" x-model="newBlockTool" placeholder="tool name" @keydown.enter="addBlockTool()">
|
|
<button class="btn btn-ghost btn-sm" @click="addBlockTool()">Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Multi-step Spawn Wizard -->
|
|
<template x-if="showSpawnModal">
|
|
<div class="modal-overlay" @click.self="showSpawnModal = false" @keydown.escape.window="showSpawnModal = false">
|
|
<div class="modal" style="max-width:560px">
|
|
<div class="modal-header">
|
|
<h3>Create Agent</h3>
|
|
<button class="modal-close" @click="showSpawnModal = false">×</button>
|
|
</div>
|
|
|
|
<!-- Wizard / TOML toggle -->
|
|
<div class="tabs" style="margin-bottom:12px">
|
|
<div class="tab" :class="{ active: spawnMode === 'wizard' }" @click="spawnMode = 'wizard'">Wizard</div>
|
|
<div class="tab" :class="{ active: spawnMode === 'toml' }" @click="spawnMode = 'toml'">Raw TOML</div>
|
|
</div>
|
|
|
|
<!-- Raw TOML mode (unchanged) -->
|
|
<div x-show="spawnMode === 'toml'">
|
|
<div class="form-group">
|
|
<label>Agent Manifest (TOML)</label>
|
|
<textarea class="form-textarea" style="height:200px;font-size:11px" x-model="spawnToml" placeholder='name = "my-agent" module = "builtin:chat" [model] provider = "groq" model = "llama-3.3-70b-versatile" system_prompt = "You are helpful."'></textarea>
|
|
</div>
|
|
<button class="btn btn-primary btn-block mt-4" @click="spawnAgent()" :disabled="spawning">
|
|
<span x-show="!spawning">Spawn Agent</span><span x-show="spawning">Spawning...</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Wizard steps -->
|
|
<div x-show="spawnMode === 'wizard'">
|
|
<!-- Step indicator -->
|
|
<div class="wizard-steps">
|
|
<template x-for="s in [1,2,3,4,5]" :key="s">
|
|
<div class="wizard-dot" :class="{ active: spawnStep === s, done: spawnStep > s }">
|
|
<span x-text="spawnStep > s ? '\u2713' : s"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Step 1: Name + Identity -->
|
|
<div x-show="spawnStep === 1">
|
|
<div class="form-group">
|
|
<label>Agent Name</label>
|
|
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Emoji</label>
|
|
<div class="emoji-grid">
|
|
<template x-for="em in emojiOptions" :key="em">
|
|
<button class="emoji-grid-item" :class="{ active: spawnIdentity.emoji === em }" @click="spawnIdentity.emoji = em" x-text="em"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Color</label>
|
|
<input type="color" x-model="spawnIdentity.color" style="width:48px;height:32px;border:none;cursor:pointer;background:none">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Archetype</label>
|
|
<select class="form-select" x-model="spawnIdentity.archetype">
|
|
<option value="">Choose...</option>
|
|
<template x-for="a in archetypeOptions" :key="a"><option :value="a.toLowerCase()" x-text="a"></option></template>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Model Selection -->
|
|
<div x-show="spawnStep === 2">
|
|
<div class="form-group">
|
|
<label>Provider</label>
|
|
<select class="form-select" x-model="spawnForm.provider">
|
|
<template x-if="spawnProvidersLoading">
|
|
<option disabled>Loading providers…</option>
|
|
</template>
|
|
<template x-if="!spawnProvidersLoading && spawnProviders.length === 0">
|
|
<option disabled>No providers available</option>
|
|
</template>
|
|
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => !p.is_local).length > 0">
|
|
<optgroup label="Cloud">
|
|
<template x-for="p in spawnProviders.filter(p => !p.is_local)" :key="p.id">
|
|
<option :value="p.id" x-text="p.display_name"></option>
|
|
</template>
|
|
</optgroup>
|
|
</template>
|
|
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => p.is_local).length > 0">
|
|
<optgroup label="Local">
|
|
<template x-for="p in spawnProviders.filter(p => p.is_local)" :key="p.id">
|
|
<option :value="p.id" x-text="p.display_name"></option>
|
|
</template>
|
|
</optgroup>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Model</label>
|
|
<input class="form-input" x-model="spawnForm.model" placeholder="e.g. llama-3.3-70b-versatile">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>System Prompt</label>
|
|
<textarea class="form-textarea" x-model="spawnForm.systemPrompt" placeholder="You are a helpful assistant."></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Personality Presets -->
|
|
<div x-show="spawnStep === 3">
|
|
<label class="mb-2" style="display:block;font-size:13px;font-weight:600">Personality</label>
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:16px">
|
|
<template x-for="preset in personalityPresets" :key="preset.id">
|
|
<button class="personality-pill" :class="{ active: selectedPreset === preset.id }" @click="selectPreset(preset)" x-text="preset.label"></button>
|
|
</template>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Soul / Persona <span class="text-xs text-dim">(editable)</span></label>
|
|
<textarea class="form-textarea" x-model="soulContent" style="height:100px" placeholder="Describe this agent's personality and communication style..."></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4: Tools & Capabilities -->
|
|
<div x-show="spawnStep === 4">
|
|
<div class="form-group">
|
|
<label>Tool Profile</label>
|
|
<select class="form-select" x-model="spawnForm.profile" @focus="loadSpawnProfiles()">
|
|
<option value="minimal">Minimal — Read-only file access</option>
|
|
<option value="coding">Coding — Files + shell + web fetch</option>
|
|
<option value="research">Research — Web search + file read/write</option>
|
|
<option value="messaging">Messaging — Agents + memory access</option>
|
|
<option value="automation">Automation — All tools except custom</option>
|
|
<option value="full" selected>Full — All 35+ tools</option>
|
|
<option value="custom">Custom (manual capabilities)</option>
|
|
</select>
|
|
<div class="capability-preview" x-show="selectedProfileTools.length > 0" style="margin-top:8px">
|
|
<div class="capability-preview-title">Tools included</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:2px">
|
|
<template x-for="tool in selectedProfileTools" :key="tool"><span class="tool-badge" x-text="tool"></span></template>
|
|
<span class="tool-badge" x-show="selectedProfileTools.length >= 15" style="opacity:0.6">...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-group" x-show="spawnForm.profile === 'custom'">
|
|
<label>Capabilities</label>
|
|
<div class="flex gap-3" style="flex-wrap:wrap">
|
|
<label class="form-checkbox"><input type="checkbox" x-model="spawnForm.caps.memory_read"> Memory Read</label>
|
|
<label class="form-checkbox"><input type="checkbox" x-model="spawnForm.caps.memory_write"> Memory Write</label>
|
|
<label class="form-checkbox"><input type="checkbox" x-model="spawnForm.caps.network"> Network</label>
|
|
<label class="form-checkbox"><input type="checkbox" x-model="spawnForm.caps.shell"> Shell</label>
|
|
<label class="form-checkbox"><input type="checkbox" x-model="spawnForm.caps.agent_spawn"> Agent Spawn</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 5: Review & Spawn -->
|
|
<div x-show="spawnStep === 5">
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
|
<div style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:22px" :style="'background:' + spawnIdentity.color + '22; border:2px solid ' + spawnIdentity.color">
|
|
<span x-text="spawnIdentity.emoji || '\u{1F916}'"></span>
|
|
</div>
|
|
<div>
|
|
<div class="font-bold" style="font-size:15px" x-text="spawnForm.name || 'Unnamed'"></div>
|
|
<div class="text-xs text-dim" x-text="spawnIdentity.archetype || 'agent'"></div>
|
|
</div>
|
|
</div>
|
|
<div class="detail-grid" style="gap:6px">
|
|
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="spawnForm.provider"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value" x-text="spawnForm.model"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Profile</span><span class="detail-value" x-text="spawnForm.profile"></span></div>
|
|
<div class="detail-row" x-show="selectedPreset"><span class="detail-label">Personality</span><span class="detail-value" style="text-transform:capitalize" x-text="selectedPreset"></span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation buttons -->
|
|
<div class="flex justify-between mt-4">
|
|
<button class="btn btn-ghost" x-show="spawnStep > 1" @click="prevStep()">Back</button>
|
|
<div x-show="spawnStep <= 1"></div>
|
|
<button class="btn btn-primary" x-show="spawnStep < 5" @click="nextStep()">Next</button>
|
|
<button class="btn btn-primary" x-show="spawnStep === 5" @click="spawnAgent()" :disabled="spawning">
|
|
<span x-show="!spawning">Spawn Agent</span><span x-show="spawning">Spawning...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
</div><!-- /activeChatAgent picker wrapper -->
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Approvals -->
|
|
<template x-if="page === 'approvals'">
|
|
<div x-data="approvalsPage()" x-init="init()">
|
|
<div class="page-header">
|
|
<h2>Execution Approvals</h2>
|
|
<div class="flex items-center gap-2">
|
|
<span class="badge badge-warn" x-show="pendingCount > 0" x-text="pendingCount + ' pending'"></span>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Refresh</button>
|
|
</div>
|
|
</div>
|
|
<div class="page-body">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<template x-if="!loading && !loadError">
|
|
<div>
|
|
<div class="filter-pills mb-4">
|
|
<button class="filter-pill" :class="{ active: filterStatus === 'all' }" @click="filterStatus = 'all'">All</button>
|
|
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
|
|
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
|
|
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
|
|
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
|
|
</div>
|
|
<div x-show="filtered.length === 0" class="empty-state">
|
|
<h4>No approvals</h4>
|
|
<p class="hint">When agents request permission for sensitive actions, they'll appear here.</p>
|
|
</div>
|
|
<div class="card-grid">
|
|
<template x-for="a in filtered" :key="a.id">
|
|
<div class="card approval-card" :class="{ approved: a.status === 'approved', rejected: a.status === 'rejected', expired: a.status === 'expired' }">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<span class="card-header" style="margin:0" x-text="a.action"></span>
|
|
<span class="badge" :class="{ 'badge-warn': a.status === 'pending', 'badge-success': a.status === 'approved', 'badge-error': a.status === 'rejected', 'badge-muted': a.status === 'expired' }" x-text="a.status"></span>
|
|
</div>
|
|
<div class="text-sm text-dim mb-2" x-text="a.description"></div>
|
|
<div class="text-xs text-dim">Agent: <span x-text="a.agent_name"></span> · <span x-text="timeAgo(a.created_at)"></span></div>
|
|
<template x-if="a.status === 'pending'">
|
|
<div class="approval-actions" style="display:flex;gap:8px;margin-top:12px">
|
|
<button class="btn btn-success btn-sm" @click="approve(a.id)">Approve</button>
|
|
<button class="btn btn-danger btn-sm" @click="reject(a.id)">Reject</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Workflows -->
|
|
<template x-if="page === 'workflows'">
|
|
<div x-data="{ wfTab: 'list' }">
|
|
<div class="page-header">
|
|
<h2>Workflows</h2>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" :class="{ active: wfTab === 'list' }" @click="wfTab = 'list'">List</button>
|
|
<button class="btn btn-ghost btn-sm" :class="{ active: wfTab === 'builder' }" @click="wfTab = 'builder'">Visual Builder</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: List -->
|
|
<template x-if="wfTab === 'list'">
|
|
<div x-data="workflowsPage" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
|
|
<div class="page-body" x-init="loadWorkflows()">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading workflows...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:4px">What are Workflows?</div>
|
|
<div class="text-sm text-dim" style="line-height:1.6">
|
|
Workflows chain multiple agents into automated pipelines. Each step runs an agent with a prompt template,
|
|
passing output from one step as input to the next. Steps can run sequentially, fan out in parallel, loop, or branch conditionally.
|
|
<br><span style="margin-top:4px;display:inline-block">Try the <strong style="color:var(--accent);cursor:pointer" @click="$dispatch('wf-switch-tab','builder')">Visual Builder</strong> to drag and drop workflow steps.</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mb-4">
|
|
<button class="btn btn-primary btn-sm" @click="showCreateModal = true">+ New Workflow</button>
|
|
</div>
|
|
<div class="table-wrap" x-show="workflows.length">
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Steps</th><th>Created</th><th>Actions</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="wf in workflows" :key="wf.id">
|
|
<tr>
|
|
<td><span class="font-bold" x-text="wf.name"></span><br><span class="text-xs text-dim" x-text="wf.description"></span></td>
|
|
<td x-text="Array.isArray(wf.steps) ? wf.steps.length + ' step' + (wf.steps.length !== 1 ? 's' : '') : wf.steps"></td>
|
|
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
|
|
<td>
|
|
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
|
|
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
|
|
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
|
|
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!workflows.length">
|
|
<div class="empty-state-icon">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/></svg>
|
|
</div>
|
|
<h3>No workflows yet</h3>
|
|
<p>Chain multiple agents into automated pipelines with branching, fan-out, and loops.</p>
|
|
<button class="btn btn-primary" @click="showCreateModal = true">Create Workflow</button>
|
|
</div>
|
|
</div>
|
|
<!-- Create modal -->
|
|
<template x-if="showCreateModal">
|
|
<div class="modal-overlay" @click.self="showCreateModal = false" @keydown.escape.window="showCreateModal = false">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3>Create Workflow</h3><button class="modal-close" @click="showCreateModal = false">×</button></div>
|
|
<div class="form-group"><label>Name</label><input class="form-input" x-model="newWf.name" placeholder="my-workflow"></div>
|
|
<div class="form-group"><label>Description</label><input class="form-input" x-model="newWf.description" placeholder="What does this workflow do?"></div>
|
|
<div class="mb-4">
|
|
<div class="form-group" style="margin:0"><label>Steps</label></div>
|
|
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
|
|
<template x-for="(step, i) in newWf.steps" :key="i">
|
|
<div class="card mt-2" style="padding:10px">
|
|
<div class="flex gap-2 items-center">
|
|
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
|
|
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
|
|
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
|
|
<select class="form-select" style="width:120px" x-model="step.mode">
|
|
<option value="sequential">Sequential</option>
|
|
<option value="fan_out">Fan Out</option>
|
|
<option value="conditional">Conditional</option>
|
|
<option value="loop">Loop</option>
|
|
</select>
|
|
<button class="btn btn-danger btn-sm" @click="newWf.steps.splice(i,1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
|
</div>
|
|
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
|
|
</div>
|
|
</template>
|
|
<button class="btn btn-ghost btn-sm mt-2" @click="newWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
|
|
</div>
|
|
<button class="btn btn-primary btn-block" @click="createWorkflow()">Create</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Run modal -->
|
|
<template x-if="runModal">
|
|
<div class="modal-overlay" @click.self="runModal = null" @keydown.escape.window="runModal = null">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3 x-text="'Run: ' + runModal.name"></h3><button class="modal-close" @click="runModal = null"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
|
<div class="form-group"><label>Input</label><textarea class="form-textarea" x-model="runInput" placeholder="Enter workflow input..."></textarea></div>
|
|
<button class="btn btn-primary btn-block" @click="executeWorkflow()" :disabled="running">
|
|
<span x-show="!running">Execute</span><span x-show="running">Running...</span>
|
|
</button>
|
|
<div class="card mt-4" x-show="runResult">
|
|
<div class="card-header">Result</div>
|
|
<pre style="font-size:11px;white-space:pre-wrap;margin-top:8px;color:var(--text-dim)" x-text="runResult"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Edit modal -->
|
|
<template x-if="editModal">
|
|
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
|
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
|
|
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
|
|
<div class="mb-4">
|
|
<div class="form-group" style="margin:0"><label>Steps</label></div>
|
|
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
|
|
<template x-for="(step, i) in editWf.steps" :key="i">
|
|
<div class="card mt-2" style="padding:10px">
|
|
<div class="flex gap-2 items-center">
|
|
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
|
|
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
|
|
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
|
|
<select class="form-select" style="width:120px" x-model="step.mode">
|
|
<option value="sequential">Sequential</option>
|
|
<option value="fan_out">Fan Out</option>
|
|
<option value="conditional">Conditional</option>
|
|
<option value="loop">Loop</option>
|
|
</select>
|
|
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
|
</div>
|
|
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
|
|
</div>
|
|
</template>
|
|
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
|
|
</div>
|
|
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Tab: Visual Builder -->
|
|
<template x-if="wfTab === 'builder'">
|
|
<div x-data="workflowBuilder()" x-init="init()">
|
|
<div class="wf-builder-layout">
|
|
<!-- Node palette (left sidebar) -->
|
|
<div class="wf-palette">
|
|
<div class="wf-palette-title">Node Palette</div>
|
|
<div class="text-xs text-dim mb-3">Drag nodes onto the canvas</div>
|
|
<template x-for="nt in nodeTypes" :key="nt.type">
|
|
<div class="wf-palette-node" draggable="true" @dragstart="onPaletteDragStart(nt.type, $event)"
|
|
:style="'border-left: 3px solid ' + nt.color">
|
|
<span class="wf-palette-icon" :style="'background:' + nt.color" x-text="nt.icon"></span>
|
|
<span class="text-xs" x-text="nt.label"></span>
|
|
</div>
|
|
</template>
|
|
<hr style="border-color:var(--border);margin:12px 0">
|
|
<div class="text-xs text-dim mb-2">Workflow</div>
|
|
<div class="form-group" style="margin-bottom:8px">
|
|
<input class="form-input" x-model="workflowName" placeholder="Workflow name" style="font-size:11px">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:8px">
|
|
<input class="form-input" x-model="workflowDescription" placeholder="Description" style="font-size:11px">
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<button class="btn btn-primary btn-sm btn-block" @click="generateToml()">Export TOML</button>
|
|
<button class="btn btn-primary btn-sm btn-block" @click="showSaveModal = true">Save Workflow</button>
|
|
<button class="btn btn-ghost btn-sm btn-block" @click="autoLayout()">Auto Layout</button>
|
|
<button class="btn btn-ghost btn-sm btn-block" @click="clearCanvas()">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Canvas -->
|
|
<div class="wf-canvas-wrap">
|
|
<!-- Zoom controls -->
|
|
<div class="wf-zoom-controls">
|
|
<button class="btn btn-ghost btn-sm" @click="zoomOut()" title="Zoom out">-</button>
|
|
<span class="text-xs" x-text="Math.round(zoom * 100) + '%'" style="min-width:36px;text-align:center"></span>
|
|
<button class="btn btn-ghost btn-sm" @click="zoomIn()" title="Zoom in">+</button>
|
|
<button class="btn btn-ghost btn-sm" @click="zoomReset()" title="Reset view">Fit</button>
|
|
</div>
|
|
|
|
<svg id="wf-canvas" class="wf-canvas"
|
|
@mousedown="onCanvasMouseDown($event)"
|
|
@mousemove="onCanvasMouseMove($event)"
|
|
@mouseup="onCanvasMouseUp()"
|
|
@mouseleave="onCanvasMouseUp()"
|
|
@wheel.prevent="onCanvasWheel($event)"
|
|
@drop="onCanvasDrop($event)"
|
|
@dragover="onCanvasDragOver($event)">
|
|
<g :transform="'translate(' + (canvasOffset.x * zoom) + ',' + (canvasOffset.y * zoom) + ') scale(' + zoom + ')'">
|
|
<!-- Grid pattern -->
|
|
<defs>
|
|
<pattern id="wf-grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
|
<circle cx="1" cy="1" r="0.5" fill="var(--text-dim)" opacity="0.15"/>
|
|
</pattern>
|
|
</defs>
|
|
<rect x="-2000" y="-2000" width="6000" height="6000" fill="url(#wf-grid)"/>
|
|
|
|
<!-- Manually rendered nodes & connections (Alpine x-for breaks inside SVG) -->
|
|
<g id="wf-render-group" x-effect="nodes.length; connections.length; selectedNode; selectedConnection; connecting; connectPreview; scheduleRender()"></g>
|
|
</g>
|
|
</svg>
|
|
|
|
<!-- Canvas hints -->
|
|
<div class="wf-canvas-hint" x-show="nodes.length <= 1">
|
|
Drag nodes from the palette onto the canvas. Connect output ports (bottom) to input ports (top). Double-click to edit.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Node editor panel (right sidebar) -->
|
|
<div class="wf-editor-panel" x-show="showNodeEditor && selectedNode" x-transition>
|
|
<div class="wf-editor-header">
|
|
<span class="font-bold text-sm" x-text="selectedNode ? selectedNode.label : ''"></span>
|
|
<button class="btn btn-ghost btn-sm" @click="showNodeEditor = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
|
</div>
|
|
<template x-if="selectedNode">
|
|
<div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Label</label>
|
|
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
|
|
</div>
|
|
|
|
<!-- Agent config -->
|
|
<template x-if="selectedNode.type === 'agent'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Agent</label>
|
|
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
|
|
<option value="">Select agent...</option>
|
|
<template x-for="a in agents" :key="a.id || a.name">
|
|
<option :value="a.name" x-text="a.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Prompt Template</label>
|
|
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Model (optional)</label>
|
|
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Condition config -->
|
|
<template x-if="selectedNode.type === 'condition'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Expression</label>
|
|
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
|
|
</div>
|
|
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Loop config -->
|
|
<template x-if="selectedNode.type === 'loop'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Max Iterations</label>
|
|
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Until (stop condition)</label>
|
|
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Parallel config -->
|
|
<template x-if="selectedNode.type === 'parallel'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Fan-out Count</label>
|
|
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Collect config -->
|
|
<template x-if="selectedNode.type === 'collect'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label class="text-xs">Strategy</label>
|
|
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
|
|
<option value="all">Wait for all</option>
|
|
<option value="first">First to finish</option>
|
|
<option value="majority">Majority vote</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<hr style="border-color:var(--border);margin:12px 0">
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="duplicateNode(selectedNode)">Duplicate</button>
|
|
<button class="btn btn-danger btn-sm" @click="deleteNode(selectedNode.id)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete connection hint -->
|
|
<div class="wf-conn-hint" x-show="selectedConnection" x-transition>
|
|
<span class="text-xs">Connection selected</span>
|
|
<button class="btn btn-danger btn-sm" @click="deleteConnection(selectedConnection.id)">Delete Connection</button>
|
|
</div>
|
|
|
|
<!-- TOML Preview modal -->
|
|
<template x-if="showTomlPreview">
|
|
<div class="modal-overlay" @click.self="showTomlPreview = false" @keydown.escape.window="showTomlPreview = false">
|
|
<div class="modal" style="max-width:600px">
|
|
<div class="modal-header"><h3>Generated TOML</h3><button class="modal-close" @click="showTomlPreview = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
|
<pre class="wf-toml-preview" x-text="tomlOutput"></pre>
|
|
<button class="btn btn-ghost btn-block mt-2" @click="navigator.clipboard.writeText(tomlOutput); OpenFangToast.success('Copied!')">Copy to Clipboard</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Save modal -->
|
|
<template x-if="showSaveModal">
|
|
<div class="modal-overlay" @click.self="showSaveModal = false" @keydown.escape.window="showSaveModal = false">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3>Save Workflow</h3><button class="modal-close" @click="showSaveModal = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
|
<div class="form-group"><label>Name</label><input class="form-input" x-model="workflowName" placeholder="my-workflow"></div>
|
|
<div class="form-group"><label>Description</label><input class="form-input" x-model="workflowDescription" placeholder="What does this workflow do?"></div>
|
|
<div class="text-xs text-dim mb-4" x-text="nodes.filter(function(n){return n.type!=='start'&&n.type!=='end'}).length + ' steps, ' + connections.length + ' connections'"></div>
|
|
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Scheduler -->
|
|
<template x-if="page === 'scheduler'">
|
|
<div x-data="schedulerPage()">
|
|
<div class="page-header">
|
|
<h2>Scheduler</h2>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary btn-sm" x-show="tab === 'jobs'" @click="showCreateForm = true">+ New Job</button>
|
|
</div>
|
|
</div>
|
|
<div class="tabs" role="tablist">
|
|
<div class="tab" role="tab" :class="{ active: tab === 'jobs' }" @click="tab = 'jobs'">
|
|
Scheduled Jobs <span class="badge badge-dim" x-show="jobs.length" x-text="jobCount() + '/' + jobs.length + ' active'" style="margin-left:4px"></span>
|
|
</div>
|
|
<div class="tab" role="tab" :class="{ active: tab === 'triggers' }" @click="tab = 'triggers'; if (!triggers.length && !trigLoading) loadTriggers()">
|
|
Event Triggers <span class="badge badge-dim" x-show="triggers.length" x-text="triggers.length" style="margin-left:4px"></span>
|
|
</div>
|
|
<div class="tab" :class="{ active: tab === 'history' }" @click="tab = 'history'; loadHistory()">
|
|
Run History
|
|
</div>
|
|
</div>
|
|
<div class="page-body" x-init="loadData()">
|
|
|
|
<!-- ── TAB: Scheduled Jobs ── -->
|
|
<div x-show="tab === 'jobs'">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading scheduled jobs...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
<!-- Explainer -->
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:4px">Scheduled Jobs</div>
|
|
<div class="text-sm text-dim" style="line-height:1.6">
|
|
Create cron-based scheduled jobs that send messages to agents on a recurring schedule.
|
|
Use cron expressions like <code style="color:var(--accent)">*/5 * * * *</code> (every 5 min) or
|
|
<code style="color:var(--accent)">0 9 * * 1-5</code> (weekdays at 9am). You can also run any job
|
|
manually with the "Run Now" button.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Jobs Table -->
|
|
<div class="table-wrap" x-show="jobs.length">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Schedule</th>
|
|
<th>Agent</th>
|
|
<th>Status</th>
|
|
<th>Delivery</th>
|
|
<th>Last Run</th>
|
|
<th>Next Run</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="job in jobs" :key="job.id">
|
|
<tr>
|
|
<td>
|
|
<span class="font-bold" x-text="job.name || job.description || '(unnamed)'"></span>
|
|
<div class="text-xs text-dim" x-show="job.message" x-text="(job.message || '').substring(0, 60) + ((job.message || '').length > 60 ? '...' : '')" :title="job.message"></div>
|
|
<div x-show="expandedJobId === job.id" style="margin-top:8px;padding:8px;background:rgba(148,163,184,0.06);border-left:3px solid var(--accent);border-radius:3px">
|
|
<div class="font-bold" style="font-size:12px;margin-bottom:6px">Delivery Log</div>
|
|
<div x-show="deliveryLogLoading" class="text-xs text-dim">Loading delivery log...</div>
|
|
<div x-show="!deliveryLogLoading && deliveryLogError" class="text-xs" style="color:var(--error)" x-text="deliveryLogError"></div>
|
|
<div x-show="!deliveryLogLoading && !deliveryLogError">
|
|
<div class="text-xs text-dim" style="margin-bottom:4px">Configured targets (<span x-text="(deliveryLog.targets || []).length"></span>):</div>
|
|
<div x-show="!(deliveryLog.targets || []).length" class="text-xs text-dim">No fan-out targets configured.</div>
|
|
<template x-for="(t, ti) in (deliveryLog.targets || [])" :key="ti">
|
|
<div class="text-xs" style="padding:2px 0">
|
|
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
|
|
<span class="text-dim" style="margin-left:6px" x-text="targetSummary(t)"></span>
|
|
</div>
|
|
</template>
|
|
<div class="text-xs text-dim" style="margin-top:8px">Recent deliveries (<span x-text="(deliveryLog.entries || []).length"></span>):</div>
|
|
<div x-show="!(deliveryLog.entries || []).length" class="text-xs text-dim">
|
|
Delivery history will appear here after the next run. Per-run results are not yet
|
|
persisted — the fan-out engine emits tracing events you can view under
|
|
<a href="#logs" style="color:var(--accent)">Logs</a>.
|
|
</div>
|
|
<template x-for="(e, ei) in (deliveryLog.entries || [])" :key="ei">
|
|
<div class="text-xs" style="padding:2px 0">
|
|
<span class="badge" :class="e.success ? 'badge-success' : 'badge-error'" x-text="e.success ? 'ok' : 'err'"></span>
|
|
<span style="margin-left:6px" x-text="e.target"></span>
|
|
<span class="text-dim" style="margin-left:6px" x-show="e.error" x-text="e.error"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<code style="font-size:11px;color:var(--accent)" x-text="job.cron"></code>
|
|
<div class="text-xs text-dim" x-text="describeCron(job.cron)"></div>
|
|
</td>
|
|
<td class="truncate" style="max-width:120px" x-text="agentName(job.agent_id || job.agent)" :title="job.agent_id || job.agent"></td>
|
|
<td>
|
|
<span class="badge" :class="job.enabled ? 'badge-success' : 'badge-dim'" x-text="job.enabled ? 'Active' : 'Paused'"></span>
|
|
</td>
|
|
<td>
|
|
<div class="flex gap-1" style="flex-wrap:wrap;max-width:240px" x-show="(job.delivery_targets || []).length">
|
|
<template x-for="(t, ti) in (job.delivery_targets || [])" :key="ti">
|
|
<span class="badge" :class="targetChipClass(t)" :title="targetSummary(t)" x-text="targetChipLabel(t)"></span>
|
|
</template>
|
|
</div>
|
|
<span class="text-xs text-dim" x-show="!(job.delivery_targets || []).length">(none)</span>
|
|
</td>
|
|
<td class="text-xs" :title="formatTime(job.last_run)" x-text="relativeTime(job.last_run)"></td>
|
|
<td class="text-xs" :title="formatTime(job.next_run)" x-text="relativeTime(job.next_run)"></td>
|
|
<td>
|
|
<div class="flex gap-1" style="flex-wrap:wrap">
|
|
<button class="btn btn-primary btn-sm" @click="runNow(job)" :disabled="runningJobId === job.id">
|
|
<span x-show="runningJobId !== job.id">Run</span>
|
|
<span x-show="runningJobId === job.id">...</span>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="toggleJob(job)" x-text="job.enabled ? 'Pause' : 'Enable'"></button>
|
|
<button class="btn btn-ghost btn-sm" @click="toggleExpand(job)" x-text="expandedJobId === job.id ? 'Hide' : 'Details'"></button>
|
|
<button class="btn btn-ghost btn-sm" @click="startEditTargets(job)">Targets</button>
|
|
<button class="btn btn-danger btn-sm" @click="deleteJob(job)">Del</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div class="empty-state" x-show="!jobs.length">
|
|
<h4>No scheduled jobs</h4>
|
|
<p class="hint">Create a cron job to run agents on a recurring schedule. Jobs are stored persistently and survive restarts.</p>
|
|
<button class="btn btn-primary mt-4" @click="showCreateForm = true">+ Create Scheduled Job</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Job Modal -->
|
|
<template x-if="showCreateForm">
|
|
<div class="modal-overlay" @click.self="showCreateForm = false" @keydown.escape.window="showCreateForm = false">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Create Scheduled Job</h3>
|
|
<button class="modal-close" @click="showCreateForm = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Job Name</label>
|
|
<input class="form-input" x-model="newJob.name" placeholder="daily-report">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Cron Expression</label>
|
|
<input class="form-input" x-model="newJob.cron" placeholder="0 9 * * 1-5" style="font-family:monospace">
|
|
<div class="text-xs text-dim mt-1" x-show="newJob.cron" x-text="describeCron(newJob.cron)"></div>
|
|
<div class="text-xs text-dim mt-1">Format: <code>minute hour day-of-month month day-of-week</code></div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Quick Presets</label>
|
|
<div class="flex gap-1 flex-wrap">
|
|
<template x-for="preset in cronPresets" :key="preset.cron">
|
|
<button class="btn btn-sm" :class="newJob.cron === preset.cron ? 'btn-primary' : 'btn-ghost'" @click="applyCronPreset(preset)" x-text="preset.label"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Target Agent</label>
|
|
<select class="form-select" x-model="newJob.agent_id">
|
|
<option value="">Any available agent</option>
|
|
<template x-for="a in availableAgents" :key="a.id">
|
|
<option :value="a.id" x-text="a.name + ' (' + (a.model_provider || 'unknown') + ':' + (a.model_name || 'unknown') + ')'"></option>
|
|
</template>
|
|
</select>
|
|
<div class="text-xs text-dim mt-1" x-show="!availableAgents.length">No agents running. <a href="#agents" style="color:var(--accent)">Spawn one first.</a></div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Message to Send</label>
|
|
<textarea class="form-textarea" x-model="newJob.message" placeholder="Generate and email the daily status report..." rows="3"></textarea>
|
|
<div class="text-xs text-dim mt-1">The message sent to the agent each time this job runs.</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="flex items-center gap-2">
|
|
<div class="toggle" :class="{ active: newJob.enabled }" @click="newJob.enabled = !newJob.enabled"></div>
|
|
<span x-text="newJob.enabled ? 'Enabled (will start running immediately)' : 'Disabled (create paused)'"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<div class="flex items-center justify-between" style="margin-bottom:6px">
|
|
<label style="margin:0">Delivery Targets</label>
|
|
<button class="btn btn-ghost btn-sm" type="button" @click="openTargetPicker()">+ Add target</button>
|
|
</div>
|
|
<div class="text-xs text-dim mt-1" x-show="!(newJob.delivery_targets || []).length">
|
|
The job output is delivered to the agent's last channel by default. Add one or more
|
|
fan-out destinations — channels, webhooks, local files, or email — to copy the output
|
|
to extra destinations each run.
|
|
</div>
|
|
<div x-show="(newJob.delivery_targets || []).length" style="margin-top:6px">
|
|
<template x-for="(t, ti) in (newJob.delivery_targets || [])" :key="ti">
|
|
<div class="card" style="padding:8px;margin-bottom:6px">
|
|
<div class="flex items-center justify-between">
|
|
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
|
|
<button class="btn btn-ghost btn-sm" type="button" @click="removeTarget(ti)">Remove</button>
|
|
</div>
|
|
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Target picker (shared by create + edit flows) -->
|
|
<div class="card" x-show="showTargetPicker && !editingTargetsJobId" style="border-left:3px solid var(--accent);margin-top:8px">
|
|
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
|
|
<div class="form-group">
|
|
<label>Type</label>
|
|
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
|
|
<option value="channel">Channel</option>
|
|
<option value="webhook">Webhook</option>
|
|
<option value="local_file">Local File</option>
|
|
<option value="email">Email</option>
|
|
</select>
|
|
</div>
|
|
<template x-if="draftTarget && draftTarget.type === 'channel'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>Channel type</label>
|
|
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
|
|
<option value="">-- pick --</option>
|
|
<template x-for="c in channelTypes" :key="c">
|
|
<option :value="c" x-text="c"></option>
|
|
</template>
|
|
</select>
|
|
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Recipient</label>
|
|
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="draftTarget && draftTarget.type === 'webhook'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>URL</label>
|
|
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Authorization header (optional)</label>
|
|
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="draftTarget && draftTarget.type === 'local_file'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>Path</label>
|
|
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="flex items-center gap-2">
|
|
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
|
|
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="draftTarget && draftTarget.type === 'email'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>To</label>
|
|
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Subject template (optional)</label>
|
|
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
|
|
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="flex gap-2" style="margin-top:6px">
|
|
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTarget()">Add</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-primary btn-block mt-4" @click="createJob()" :disabled="creating">
|
|
<span x-show="!creating">Create Schedule</span>
|
|
<span x-show="creating">Creating...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Edit Targets Modal -->
|
|
<template x-if="editingTargetsJobId">
|
|
<div class="modal-overlay" @click.self="cancelEditTargets()" @keydown.escape.window="cancelEditTargets()">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Edit Delivery Targets</h3>
|
|
<button class="modal-close" @click="cancelEditTargets()">x</button>
|
|
</div>
|
|
<div class="form-group">
|
|
<div class="flex items-center justify-between" style="margin-bottom:6px">
|
|
<div class="text-xs text-dim">
|
|
Targets are replaced on save. Leave empty to remove all fan-out destinations.
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm" type="button" @click="addEditTarget()">+ Add target</button>
|
|
</div>
|
|
<div x-show="!editingTargets.length" class="text-xs text-dim">No targets configured.</div>
|
|
<template x-for="(t, ti) in editingTargets" :key="ti">
|
|
<div class="card" style="padding:8px;margin-bottom:6px">
|
|
<div class="flex items-center justify-between">
|
|
<span class="badge" :class="targetChipClass(t)" x-text="targetChipLabel(t)"></span>
|
|
<button class="btn btn-ghost btn-sm" type="button" @click="removeEditTarget(ti)">Remove</button>
|
|
</div>
|
|
<div class="text-xs text-dim" style="margin-top:4px" x-text="targetSummary(t)"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="card" x-show="showTargetPicker" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:12px;margin-bottom:6px">New Delivery Target</div>
|
|
<div class="form-group">
|
|
<label>Type</label>
|
|
<select class="form-select" x-model="pickerType" @change="onPickerTypeChange()">
|
|
<option value="channel">Channel</option>
|
|
<option value="webhook">Webhook</option>
|
|
<option value="local_file">Local File</option>
|
|
<option value="email">Email</option>
|
|
</select>
|
|
</div>
|
|
<template x-if="draftTarget && draftTarget.type === 'channel'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>Channel type</label>
|
|
<select class="form-select" x-model="draftTarget.channel_type" x-show="channelTypes.length">
|
|
<option value="">-- pick --</option>
|
|
<template x-for="c in channelTypes" :key="c">
|
|
<option :value="c" x-text="c"></option>
|
|
</template>
|
|
</select>
|
|
<input class="form-input" x-model="draftTarget.channel_type" placeholder="telegram" x-show="!channelTypes.length">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Recipient</label>
|
|
<input class="form-input" x-model="draftTarget.recipient" placeholder="chat_12345">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="draftTarget && draftTarget.type === 'webhook'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>URL</label>
|
|
<input class="form-input" x-model="draftTarget.url" placeholder="https://example.com/hook">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Authorization header (optional)</label>
|
|
<input class="form-input" x-model="draftTarget.auth_header" placeholder="Bearer abc123">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="draftTarget && draftTarget.type === 'local_file'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>Path</label>
|
|
<input class="form-input" x-model="draftTarget.path" placeholder="/var/log/openfang-cron.log">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="flex items-center gap-2">
|
|
<div class="toggle" :class="{ active: draftTarget.append }" @click="draftTarget.append = !draftTarget.append"></div>
|
|
<span x-text="draftTarget.append ? 'Append' : 'Overwrite'"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="draftTarget && draftTarget.type === 'email'">
|
|
<div>
|
|
<div class="form-group">
|
|
<label>To</label>
|
|
<input class="form-input" x-model="draftTarget.to" placeholder="alice@example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Subject template (optional)</label>
|
|
<input class="form-input" x-model="draftTarget.subject_template" placeholder="Cron: {job}">
|
|
<div class="text-xs text-dim mt-1">Placeholders: <code>{job}</code></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="flex gap-2" style="margin-top:6px">
|
|
<button class="btn btn-primary btn-sm" type="button" @click="addDraftTargetToEdit()">Add</button>
|
|
<button class="btn btn-ghost btn-sm" type="button" @click="cancelTargetPicker()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mt-4">
|
|
<button class="btn btn-primary" @click="saveEditTargets()" :disabled="savingTargets">
|
|
<span x-show="!savingTargets">Save</span>
|
|
<span x-show="savingTargets">Saving...</span>
|
|
</button>
|
|
<button class="btn btn-ghost" @click="cancelEditTargets()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- ── TAB: Event Triggers ── -->
|
|
<div x-show="tab === 'triggers'">
|
|
<div x-show="trigLoading" class="loading-state"><div class="spinner"></div><span>Loading triggers...</span></div>
|
|
<div x-show="!trigLoading && trigLoadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="trigLoadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadTriggers()">Retry</button>
|
|
</div>
|
|
<div x-show="!trigLoading && !trigLoadError">
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:4px">Event Triggers</div>
|
|
<div class="text-sm text-dim" style="line-height:1.6">
|
|
Event triggers fire agents in response to system events (agent lifecycle, memory updates, custom events).
|
|
Create and manage triggers on the <a href="#workflows" style="color:var(--accent)">Workflows</a> page.
|
|
This view shows all active triggers for monitoring.
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap" x-show="triggers.length">
|
|
<table>
|
|
<thead><tr><th>Agent</th><th>Pattern</th><th>Prompt</th><th>Fires</th><th>Enabled</th><th>Created</th><th>Actions</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="t in triggers" :key="t.id">
|
|
<tr>
|
|
<td class="font-bold truncate" style="max-width:120px" x-text="agentName(t.agent_id)" :title="t.agent_id"></td>
|
|
<td><span class="badge badge-created trigger-type" x-text="triggerType(t.pattern)"></span></td>
|
|
<td class="truncate text-xs text-dim" style="max-width:180px" x-text="t.prompt_template" :title="t.prompt_template"></td>
|
|
<td x-text="t.fire_count + (t.max_fires > 0 ? '/' + t.max_fires : '')"></td>
|
|
<td>
|
|
<div class="toggle" :class="{ active: t.enabled }" @click="toggleTrigger(t)"></div>
|
|
</td>
|
|
<td class="text-xs" x-text="new Date(t.created_at).toLocaleDateString()"></td>
|
|
<td>
|
|
<button class="btn btn-danger btn-sm" @click="deleteTrigger(t)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!triggers.length">
|
|
<h4>No event triggers</h4>
|
|
<p class="hint">Create event triggers on the <a href="#workflows" style="color:var(--accent)">Workflows page</a> to fire agents in response to system events.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── TAB: Run History ── -->
|
|
<div x-show="tab === 'history'">
|
|
<div x-show="historyLoading" class="loading-state"><div class="spinner"></div><span>Loading run history...</span></div>
|
|
<div x-show="!historyLoading">
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:4px">Run History</div>
|
|
<div class="text-sm text-dim" style="line-height:1.6">
|
|
Recent executions of scheduled jobs and event trigger fires. History is aggregated from schedule run counts and trigger fire counts.
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap" x-show="history.length">
|
|
<table>
|
|
<thead><tr><th>Time</th><th>Name</th><th>Type</th><th>Status</th><th>Total Runs</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="(h, idx) in history" :key="idx">
|
|
<tr>
|
|
<td class="text-xs" style="white-space:nowrap" x-text="formatTime(h.timestamp)"></td>
|
|
<td class="font-bold" x-text="h.name"></td>
|
|
<td><span class="badge" :class="h.type === 'schedule' ? 'badge-created' : 'badge-dim'" x-text="h.type === 'schedule' ? 'Cron Job' : 'Trigger'"></span></td>
|
|
<td><span class="badge badge-success" x-text="h.status"></span></td>
|
|
<td x-text="h.run_count"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!history.length">
|
|
<h4>No run history yet</h4>
|
|
<p class="hint">Run history will appear here after scheduled jobs or triggers execute.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Channels -->
|
|
<template x-if="page === 'channels'">
|
|
<div x-data="channelsPage">
|
|
<div class="page-header">
|
|
<h2>Channels <span class="badge badge-muted" x-text="configuredCount + '/' + allChannels.length + ' configured'"></span></h2>
|
|
</div>
|
|
<div class="page-body" x-init="loadChannels()">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading channels...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
|
|
<!-- Category tabs -->
|
|
<div class="flex gap-2 mb-4" style="flex-wrap:wrap">
|
|
<template x-for="cat in categories" :key="cat.key">
|
|
<button class="btn btn-sm"
|
|
:class="categoryFilter === cat.key ? 'btn-primary' : 'btn-ghost'"
|
|
@click="categoryFilter = cat.key"
|
|
x-text="cat.label + ' (' + categoryCount(cat.key) + ')'">
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Search bar -->
|
|
<div class="mb-4">
|
|
<input class="form-input" type="text" placeholder="Search channels..."
|
|
x-model="searchQuery" style="max-width:400px">
|
|
</div>
|
|
|
|
<!-- Channel card grid -->
|
|
<div class="card-grid">
|
|
<template x-for="ch in filteredChannels" :key="ch.name">
|
|
<div class="card" :class="{ 'card-unconfigured': !ch.configured }" style="cursor:pointer" @click="openSetup(ch)">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="channel-icon" x-text="ch.icon"></span>
|
|
<div class="card-header" style="margin:0" x-text="ch.display_name"></div>
|
|
</div>
|
|
<span class="badge" :class="statusBadge(ch).cls" x-text="statusBadge(ch).text"></span>
|
|
</div>
|
|
<div class="card-meta" x-text="ch.description"></div>
|
|
<div class="flex justify-between items-center mt-2">
|
|
<span class="difficulty-badge" :class="difficultyClass(ch.difficulty)" x-text="ch.difficulty + ' · ' + ch.setup_time"></span>
|
|
<button class="btn btn-ghost btn-sm" @click.stop="openSetup(ch)" x-text="ch.configured ? 'Edit' : 'Set up'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div x-show="filteredChannels.length === 0" class="text-dim mt-4" style="text-align:center">
|
|
<p>No channels match your search.</p>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Setup modal (OpenClaw-style with QR support + 3-step flow) -->
|
|
<template x-if="setupModal">
|
|
<div class="modal-overlay" @click.self="setupModal = null" @keydown.escape.window="setupModal = null">
|
|
<div class="modal" style="max-width:480px">
|
|
<div class="modal-header">
|
|
<div>
|
|
<h3 style="display:flex;align-items:center;gap:0.5rem">
|
|
<span class="channel-icon" x-text="setupModal.icon" style="font-size:1rem"></span>
|
|
<span x-text="setupModal.display_name"></span>
|
|
</h3>
|
|
<div class="text-xs text-dim mt-1" x-text="setupModal.quick_setup || setupModal.description"></div>
|
|
</div>
|
|
<button class="modal-close" @click="setupModal = null"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
|
|
</div>
|
|
|
|
<!-- 3-step progress indicator (non-QR channels only) -->
|
|
<div class="channel-steps" x-show="!isQrChannel()">
|
|
<div class="channel-step-item">
|
|
<div class="channel-step-num" :class="{ active: setupStep === 1, done: setupStep > 1 }">
|
|
<span x-show="setupStep <= 1">1</span><span x-show="setupStep > 1"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
</div>
|
|
<span class="channel-step-label" :class="{ active: setupStep === 1, done: setupStep > 1 }">Configure</span>
|
|
</div>
|
|
<div class="channel-step-line" :class="{ done: setupStep > 1 }"></div>
|
|
<div class="channel-step-item">
|
|
<div class="channel-step-num" :class="{ active: setupStep === 2, done: setupStep > 2 }">
|
|
<span x-show="setupStep <= 2">2</span><span x-show="setupStep > 2"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
</div>
|
|
<span class="channel-step-label" :class="{ active: setupStep === 2, done: setupStep > 2 }">Verify</span>
|
|
</div>
|
|
<div class="channel-step-line" :class="{ done: setupStep > 2 }"></div>
|
|
<div class="channel-step-item">
|
|
<div class="channel-step-num" :class="{ active: setupStep === 3, done: setupStep >= 3 }">
|
|
<span x-show="setupStep < 3">3</span><span x-show="setupStep >= 3"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
</div>
|
|
<span class="channel-step-label" :class="{ active: setupStep === 3, done: setupStep >= 3 }">Ready</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ready panel (step 3) for non-QR channels -->
|
|
<div x-show="!isQrChannel() && setupStep === 3 && testPassed" class="ready-panel">
|
|
<div class="ready-panel-icon">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</div>
|
|
<div class="ready-panel-title" x-text="setupModal.display_name + ' is ready!'"></div>
|
|
<div class="ready-panel-desc">
|
|
Your channel is configured and verified. It will activate automatically.
|
|
</div>
|
|
<div class="flex gap-2 mt-4" style="justify-content:center">
|
|
<button class="btn btn-ghost btn-sm" @click="setupStep = 1">Edit Config</button>
|
|
<button class="btn btn-primary btn-sm" @click="setupModal = null">Done</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══ QR CODE FLOW (WhatsApp Web style) ═══ -->
|
|
<template x-if="isQrChannel() && !showBusinessApi">
|
|
<div>
|
|
<!-- QR: Loading -->
|
|
<div x-show="qr.loading" style="text-align:center;padding:2rem 0">
|
|
<div class="spinner"></div>
|
|
<p class="text-sm text-dim mt-2">Connecting to WhatsApp Web gateway...</p>
|
|
</div>
|
|
|
|
<!-- QR: Gateway available — show QR code -->
|
|
<div x-show="!qr.loading && qr.available && qr.dataUrl && !qr.connected" style="text-align:center">
|
|
<div style="background:#fff;display:inline-block;padding:1rem;border-radius:12px;margin:0.5rem 0">
|
|
<img :src="qr.dataUrl" alt="WhatsApp QR Code" style="width:256px;height:256px;image-rendering:pixelated">
|
|
</div>
|
|
<ol style="text-align:left;font-size:0.85rem;margin:1rem 0;padding-left:1.5rem;opacity:0.8">
|
|
<template x-for="(step, i) in setupModal.setup_steps || []" :key="i">
|
|
<li x-text="step" style="margin-bottom:0.25rem"></li>
|
|
</template>
|
|
</ol>
|
|
<p class="text-xs text-dim" x-text="qr.message"></p>
|
|
<button class="btn btn-ghost btn-sm mt-2" x-show="qr.expired" @click="startQR()">Generate New QR</button>
|
|
</div>
|
|
|
|
<!-- QR: Connected! -->
|
|
<div x-show="!qr.loading && qr.connected" style="text-align:center;padding:2rem 0">
|
|
<div style="margin-bottom:0.5rem;display:flex;justify-content:center">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</div>
|
|
<p class="text-sm" style="font-weight:600" x-text="qr.message || 'WhatsApp linked successfully!'"></p>
|
|
<p class="text-xs text-dim mt-1">Channel will activate automatically.</p>
|
|
</div>
|
|
|
|
<!-- QR: Gateway not available — show setup hint -->
|
|
<div x-show="!qr.loading && !qr.available" style="padding:1rem 0">
|
|
<div style="background:var(--bg-secondary,#1a1a2e);border-radius:8px;padding:1.25rem;text-align:center">
|
|
<div style="font-size:2rem;margin-bottom:0.5rem;opacity:0.5">📱</div>
|
|
<p class="text-sm" x-text="qr.message || 'WhatsApp Web gateway not available'"></p>
|
|
<p class="text-xs text-dim mt-2" x-show="qr.help" x-text="qr.help"></p>
|
|
<p class="text-xs text-dim mt-1" x-show="qr.error" style="color:var(--red,#ef4444)" x-text="qr.error"></p>
|
|
</div>
|
|
<p class="text-xs text-dim mt-3" style="text-align:center">
|
|
Or use the <button class="btn-link" @click="showBusinessApi = true" style="font-size:inherit;text-decoration:underline;cursor:pointer;background:none;border:none;color:var(--accent,#818cf8);padding:0">Business API</button> with a Meta developer account.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- QR: Switch to Business API link (always visible when gateway is available) -->
|
|
<div x-show="!qr.loading && qr.available" class="text-xs text-dim mt-2" style="text-align:center">
|
|
Have a Meta Business account? <button class="btn-link" @click="showBusinessApi = true" style="font-size:inherit;text-decoration:underline;cursor:pointer;background:none;border:none;color:var(--accent,#818cf8);padding:0">Use Business API instead</button>
|
|
</div>
|
|
|
|
<!-- Action buttons for QR mode -->
|
|
<div class="flex gap-2 mt-4" style="flex-wrap:wrap;justify-content:center" x-show="!qr.loading">
|
|
<button class="btn btn-ghost" x-show="qr.available && !qr.connected && !qr.expired" @click="startQR()">Refresh QR</button>
|
|
<button class="btn btn-ghost" x-show="setupModal.configured" @click="testChannel()"
|
|
:disabled="testing[setupModal.name]"
|
|
x-text="testing[setupModal.name] ? 'Testing...' : 'Test Connection'">
|
|
</button>
|
|
<button class="btn btn-ghost" style="color:var(--red,#ef4444)" x-show="setupModal.configured" @click="removeChannel()">Remove</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ STANDARD FORM FLOW (for non-QR channels, or Business API fallback) ═══ -->
|
|
<template x-if="(!isQrChannel() || showBusinessApi) && !(setupStep === 3 && testPassed && !isQrChannel())">
|
|
<div>
|
|
<!-- Back to QR link (only for QR channels in Business API mode) -->
|
|
<div x-show="isQrChannel() && showBusinessApi" class="mb-3">
|
|
<button class="btn btn-ghost btn-sm" @click="showBusinessApi = false" style="font-size:0.8rem">← Back to QR scan</button>
|
|
<p class="text-xs text-dim mt-1">Configure via WhatsApp Cloud API (requires a Meta Business developer account).</p>
|
|
</div>
|
|
|
|
<!-- Quick setup steps (collapsed, minimal) -->
|
|
<details class="mb-4" style="font-size:0.8rem" x-show="!isQrChannel()">
|
|
<summary class="text-dim" style="cursor:pointer">How to get credentials</summary>
|
|
<ol class="setup-steps" style="margin-top:0.5rem">
|
|
<template x-for="(step, i) in setupModal.setup_steps || []" :key="i">
|
|
<li class="text-sm" x-text="step"></li>
|
|
</template>
|
|
</ol>
|
|
</details>
|
|
|
|
<!-- Basic fields only (the minimum to get started) -->
|
|
<template x-for="f in (isQrChannel() && showBusinessApi) ? advancedFields() : basicFields()" :key="f.key">
|
|
<div style="margin-bottom:0.75rem">
|
|
<label class="text-sm" style="display:block;margin-bottom:0.25rem" x-text="f.label + (f.required ? ' *' : '')"></label>
|
|
<template x-if="f.type === 'secret'">
|
|
<input class="form-input" type="password"
|
|
x-model="formValues[f.key]"
|
|
:placeholder="f.has_value ? '••••••• (set — leave blank to keep)' : f.placeholder"
|
|
:required="f.required">
|
|
</template>
|
|
<template x-if="f.type === 'number'">
|
|
<input class="form-input" type="number"
|
|
x-model="formValues[f.key]"
|
|
:placeholder="f.placeholder">
|
|
</template>
|
|
<template x-if="f.type === 'text' || f.type === 'list'">
|
|
<input class="form-input" type="text"
|
|
x-model="formValues[f.key]"
|
|
:placeholder="f.type === 'list' ? f.placeholder + ' (comma-separated)' : f.placeholder">
|
|
</template>
|
|
<div class="text-xs text-dim" style="margin-top:2px"
|
|
x-show="f.env_var && f.has_value"
|
|
x-text="f.env_var + ' is set'"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Advanced toggle (only for non-QR channels) -->
|
|
<template x-if="!isQrChannel() && hasAdvanced()">
|
|
<div>
|
|
<button class="btn btn-ghost btn-sm mb-2" @click="showAdvanced = !showAdvanced"
|
|
x-text="showAdvanced ? 'Hide advanced' : 'Show advanced (' + advancedFields().length + ')'"></button>
|
|
<template x-if="showAdvanced">
|
|
<div>
|
|
<template x-for="f in advancedFields()" :key="f.key">
|
|
<div style="margin-bottom:0.75rem">
|
|
<label class="text-sm text-dim" style="display:block;margin-bottom:0.25rem" x-text="f.label"></label>
|
|
<template x-if="f.type === 'secret'">
|
|
<input class="form-input" type="password"
|
|
x-model="formValues[f.key]"
|
|
:placeholder="f.has_value ? '••••••• (set)' : f.placeholder">
|
|
</template>
|
|
<template x-if="f.type === 'number'">
|
|
<input class="form-input" type="number"
|
|
x-model="formValues[f.key]"
|
|
:placeholder="f.placeholder">
|
|
</template>
|
|
<template x-if="f.type === 'text' || f.type === 'list'">
|
|
<input class="form-input" type="text"
|
|
x-model="formValues[f.key]"
|
|
:placeholder="f.type === 'list' ? f.placeholder + ' (comma-separated)' : f.placeholder">
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Action buttons -->
|
|
<div class="flex gap-2 mt-4" style="flex-wrap:wrap">
|
|
<button class="btn btn-primary" @click="saveChannel()" :disabled="configuring"
|
|
x-text="configuring ? 'Saving...' : (setupModal.configured ? 'Update' : 'Save & Test')">
|
|
</button>
|
|
<button class="btn btn-ghost" x-show="setupModal.configured" @click="testChannel()"
|
|
:disabled="testing[setupModal.name]"
|
|
x-text="testing[setupModal.name] ? 'Testing...' : 'Test'">
|
|
</button>
|
|
<button class="btn btn-ghost" style="color:var(--red,#ef4444)" x-show="setupModal.configured" @click="removeChannel()">Remove</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Skills -->
|
|
<template x-if="page === 'skills'">
|
|
<div x-data="skillsPage">
|
|
<div class="page-header"><h2>Skills</h2></div>
|
|
<div class="page-body" x-init="loadSkills()">
|
|
<div class="info-card">
|
|
<h4>Skills & Ecosystem</h4>
|
|
<p>Skills extend your agents with new capabilities. OpenFang supports the <strong>OpenClaw/ClawHub</strong> ecosystem (3,000+ community skills) plus local skills.</p>
|
|
<ul>
|
|
<li><strong>Prompt-only</strong> — inject context and instructions into the agent's system prompt (most ClawHub skills)</li>
|
|
<li><strong>Python / Node.js</strong> — executable tools that agents can call during conversations</li>
|
|
<li><strong>MCP Servers</strong> — external tools via Model Context Protocol (GitHub, filesystem, databases, etc.)</li>
|
|
</ul>
|
|
</div>
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading skills...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
|
|
<!-- Main tabs -->
|
|
<div class="tabs" role="tablist">
|
|
<div class="tab" role="tab" :class="{ active: tab === 'installed' }" @click="tab = 'installed'">
|
|
Installed <span class="badge badge-dim" x-show="skills.length" x-text="skills.length" style="margin-left:4px"></span>
|
|
</div>
|
|
<div class="tab" :class="{ active: tab === 'clawhub' }" @click="tab = 'clawhub'; if (!clawhubBrowseResults.length && !clawhubSearch) browseClawHub('trending')">ClawHub</div>
|
|
<div class="tab" :class="{ active: tab === 'mcp' }" @click="tab = 'mcp'; loadMcpServers()">MCP Servers</div>
|
|
<div class="tab" :class="{ active: tab === 'create' }" @click="tab = 'create'">Quick Start</div>
|
|
</div>
|
|
|
|
<!-- TAB: Installed Skills -->
|
|
<div x-show="tab === 'installed'">
|
|
<div class="card-grid" x-show="skills.length">
|
|
<template x-for="skill in skills" :key="skill.name">
|
|
<div class="card">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<div class="card-header" style="margin:0" x-text="skill.name"></div>
|
|
<span class="runtime-badge" :class="runtimeBadge(skill.runtime).cls" x-text="runtimeBadge(skill.runtime).text"></span>
|
|
<span class="badge" :class="sourceBadge(skill.source).cls" x-text="sourceBadge(skill.source).text" style="font-size:0.65rem"></span>
|
|
</div>
|
|
<div class="toggle" :class="{ active: skill.enabled }" @click="skill.enabled = !skill.enabled"></div>
|
|
</div>
|
|
<div class="card-meta" x-text="skill.description"></div>
|
|
<div class="flex justify-between items-center mt-2">
|
|
<div class="flex gap-2 items-center">
|
|
<span class="text-xs text-dim" x-text="skill.tools_count + ' tool(s)'"></span>
|
|
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
|
<span class="text-xs text-dim" x-show="skill.has_prompt_context">(prompt context)</span>
|
|
<span class="text-xs text-dim" x-show="skill.config_declared_count > 0" x-text="skill.config_declared_count + ' config var(s)'"></span>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" x-show="skill.config_declared_count > 0" @click="openSkillConfig(skill)" title="Configure skill variables">⚙ Configure</button>
|
|
<button class="btn btn-danger btn-sm" @click="uninstallSkill(skill.name)">Uninstall</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty-state" x-show="!skills.length">
|
|
<div class="empty-state-icon">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg>
|
|
</div>
|
|
<h3>No skills installed</h3>
|
|
<p>Skills add new capabilities to your agents. Browse ClawHub for 3,000+ community skills or create your own.</p>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary" @click="tab = 'clawhub'; if (!clawhubBrowseResults.length) browseClawHub('trending')">Browse ClawHub</button>
|
|
<button class="btn btn-ghost" @click="tab = 'create'">Quick Start</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB: ClawHub (OpenClaw Ecosystem) -->
|
|
<div x-show="tab === 'clawhub'">
|
|
<!-- Search bar with live search and clear button -->
|
|
<div class="search-input mb-4" style="position:relative">
|
|
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
|
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
|
|
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Sort pills (always visible when not searching) -->
|
|
<div class="filter-pills mb-4" x-show="!clawhubSearch">
|
|
<span class="filter-pill" :class="{ active: clawhubSort === 'trending' }" @click="browseClawHub('trending')">Trending</span>
|
|
<span class="filter-pill" :class="{ active: clawhubSort === 'downloads' }" @click="browseClawHub('downloads')">Most Downloaded</span>
|
|
<span class="filter-pill" :class="{ active: clawhubSort === 'stars' }" @click="browseClawHub('stars')">Most Starred</span>
|
|
<span class="filter-pill" :class="{ active: clawhubSort === 'updated' }" @click="browseClawHub('updated')">Recently Updated</span>
|
|
</div>
|
|
|
|
<!-- Category quick-search chips (always visible when not searching) -->
|
|
<div class="mb-4" x-show="!clawhubSearch">
|
|
<div class="text-xs text-dim mb-2" style="letter-spacing:0.5px">CATEGORIES</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<template x-for="cat in categories" :key="cat.id">
|
|
<span class="filter-pill" style="font-size:0.7rem;padding:2px 8px" @click="searchCategory(cat)" x-text="cat.name"></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading spinner -->
|
|
<div x-show="clawhubLoading" class="loading-state"><div class="spinner"></div><span x-text="clawhubSearch ? 'Searching ClawHub...' : 'Loading skills...'"></span></div>
|
|
|
|
<!-- Error -->
|
|
<div x-show="clawhubError && !clawhubLoading" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="clawhubError"></p>
|
|
<p class="hint mt-2">ClawHub may be temporarily unavailable. The OpenClaw ecosystem is hosted at clawhub.ai.</p>
|
|
<button class="btn btn-ghost btn-sm mt-2" @click="clawhubSearch ? searchClawHub() : browseClawHub(clawhubSort)">Retry</button>
|
|
</div>
|
|
|
|
<!-- Search results -->
|
|
<div x-show="clawhubSearch && clawhubResults.length && !clawhubLoading">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<div class="text-sm text-dim" x-text="clawhubResults.length + ' result(s) for "' + clawhubSearch + '"'"></div>
|
|
<button class="btn btn-ghost btn-sm" @click="clearSearch()">Clear search</button>
|
|
</div>
|
|
<div class="card-grid">
|
|
<template x-for="skill in clawhubResults" :key="skill.slug">
|
|
<div class="card card-glow" @click="showSkillDetail(skill.slug)" style="cursor:pointer">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0" x-text="skill.name || skill.slug"></div>
|
|
<span class="badge badge-info" style="font-size:0.6rem">ClawHub</span>
|
|
</div>
|
|
<div class="card-meta" x-text="skill.description" style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden"></div>
|
|
<div class="flex justify-between items-center mt-3">
|
|
<div class="flex gap-3 items-center">
|
|
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Browse results (when no search query) -->
|
|
<div x-show="!clawhubSearch && clawhubBrowseResults.length && !clawhubLoading">
|
|
<div class="card-grid">
|
|
<template x-for="skill in clawhubBrowseResults" :key="skill.slug">
|
|
<div class="card card-glow" @click="showSkillDetail(skill.slug)" style="cursor:pointer">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0" x-text="skill.name || skill.slug"></div>
|
|
<span class="badge badge-info" style="font-size:0.6rem">ClawHub</span>
|
|
</div>
|
|
<div class="card-meta" x-text="skill.description" style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden"></div>
|
|
<div class="flex justify-between items-center mt-3">
|
|
<div class="flex gap-3 items-center">
|
|
<span class="text-xs text-dim" x-show="skill.downloads" x-text="formatDownloads(skill.downloads) + ' downloads'"></span>
|
|
<span class="text-xs text-dim" x-show="skill.stars" x-text="skill.stars + ' stars'"></span>
|
|
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<!-- Load more button -->
|
|
<div class="text-center mt-4" x-show="clawhubNextCursor">
|
|
<button class="btn btn-ghost" @click="loadMoreClawHub()" :disabled="clawhubLoading">Load More</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty search results -->
|
|
<div class="empty-state" x-show="clawhubSearch && !clawhubResults.length && !clawhubLoading && !clawhubError">
|
|
<p>No skills found for "<span x-text="clawhubSearch"></span>"</p>
|
|
<p class="hint mt-1">Try a different search term or browse by category.</p>
|
|
<button class="btn btn-ghost btn-sm mt-2" @click="clearSearch()">Back to browse</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB: MCP Servers -->
|
|
<div x-show="tab === 'mcp'">
|
|
<div class="info-card">
|
|
<h4>MCP Servers (Model Context Protocol)</h4>
|
|
<p>MCP servers provide external tools to your agents — GitHub, filesystem, databases, APIs, and more. OpenFang is compatible with all OpenClaw MCP servers.</p>
|
|
<p class="mt-2" style="font-size:0.8rem;color:var(--text-dim)">Configure MCP servers in your <code>config.toml</code> under <code>[mcp_servers]</code>.</p>
|
|
</div>
|
|
<div x-show="mcpLoading" class="loading-state"><div class="spinner"></div><span>Loading MCP servers...</span></div>
|
|
<div x-show="!mcpLoading">
|
|
<!-- Connected servers -->
|
|
<div x-show="mcpServers.total_connected > 0" class="mb-4">
|
|
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px">CONNECTED SERVERS</div>
|
|
<div class="card-grid">
|
|
<template x-for="srv in mcpServers.connected" :key="srv.name">
|
|
<div class="card">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0" x-text="srv.name"></div>
|
|
<span class="badge badge-success">Connected</span>
|
|
</div>
|
|
<div class="card-meta" x-text="srv.tools_count + ' tool(s) available'"></div>
|
|
<div class="mt-2" x-show="srv.tools && srv.tools.length">
|
|
<div class="text-xs text-dim mb-1">Tools:</div>
|
|
<template x-for="tool in srv.tools.slice(0, 10)" :key="tool.name">
|
|
<div class="text-xs" style="padding:2px 0">
|
|
<code x-text="tool.name" style="font-size:0.7rem"></code>
|
|
<span class="text-dim" x-show="tool.description" x-text="' — ' + tool.description" style="font-size:0.65rem"></span>
|
|
</div>
|
|
</template>
|
|
<div class="text-xs text-dim" x-show="srv.tools.length > 10" x-text="'... and ' + (srv.tools.length - 10) + ' more'"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<!-- Configured but not connected -->
|
|
<div x-show="mcpServers.total_configured > 0" class="mb-4">
|
|
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px">CONFIGURED SERVERS</div>
|
|
<div class="card-grid">
|
|
<template x-for="srv in mcpServers.configured" :key="srv.name">
|
|
<div class="card card-unconfigured">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0" x-text="srv.name"></div>
|
|
<span class="badge badge-dim" x-text="srv.transport.type"></span>
|
|
</div>
|
|
<div class="text-xs" x-show="srv.transport.type === 'stdio'">
|
|
<code x-text="srv.transport.command + ' ' + (srv.transport.args || []).join(' ')" style="font-size:0.7rem"></code>
|
|
</div>
|
|
<div class="text-xs" x-show="srv.transport.type === 'sse'">
|
|
<code x-text="srv.transport.url" style="font-size:0.7rem"></code>
|
|
</div>
|
|
<div class="text-xs text-dim mt-1" x-show="srv.env && srv.env.length" x-text="'Env: ' + srv.env.join(', ')"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<!-- Empty state -->
|
|
<div class="empty-state" x-show="mcpServers.total_configured === 0 && mcpServers.total_connected === 0">
|
|
<h4>No MCP servers configured</h4>
|
|
<p class="hint">MCP servers extend your agents with external tools. Add servers to your config.toml:</p>
|
|
<pre style="text-align:left;font-size:0.75rem;margin-top:8px;background:var(--bg-secondary);padding:12px;border-radius:6px;max-width:400px;margin-left:auto;margin-right:auto">[[mcp_servers]]
|
|
name = "filesystem"
|
|
timeout_secs = 30
|
|
|
|
[mcp_servers.transport]
|
|
type = "stdio"
|
|
command = "npx"
|
|
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
|
<p class="hint mt-2" style="font-size:0.75rem">OpenFang supports all OpenClaw-compatible MCP servers.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB: Quick Start (Create prompt-only skills) -->
|
|
<div x-show="tab === 'create'">
|
|
<div class="info-card">
|
|
<h4>Quick Start Skills</h4>
|
|
<p>Create prompt-only skills with one click. These inject context into your agent's system prompt — no code required. Perfect for adding domain expertise or workflow guidelines.</p>
|
|
</div>
|
|
<div class="card-grid">
|
|
<template x-for="qs in quickStartSkills" :key="qs.name">
|
|
<div class="card card-glow">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0" x-text="qs.name"></div>
|
|
<span class="runtime-badge runtime-badge-prompt">PROMPT</span>
|
|
</div>
|
|
<div class="card-meta" x-text="qs.description"></div>
|
|
<div class="flex justify-end mt-2">
|
|
<button class="btn btn-ghost btn-sm" @click="createDemoSkill(qs)" :disabled="isSkillInstalledByName(qs.name)" x-text="isSkillInstalledByName(qs.name) ? 'Created' : 'Create Skill'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skill Detail Modal -->
|
|
<template x-if="skillDetail || detailLoading">
|
|
<div class="modal-overlay" @click.self="closeDetail()" @keydown.escape.window="closeDetail()">
|
|
<div class="modal" style="max-width:600px">
|
|
<!-- Loading state -->
|
|
<div x-show="detailLoading" class="loading-state" style="padding:40px 0"><div class="spinner"></div><span>Loading skill details...</span></div>
|
|
<!-- Loaded content -->
|
|
<template x-if="skillDetail">
|
|
<div>
|
|
<div class="modal-header">
|
|
<h3 x-text="skillDetail.name || skillDetail.slug"></h3>
|
|
<button class="modal-close" @click="closeDetail()">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="flex gap-2 items-center flex-wrap">
|
|
<span class="badge badge-info">ClawHub</span>
|
|
<span class="text-xs text-dim" x-show="skillDetail.version" x-text="'v' + skillDetail.version"></span>
|
|
<span class="text-xs" x-show="skillDetail.author_name || skillDetail.author" style="color:var(--text-dim)">
|
|
<span x-show="skillDetail.author_image" style="display:inline-block;vertical-align:middle;margin-right:4px"><img :src="skillDetail.author_image" style="width:16px;height:16px;border-radius:50%"></span>
|
|
<span x-text="'by ' + (skillDetail.author_name || skillDetail.author)"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-4 items-center mb-3" x-show="skillDetail.downloads || skillDetail.stars">
|
|
<span class="text-sm" x-show="skillDetail.downloads" x-text="formatDownloads(skillDetail.downloads) + ' downloads'" style="color:var(--text-dim)"></span>
|
|
<span class="text-sm" x-show="skillDetail.stars" x-text="skillDetail.stars + ' stars'" style="color:var(--text-dim)"></span>
|
|
</div>
|
|
<div class="mb-4" x-show="skillDetail.description">
|
|
<p x-text="skillDetail.description"></p>
|
|
</div>
|
|
<div class="mb-4" x-show="skillDetail.tags && typeof skillDetail.tags === 'object'">
|
|
<div class="flex flex-wrap gap-1">
|
|
<template x-for="key in Object.keys(skillDetail.tags || {})" :key="key">
|
|
<span class="category-badge" x-text="key + ': ' + skillDetail.tags[key]"></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="mb-4" x-show="installResult && installResult.warnings && installResult.warnings.length">
|
|
<div class="form-group"><label>Security Warnings</label></div>
|
|
<template x-for="w in (installResult ? installResult.warnings : [])" :key="w.message">
|
|
<div class="text-xs" :class="w.severity === 'Critical' ? 'text-danger' : 'text-dim'" x-text="'[' + w.severity + '] ' + w.message" style="padding:2px 0"></div>
|
|
</template>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary" style="flex:1" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
|
<button class="btn btn-ghost" @click="viewSkillCode(skillDetail.slug)" :disabled="skillCodeLoading" x-text="skillCodeLoading ? 'Loading...' : showSkillCode ? 'Hide Code' : 'View Code'"></button>
|
|
</div>
|
|
<div x-show="showSkillCode && skillCode" x-transition class="mt-3" style="max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset)">
|
|
<div class="flex justify-between items-center" style="padding:6px 12px;border-bottom:1px solid var(--border)">
|
|
<span class="text-xs text-dim" x-text="skillCodeFilename"></span>
|
|
</div>
|
|
<pre style="margin:0;padding:12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all" x-text="skillCode"></pre>
|
|
</div>
|
|
<div class="text-xs text-dim mt-2 text-center">Skills are security-scanned before installation. Prompt injection and malware patterns are blocked.</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Skill Config Modal (SKILL.md frontmatter `config:` variables) -->
|
|
<template x-if="configSkill">
|
|
<div class="modal-overlay" @click.self="closeSkillConfig()" @keydown.escape.window="closeSkillConfig()">
|
|
<div class="modal" style="max-width:640px">
|
|
<div class="modal-header">
|
|
<div>
|
|
<h3>Configure: <span x-text="configSkill.name"></span></h3>
|
|
<div class="text-xs text-dim mt-1" x-text="configSkill.description"></div>
|
|
</div>
|
|
<button class="modal-close" @click="closeSkillConfig()">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="configLoading" class="loading-state" style="padding:20px 0">
|
|
<div class="spinner"></div><span>Loading configuration…</span>
|
|
</div>
|
|
|
|
<div x-show="!configLoading && configError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="configError"></p>
|
|
</div>
|
|
|
|
<div x-show="!configLoading && !configError">
|
|
<div x-show="configDeclaredNames.length === 0" class="empty-state" style="padding:16px 0">
|
|
<p class="text-dim">This skill does not declare any runtime config variables.</p>
|
|
</div>
|
|
|
|
<template x-for="name in configDeclaredNames" :key="name">
|
|
<div class="form-group" style="margin-bottom:16px">
|
|
<label style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
|
<code x-text="name" style="font-size:0.8rem"></code>
|
|
<span x-show="configDeclared[name] && configDeclared[name].required" class="badge badge-danger" style="font-size:0.6rem">required</span>
|
|
<span class="badge" :class="sourceBadgeClass(configResolved[name] && configResolved[name].source)" style="font-size:0.6rem">
|
|
<template x-if="configResolved[name] && configResolved[name].source === 'user'"><span>user override</span></template>
|
|
<template x-if="configResolved[name] && configResolved[name].source === 'env'">
|
|
<span>env<template x-if="configDeclared[name] && configDeclared[name].env"><span>:<span x-text="configDeclared[name].env"></span></span></template></span>
|
|
</template>
|
|
<template x-if="configResolved[name] && configResolved[name].source === 'default'"><span>default</span></template>
|
|
<template x-if="!configResolved[name] || configResolved[name].source === 'unresolved'"><span>unresolved ⚠</span></template>
|
|
</span>
|
|
</label>
|
|
<div class="text-xs text-dim" x-show="configDeclared[name] && configDeclared[name].description" x-text="configDeclared[name] ? configDeclared[name].description : ''" style="margin:2px 0 6px 0"></div>
|
|
<div class="flex gap-2 items-center" style="position:relative">
|
|
<input
|
|
style="flex:1"
|
|
:type="(configResolved[name] && configResolved[name].is_secret && !configRevealed[name]) ? 'password' : 'text'"
|
|
:placeholder="(configResolved[name] && configResolved[name].source !== 'user' && configResolved[name].value != null) ? ('Currently: ' + configResolved[name].value) : ((configDeclared[name] && configDeclared[name].default) ? ('default: ' + configDeclared[name].default) : 'Enter value')"
|
|
x-model="configDraft[name]"
|
|
:class="{ 'input-error': configRowInvalid(name) }">
|
|
<button type="button" class="btn btn-ghost btn-sm" x-show="configResolved[name] && configResolved[name].is_secret" @click="toggleReveal(name)" :title="configRevealed[name] ? 'Hide' : 'Reveal'">
|
|
<svg x-show="!configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
<svg x-show="configRevealed[name]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-10-8-10-8a19.77 19.77 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 10 8 10 8a19.77 19.77 0 0 1-2.16 3.19M1 1l22 22"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="text-xs" style="margin-top:4px;display:flex;justify-content:space-between;align-items:center">
|
|
<div class="text-dim" style="font-style:italic" x-show="configResolved[name] && configResolved[name].source === 'user'">Leaving blank and saving keeps existing override. Use Reset to remove it.</div>
|
|
<a href="#" class="text-dim" style="font-size:0.7rem" x-show="configResolved[name] && configResolved[name].source === 'user'" @click.prevent="resetSkillConfigVar(name)">Reset to env/default</a>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="text-xs text-dim" style="margin-top:12px">
|
|
Values are stored in <code>~/.openfang/config.toml</code> under <code>[skills.<span x-text="configSkill.name"></span>]</code>. Secrets are redacted in this view.
|
|
</div>
|
|
<div class="flex justify-end gap-2" style="margin-top:16px">
|
|
<button class="btn btn-ghost" @click="closeSkillConfig()" :disabled="configSaving">Cancel</button>
|
|
<button class="btn btn-primary" @click="saveSkillConfig()" :disabled="configSaving || hasInvalidConfig()" x-text="configSaving ? 'Saving...' : 'Save'"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Hands -->
|
|
<template x-if="page === 'hands'">
|
|
<div x-data="handsPage">
|
|
<div class="page-header"><h2>Hands</h2></div>
|
|
<div class="page-body" x-init="loadData()">
|
|
<div class="info-card">
|
|
<h4>Hands — Curated Autonomous Capability Packages</h4>
|
|
<p>Hands are pre-configured AI agents that autonomously handle specific tasks. Each hand includes a tuned system prompt, required tools, and a dashboard for tracking work.</p>
|
|
</div>
|
|
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading hands...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
|
|
<div x-show="!loading && !loadError">
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<div class="tab" :class="{ active: tab === 'available' }" @click="tab = 'available'">
|
|
Available <span class="badge badge-dim" x-show="hands.length" x-text="hands.length" style="margin-left:4px"></span>
|
|
</div>
|
|
<div class="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'; loadActive()">
|
|
Active <span class="badge badge-success" x-show="instances.length" x-text="instances.length" style="margin-left:4px"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB: Available Hands -->
|
|
<div x-show="tab === 'available'">
|
|
<div class="card-grid" x-show="hands.length">
|
|
<template x-for="hand in hands" :key="hand.id">
|
|
<div class="card" :class="{ 'card-glow': hand.requirements_met }">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<span style="font-size:1.4rem" x-text="hand.icon"></span>
|
|
<div class="card-header" style="margin:0" x-text="hand.name"></div>
|
|
</div>
|
|
<span class="badge" :class="hand.requirements_met ? 'badge-success' : 'badge-dim'" x-text="hand.requirements_met ? 'Ready' : 'Setup needed'"></span>
|
|
</div>
|
|
<div class="card-meta" x-text="hand.description"></div>
|
|
|
|
<!-- Requirements -->
|
|
<div class="mt-3" x-show="hand.requirements && hand.requirements.length">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px">REQUIREMENTS</div>
|
|
<template x-for="req in hand.requirements" :key="req.key">
|
|
<div class="flex items-center gap-2 text-xs" style="padding:2px 0">
|
|
<span :style="req.satisfied ? 'color:var(--success)' : 'color:var(--danger)'" x-text="req.satisfied ? '\u2713' : '\u2717'"></span>
|
|
<span x-text="req.label"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Tools -->
|
|
<div class="mt-2">
|
|
<span class="text-xs text-dim" x-text="hand.tools.length + ' tool(s)'"></span>
|
|
<span class="text-xs text-dim" style="margin-left:8px" x-text="hand.dashboard_metrics + ' metric(s)'"></span>
|
|
<span class="category-badge" style="margin-left:8px;font-size:0.65rem" x-text="hand.category"></span>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex justify-between items-center mt-3">
|
|
<button class="btn btn-ghost btn-sm" @click="showDetail(hand.id)">Details</button>
|
|
<button class="btn btn-primary btn-sm" @click="openSetupWizard(hand.id)" :disabled="setupLoading || activatingId === hand.id" x-text="setupLoading ? 'Loading...' : 'Activate'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty-state" x-show="!hands.length">
|
|
<div class="empty-state-icon">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v6"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.9-5.7-2.4L3.4 16a2 2 0 0 1 3.2-2.4L8 15"/></svg>
|
|
</div>
|
|
<h3>No hands available</h3>
|
|
<p>Hands are curated AI capability packages. They will appear once the kernel loads bundled hands.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB: Active Instances -->
|
|
<div x-show="tab === 'active'">
|
|
<div x-show="activeLoading" class="loading-state"><div class="spinner"></div><span>Loading active hands...</span></div>
|
|
<div class="card-grid" x-show="instances.length && !activeLoading">
|
|
<template x-for="inst in instances" :key="inst.instance_id">
|
|
<div class="card">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<span style="font-size:1.4rem" x-text="getHandIcon(inst.hand_id)"></span>
|
|
<div class="card-header" style="margin:0" x-text="inst.agent_name || inst.hand_id"></div>
|
|
</div>
|
|
<span class="badge" :class="{ 'badge-success': inst.status === 'Active', 'badge-dim': inst.status === 'Paused', 'badge-warn': inst.status && inst.status.startsWith('Error'), 'badge-info': inst.status === 'Inactive' }" x-text="inst.status"></span>
|
|
</div>
|
|
<div class="text-xs text-dim" x-text="'Activated: ' + new Date(inst.activated_at).toLocaleString()"></div>
|
|
<div class="text-xs text-dim" x-show="inst.agent_id" x-text="'Agent: ' + inst.agent_id"></div>
|
|
|
|
<!-- Stats (loaded on demand) -->
|
|
<div class="mt-3" x-show="inst._stats">
|
|
<template x-for="(val, label) in (inst._stats || {})" :key="label">
|
|
<div class="flex justify-between text-xs" style="padding:2px 0">
|
|
<span class="text-dim" x-text="label"></span>
|
|
<span x-text="formatMetric(val)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-2 mt-3">
|
|
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
|
|
<template x-if="isTraderHand(inst)">
|
|
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
|
|
</template>
|
|
<template x-if="isBrowserHand(inst)">
|
|
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
|
|
</template>
|
|
<template x-if="inst.status === 'Active'">
|
|
<button class="btn btn-ghost btn-sm" @click="pauseHand(inst)">Pause</button>
|
|
</template>
|
|
<template x-if="inst.status === 'Paused'">
|
|
<button class="btn btn-ghost btn-sm" @click="resumeHand(inst)">Resume</button>
|
|
</template>
|
|
<template x-if="inst.status && inst.status.startsWith('Error')">
|
|
<span class="text-xs text-dim">Error — deactivate and reactivate</span>
|
|
</template>
|
|
<button class="btn btn-danger btn-sm" @click="deactivate(inst)">Deactivate</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty-state" x-show="!instances.length && !activeLoading">
|
|
<h4>No active hands</h4>
|
|
<p class="hint">Activate a hand from the Available tab to get started. Each hand spawns a dedicated agent.</p>
|
|
<button class="btn btn-primary mt-4" @click="tab = 'available'">Browse Hands</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail Modal -->
|
|
<template x-if="detailHand">
|
|
<div class="modal-overlay" @click.self="detailHand = null" @keydown.escape.window="detailHand = null">
|
|
<div class="modal" style="max-width:600px">
|
|
<div class="modal-header">
|
|
<h3><span x-text="detailHand.icon"></span> <span x-text="detailHand.name"></span></h3>
|
|
<button class="modal-close" @click="detailHand = null">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
<div class="mb-3">
|
|
<p x-text="detailHand.description"></p>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px">AGENT CONFIG</div>
|
|
<div class="text-sm" x-show="detailHand.agent">
|
|
<div class="flex justify-between" style="padding:2px 0"><span class="text-dim">Provider</span><span x-text="detailHand.agent.provider"></span></div>
|
|
<div class="flex justify-between" style="padding:2px 0"><span class="text-dim">Model</span><span x-text="detailHand.agent.model"></span></div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px">REQUIREMENTS</div>
|
|
<template x-for="req in (detailHand.requirements || [])" :key="req.key">
|
|
<div class="flex items-center gap-2 text-sm" style="padding:3px 0">
|
|
<span :style="req.satisfied ? 'color:var(--success)' : 'color:var(--danger)'" x-text="req.satisfied ? '\u2713' : '\u2717'"></span>
|
|
<span x-text="req.label"></span>
|
|
<code class="text-xs text-dim" x-text="req.check_value" style="margin-left:auto"></code>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px">TOOLS</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<template x-for="tool in (detailHand.tools || [])" :key="tool">
|
|
<span class="category-badge" x-text="tool" style="font-size:0.7rem"></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3" x-show="detailHand.dashboard && detailHand.dashboard.length">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px">DASHBOARD METRICS</div>
|
|
<template x-for="m in (detailHand.dashboard || [])" :key="m.memory_key">
|
|
<div class="text-sm" style="padding:2px 0"><span x-text="m.label"></span> <code class="text-xs text-dim" x-text="'(' + m.format + ')'"></code></div>
|
|
</template>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary btn-block" @click="var hid = detailHand.id; detailHand = null; openSetupWizard(hid)" :disabled="setupLoading">Activate</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Setup Wizard (guided multi-step activation) -->
|
|
<template x-if="setupWizard">
|
|
<div class="modal-overlay" @click.self="closeSetupWizard()" @keydown.escape.window="closeSetupWizard()">
|
|
<div class="hand-wizard">
|
|
<!-- Header -->
|
|
<div class="hand-wizard-header">
|
|
<span class="wizard-icon" x-text="setupWizard.icon"></span>
|
|
<div>
|
|
<div class="wizard-title" x-text="'Set up ' + setupWizard.name"></div>
|
|
<div class="wizard-subtitle" x-text="setupWizard.description"></div>
|
|
</div>
|
|
<button class="wizard-close" @click="closeSetupWizard()">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Step Indicators -->
|
|
<div class="hand-steps">
|
|
<template x-if="setupHasReqs">
|
|
<div class="hand-step-item" :class="{ active: setupStep === 1, done: setupStep > 1 }">
|
|
<div class="hand-step-num" x-text="setupStep > 1 ? '\u2713' : '1'"></div>
|
|
<div class="hand-step-label">Dependencies</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="setupHasReqs">
|
|
<div class="hand-step-line" :class="{ done: setupStep > 1 }"></div>
|
|
</template>
|
|
<div class="hand-step-item" :class="{ active: setupStep === 2, done: setupStep > 2 }">
|
|
<div class="hand-step-num" x-text="setupStep > 2 ? '\u2713' : (setupHasReqs ? '2' : '1')"></div>
|
|
<div class="hand-step-label">Configure</div>
|
|
</div>
|
|
<div class="hand-step-line" :class="{ done: setupStep > 2 }"></div>
|
|
<div class="hand-step-item" :class="{ active: setupStep === 3 }">
|
|
<div class="hand-step-num" x-text="setupHasReqs ? '3' : '2'"></div>
|
|
<div class="hand-step-label">Launch</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══ Step 1: Dependencies ═══ -->
|
|
<div class="hand-wizard-body" x-show="setupStep === 1">
|
|
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
|
|
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
|
|
<div class="dep-card-header">
|
|
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
|
|
<span class="dep-card-title" x-text="req.label"></span>
|
|
<template x-if="req.install && req.install.estimated_time">
|
|
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
|
|
</template>
|
|
</div>
|
|
<template x-if="req.description">
|
|
<div class="dep-card-desc" x-text="req.description"></div>
|
|
</template>
|
|
|
|
<!-- Satisfied: green message -->
|
|
<template x-if="req.satisfied">
|
|
<div class="dep-met-msg" style="display:flex;align-items:center;gap:4px">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
<span>Detected on your system</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Not satisfied: show install instructions -->
|
|
<template x-if="!req.satisfied">
|
|
<div>
|
|
<!-- Binary/EnvVar: platform install commands -->
|
|
<template x-if="req.type !== 'ApiKey' && req.install && (req.install.macos || req.install.windows || req.install.linux_apt)">
|
|
<div class="install-block">
|
|
<div class="install-platform-pills">
|
|
<button class="install-platform-pill" :class="{ active: (installPlatforms[req.key] || detectedPlatform) === 'macos' }" @click="installPlatforms[req.key] = 'macos'" x-show="req.install.macos">macOS</button>
|
|
<button class="install-platform-pill" :class="{ active: (installPlatforms[req.key] || detectedPlatform) === 'windows' }" @click="installPlatforms[req.key] = 'windows'" x-show="req.install.windows">Windows</button>
|
|
<button class="install-platform-pill" :class="{ active: (installPlatforms[req.key] || detectedPlatform) === 'linux' }" @click="installPlatforms[req.key] = 'linux'" x-show="req.install.linux_apt || req.install.linux_dnf || req.install.linux_pacman">Linux</button>
|
|
<button class="install-platform-pill" :class="{ active: (installPlatforms[req.key] || detectedPlatform) === 'pip' }" @click="installPlatforms[req.key] = 'pip'" x-show="req.install.pip && !req.install.macos">pip</button>
|
|
</div>
|
|
<div class="install-cmd">
|
|
<code x-text="getInstallCmd(req) || 'No command available'"></code>
|
|
<button class="copy-btn" :class="{ copied: clipboardMsg === getInstallCmd(req) }" @click="copyToClipboard(getInstallCmd(req))" x-text="clipboardMsg === getInstallCmd(req) ? 'Copied!' : 'Copy'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Install steps (e.g. multi-step like playwright) -->
|
|
<template x-if="req.install && req.install.steps && req.install.steps.length && req.type !== 'ApiKey'">
|
|
<ol class="api-key-steps" style="margin-top:8px">
|
|
<template x-for="step in req.install.steps" :key="step">
|
|
<li x-text="step"></li>
|
|
</template>
|
|
</ol>
|
|
</template>
|
|
|
|
<!-- API Key: input field + numbered steps + signup link -->
|
|
<template x-if="req.type === 'ApiKey' && req.install">
|
|
<div>
|
|
<div style="margin-bottom:10px">
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
|
|
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
|
|
<div class="text-xs" style="margin-top:4px;color:var(--green);display:flex;align-items:center;gap:4px" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
<span>Token entered</span>
|
|
</div>
|
|
</div>
|
|
<details style="margin-bottom:8px">
|
|
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
|
|
<template x-if="req.install.steps && req.install.steps.length">
|
|
<ol class="api-key-steps">
|
|
<template x-for="step in req.install.steps" :key="step">
|
|
<li x-text="step"></li>
|
|
</template>
|
|
</ol>
|
|
</template>
|
|
<template x-if="req.install.env_example">
|
|
<div class="install-block" style="margin-top:8px">
|
|
<div class="install-cmd">
|
|
<code x-text="req.install.env_example"></code>
|
|
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</details>
|
|
<div class="flex gap-2 mt-2">
|
|
<template x-if="req.install.signup_url">
|
|
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key →</a>
|
|
</template>
|
|
<template x-if="req.install.docs_url">
|
|
<a :href="req.install.docs_url" target="_blank" rel="noopener" class="btn btn-ghost btn-sm">Docs</a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Manual download link -->
|
|
<template x-if="req.install && req.install.manual_url && req.type !== 'ApiKey'">
|
|
<div style="margin-top:6px">
|
|
<a :href="req.install.manual_url" target="_blank" rel="noopener" class="text-xs" style="color:var(--accent)">Manual download →</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Auto-Install Progress -->
|
|
<template x-if="installProgress">
|
|
<div class="install-progress-panel">
|
|
<div class="install-progress-header">
|
|
<template x-if="installProgress.status === 'installing'">
|
|
<div class="flex items-center gap-2">
|
|
<span class="spinner-sm"></span>
|
|
<span class="text-sm font-bold">Installing dependencies...</span>
|
|
</div>
|
|
</template>
|
|
<template x-if="installProgress.status === 'done' && !installProgress.error">
|
|
<div class="flex items-center gap-2">
|
|
<span style="color:var(--green);display:flex">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</span>
|
|
<span class="text-sm font-bold" style="color:var(--green)">Installation complete!</span>
|
|
</div>
|
|
</template>
|
|
<template x-if="installProgress.error">
|
|
<div class="flex items-center gap-2">
|
|
<span style="color:var(--red);display:flex">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</span>
|
|
<span class="text-sm" style="color:var(--red)" x-text="installProgress.error"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Per-dep results -->
|
|
<template x-if="installProgress.results.length > 0">
|
|
<div class="install-results-list">
|
|
<template x-for="r in installProgress.results" :key="r.key">
|
|
<div class="install-result-row" :class="getInstallResultClass(r.status)">
|
|
<span class="install-result-icon" x-text="getInstallResultIcon(r.status)"></span>
|
|
<span class="text-sm" x-text="r.key"></span>
|
|
<span class="text-xs text-dim" style="margin-left:auto" x-text="r.message"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Installing spinner for current dep -->
|
|
<template x-if="installProgress.status === 'installing' && installProgress.results.length === 0">
|
|
<div class="install-results-list">
|
|
<template x-for="req in (setupWizard.requirements || []).filter(r => !r.satisfied)" :key="req.key">
|
|
<div class="install-result-row">
|
|
<span class="spinner-sm"></span>
|
|
<span class="text-sm" x-text="req.label"></span>
|
|
<span class="text-xs text-dim" style="margin-left:auto">Waiting...</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Progress bar -->
|
|
<div class="dep-progress">
|
|
<div class="dep-progress-label">
|
|
<span x-text="setupReqsMet + ' of ' + setupReqsTotal + ' ready'"></span>
|
|
<span class="text-dim" x-text="setupAllReqsMet ? 'All set!' : 'Install missing dependencies above'"></span>
|
|
</div>
|
|
<div class="dep-progress-bar">
|
|
<div class="dep-progress-fill" :style="'width:' + (setupReqsTotal ? Math.round(setupReqsMet / setupReqsTotal * 100) : 0) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══ Step 2: Configure ═══ -->
|
|
<div class="hand-wizard-body" x-show="setupStep === 2">
|
|
<!-- Optional instance name (for running multiple instances of the same hand) -->
|
|
<div class="mb-4">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px;text-transform:uppercase">
|
|
Instance name <span style="text-transform:none;letter-spacing:0;color:#888">(optional)</span>
|
|
</div>
|
|
<div class="text-xs text-dim mb-2">Leave empty for a single instance of this hand. Set a name to run multiple instances in parallel.</div>
|
|
<input type="text" class="form-input" x-model="setupWizard.instanceName" placeholder="e.g. clip-youtube, lead-q4-outreach" style="width:100%">
|
|
</div>
|
|
<template x-if="!setupHasSettings">
|
|
<div class="text-sm text-dim" style="text-align:center;padding:20px 0">No configuration needed for this hand. Click Next to continue.</div>
|
|
</template>
|
|
<template x-for="setting in (setupWizard.settings || [])" :key="setting.key">
|
|
<div class="mb-4">
|
|
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px;text-transform:uppercase" x-text="setting.label"></div>
|
|
<div class="text-xs text-dim mb-2" x-show="setting.description" x-text="setting.description"></div>
|
|
|
|
<!-- Select type: clickable option cards -->
|
|
<template x-if="setting.setting_type === 'select'">
|
|
<div style="display:flex;flex-direction:column;gap:6px">
|
|
<template x-for="opt in setting.options" :key="opt.value">
|
|
<div class="setting-option-card" :class="{ 'setting-option-selected': settingsValues[setting.key] === opt.value }" @click="selectOption(setting.key, opt.value)" style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border:1px solid var(--border);border-radius:6px;cursor:pointer;transition:all 0.15s">
|
|
<div>
|
|
<div class="text-sm" x-text="opt.label"></div>
|
|
<div class="text-xs text-dim" x-show="opt.provider_env" x-text="opt.provider_env"></div>
|
|
<div class="text-xs text-dim" x-show="opt.binary" x-text="'Requires: ' + opt.binary"></div>
|
|
</div>
|
|
<span class="badge" :class="opt.available ? 'badge-success' : 'badge-dim'" x-text="opt.available ? 'Ready' : 'Missing'" style="font-size:0.65rem"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Toggle type -->
|
|
<template x-if="setting.setting_type === 'toggle'">
|
|
<label class="flex items-center gap-2" style="cursor:pointer">
|
|
<input type="checkbox" :checked="settingsValues[setting.key] === 'true'" @change="settingsValues[setting.key] = $event.target.checked ? 'true' : 'false'">
|
|
<span class="text-sm" x-text="settingsValues[setting.key] === 'true' ? 'Enabled' : 'Disabled'"></span>
|
|
</label>
|
|
</template>
|
|
|
|
<!-- Text type -->
|
|
<template x-if="setting.setting_type === 'text'">
|
|
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- ═══ Step 3: Launch ═══ -->
|
|
<div class="hand-wizard-body" x-show="setupStep === 3">
|
|
<div class="launch-summary">
|
|
<div class="launch-summary-icon" x-text="setupWizard.icon"></div>
|
|
<div class="launch-summary-title" x-text="setupWizard.name"></div>
|
|
|
|
<div class="launch-summary-rows">
|
|
<!-- Dependencies summary -->
|
|
<template x-if="setupHasReqs">
|
|
<div class="launch-summary-row">
|
|
<span class="row-label">Dependencies</span>
|
|
<span class="row-check" x-text="setupReqsMet + '/' + setupReqsTotal + ' \u2713'"></span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Settings summary -->
|
|
<template x-for="setting in (setupWizard.settings || [])" :key="setting.key">
|
|
<div class="launch-summary-row">
|
|
<span class="row-label" x-text="setting.label"></span>
|
|
<span class="row-value" x-text="getSettingDisplayValue(setting)"></span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Provider / Model -->
|
|
<template x-if="setupWizard.agent">
|
|
<div class="launch-summary-row">
|
|
<span class="row-label">Model</span>
|
|
<span class="row-value" x-text="(setupWizard.agent.provider || 'default') + ' / ' + (setupWizard.agent.model || 'default')"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="hand-wizard-nav">
|
|
<div>
|
|
<button class="btn btn-ghost" @click="closeSetupWizard()">Cancel</button>
|
|
<button class="btn btn-ghost" x-show="(setupStep === 2 && setupHasReqs) || setupStep === 3" @click="setupPrevStep()" style="margin-left:4px">Back</button>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<!-- Step 1: Install + Verify + Next -->
|
|
<template x-if="setupStep === 1">
|
|
<div class="flex gap-2">
|
|
<template x-if="!setupAllReqsMet">
|
|
<button class="btn btn-success" @click="installDeps()" :disabled="installProgress && installProgress.status === 'installing'" x-text="(installProgress && installProgress.status === 'installing') ? 'Installing...' : 'Install All'">
|
|
</button>
|
|
</template>
|
|
<button class="btn btn-ghost" @click="recheckDeps()" :disabled="setupChecking" x-text="setupChecking ? 'Checking...' : 'Verify'"></button>
|
|
<button class="btn btn-primary" @click="setupNextStep()" :disabled="!setupAllReqsMet">Next</button>
|
|
</div>
|
|
</template>
|
|
<!-- Step 2: Next -->
|
|
<template x-if="setupStep === 2">
|
|
<button class="btn btn-primary" @click="setupNextStep()">Next</button>
|
|
</template>
|
|
<!-- Step 3: Activate -->
|
|
<template x-if="setupStep === 3">
|
|
<button class="btn btn-success btn-launch" @click="launchHand()" :disabled="activatingId" x-text="activatingId ? 'Activating...' : 'Activate ' + setupWizard.name"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Browser Viewer Modal -->
|
|
<template x-if="browserViewerOpen">
|
|
<div class="modal-overlay" @click.self="closeBrowserViewer()" @keydown.escape.window="closeBrowserViewer()">
|
|
<div class="browser-viewer">
|
|
<div class="browser-viewer-header">
|
|
<div class="browser-url-bar">
|
|
<span class="browser-dot red"></span>
|
|
<span class="browser-dot yellow"></span>
|
|
<span class="browser-dot green"></span>
|
|
<span class="browser-url" x-text="browserViewer ? (browserViewer.url || 'about:blank') : 'about:blank'"></span>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm" @click="refreshBrowserView()">Refresh</button>
|
|
<button class="btn btn-ghost btn-sm" @click="closeBrowserViewer()">Close</button>
|
|
</div>
|
|
<div class="browser-viewer-body">
|
|
<!-- Loading -->
|
|
<div x-show="browserViewer && browserViewer.loading" class="text-center text-dim" style="padding:40px">
|
|
Loading browser state...
|
|
</div>
|
|
<!-- Error -->
|
|
<div x-show="browserViewer && browserViewer.error && !browserViewer.loading" class="text-center" style="padding:40px;color:var(--error)">
|
|
<div style="font-size:2rem;margin-bottom:8px">!</div>
|
|
<span x-text="browserViewer ? browserViewer.error : ''"></span>
|
|
</div>
|
|
<!-- Screenshot -->
|
|
<div class="browser-screenshot" x-show="browserViewer && browserViewer.screenshot && !browserViewer.loading">
|
|
<img x-bind:src="browserViewer && browserViewer.screenshot ? ('data:image/png;base64,' + browserViewer.screenshot) : ''" alt="Browser screenshot" style="max-width:100%;border-radius:4px;display:block">
|
|
</div>
|
|
<!-- Page info -->
|
|
<div class="browser-info" x-show="browserViewer && !browserViewer.loading && !browserViewer.error">
|
|
<div class="text-dim text-sm">Title: <span x-text="browserViewer ? (browserViewer.title || '-') : '-'"></span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Trader Dashboard Modal -->
|
|
<template x-if="dashboardOpen">
|
|
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
|
|
<div class="trader-dashboard">
|
|
<!-- Header -->
|
|
<div class="trader-dashboard-header">
|
|
<div class="flex items-center gap-2">
|
|
<span style="font-size:1.4rem">📈</span>
|
|
<div>
|
|
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
|
|
<div class="text-xs text-dim">Live Trading Dashboard</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
|
|
<button class="modal-close" @click="closeDashboard()">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
|
|
<div class="spinner"></div>
|
|
<div class="text-dim mt-2">Loading dashboard data...</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Content -->
|
|
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
|
|
<!-- KPI Row -->
|
|
<div class="trader-kpi-row">
|
|
<div class="trader-kpi-card">
|
|
<div class="trader-kpi-label">Portfolio Value</div>
|
|
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
|
|
</div>
|
|
<div class="trader-kpi-card">
|
|
<div class="trader-kpi-label">Total P&L</div>
|
|
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
|
|
</div>
|
|
<div class="trader-kpi-card">
|
|
<div class="trader-kpi-label">Win Rate</div>
|
|
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
|
|
</div>
|
|
<div class="trader-kpi-card">
|
|
<div class="trader-kpi-label">Sharpe Ratio</div>
|
|
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
|
|
</div>
|
|
<div class="trader-kpi-card">
|
|
<div class="trader-kpi-label">Max Drawdown</div>
|
|
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
|
|
</div>
|
|
<div class="trader-kpi-card">
|
|
<div class="trader-kpi-label">Trades</div>
|
|
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row 1: Equity Curve + Daily P&L -->
|
|
<div class="trader-chart-row">
|
|
<div class="trader-chart-panel" style="flex:2">
|
|
<div class="trader-chart-title">Equity Curve</div>
|
|
<div class="trader-chart-wrap">
|
|
<canvas id="traderEquityChart"></canvas>
|
|
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
|
|
</div>
|
|
</div>
|
|
<div class="trader-chart-panel" style="flex:1">
|
|
<div class="trader-chart-title">Daily P&L</div>
|
|
<div class="trader-chart-wrap">
|
|
<canvas id="traderPnlChart"></canvas>
|
|
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&L data yet</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
|
|
<div class="trader-chart-row">
|
|
<div class="trader-chart-panel" style="flex:1;max-width:320px">
|
|
<div class="trader-chart-title">Signal Radar</div>
|
|
<div class="trader-chart-wrap" style="max-height:280px">
|
|
<canvas id="traderRadarChart"></canvas>
|
|
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
|
|
</div>
|
|
</div>
|
|
<div class="trader-chart-panel" style="flex:2">
|
|
<div class="trader-chart-title">Watchlist Heatmap</div>
|
|
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
|
|
<table class="trader-heatmap-table">
|
|
<thead>
|
|
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
|
|
<tr>
|
|
<td style="font-weight:600" x-text="item.ticker"></td>
|
|
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
|
|
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
|
|
<td>
|
|
<div class="confidence-bar-wrap">
|
|
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
|
|
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Trades Table -->
|
|
<div class="trader-chart-panel">
|
|
<div class="trader-chart-title">Recent Trades</div>
|
|
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
|
|
<table class="trader-trades-table">
|
|
<thead>
|
|
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&L</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
|
|
<tr>
|
|
<td class="text-dim" x-text="trade.date"></td>
|
|
<td style="font-weight:600" x-text="trade.ticker"></td>
|
|
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
|
|
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
|
|
<td x-text="trade.qty"></td>
|
|
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No data state -->
|
|
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
|
|
<div style="font-size:2rem;margin-bottom:8px">📈</div>
|
|
<div class="text-dim">Could not load dashboard data.</div>
|
|
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Activation result toast -->
|
|
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
|
|
<div class="flex items-center gap-2">
|
|
<span style="color:var(--success);font-size:1.2rem">\u2713</span>
|
|
<span x-text="activateResult"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Settings -->
|
|
<template x-if="page === 'settings'">
|
|
<div x-data="settingsPage">
|
|
<div class="page-header"><h2>Settings</h2></div>
|
|
<div class="page-body" x-init="loadSettings()">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading settings...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
<div class="tabs" role="tablist">
|
|
<div class="tab" role="tab" :class="{ active: tab === 'providers' }" @click="tab = 'providers'">Providers</div>
|
|
<div class="tab" role="tab" :class="{ active: tab === 'models' }" @click="tab = 'models'">Models</div>
|
|
<div class="tab" role="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'; if (!configSchema) loadConfigSchema()">Config</div>
|
|
<div class="tabs-separator"></div>
|
|
<div class="tab tab-secondary" role="tab" :class="{ active: tab === 'tools' }" @click="tab = 'tools'">Tools</div>
|
|
<div class="tab tab-secondary" role="tab" :class="{ active: tab === 'security' }" @click="tab = 'security'; if (!securityData && !secLoading) loadSecurity()">Security</div>
|
|
<div class="tab tab-secondary" role="tab" :class="{ active: tab === 'network' }" @click="tab = 'network'; if (!peers.length && !peersLoading) { loadPeers(); startPeerPolling(); }">Network</div>
|
|
<div class="tab tab-secondary" role="tab" :class="{ active: tab === 'budget' }" @click="tab = 'budget'">Budget</div>
|
|
<div class="tab tab-secondary" role="tab" :class="{ active: tab === 'info' }" @click="tab = 'info'">System</div>
|
|
<div class="tab tab-secondary" role="tab" :class="{ active: tab === 'migration' }" @click="tab = 'migration'">Migration</div>
|
|
</div>
|
|
|
|
<!-- Providers tab -->
|
|
<div x-show="tab === 'providers'">
|
|
<div class="info-card">
|
|
<h4>LLM Providers</h4>
|
|
<p>OpenFang ships with <span x-text="providers.length"></span> built-in providers and you can add unlimited custom ones. <span x-text="configuredProviderCount"></span> currently configured. Filter, search, or jump to a category below — only providers with a saved key (or that need none) light up models for your agents.</p>
|
|
</div>
|
|
<!-- Filter toolbar -->
|
|
<div class="flex gap-2 mb-4" style="flex-wrap:wrap;align-items:center">
|
|
<div class="search-input" style="flex:1;min-width:200px">
|
|
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
|
<input placeholder="Search providers..." x-model="providerSearch">
|
|
</div>
|
|
<select class="form-select" style="width:170px" x-model="providerStatusFilter">
|
|
<option value="">All Statuses</option>
|
|
<option value="configured">Configured</option>
|
|
<option value="unconfigured">Needs Key</option>
|
|
</select>
|
|
<select class="form-select" style="width:200px" x-model="providerCategoryFilter">
|
|
<option value="">All Categories</option>
|
|
<option value="frontier">Frontier</option>
|
|
<option value="oss">Open-Weight Hosts</option>
|
|
<option value="aggregator">Aggregators</option>
|
|
<option value="regional">Regional / China</option>
|
|
<option value="local">Local / Self-Hosted</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
<button class="btn btn-ghost btn-sm" @click="clearProviderFilters()" x-show="providerSearch || providerStatusFilter || providerCategoryFilter">Clear</button>
|
|
</div>
|
|
<div class="text-xs text-dim mb-2" x-text="filteredProviders.length + ' of ' + providers.length + ' providers'"></div>
|
|
<!-- Empty state for filters -->
|
|
<div x-show="!filteredProviders.length && providers.length" style="text-align:center;padding:32px 16px">
|
|
<h3 style="margin:0 0 4px;font-size:14px">No providers match your filters</h3>
|
|
<p class="text-xs text-dim">Try a different search term or category.</p>
|
|
<button class="btn btn-ghost btn-sm mt-2" @click="clearProviderFilters()">Clear Filters</button>
|
|
</div>
|
|
<!-- Grouped provider sections -->
|
|
<template x-for="group in providersGrouped" :key="group.category">
|
|
<div style="margin-bottom:1.25rem">
|
|
<div class="card-header" style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted)">
|
|
<span x-text="group.label"></span>
|
|
<span class="text-xs text-dim" style="font-weight:normal;text-transform:none;letter-spacing:0" x-text="'(' + group.items.length + ')'"></span>
|
|
</div>
|
|
<div class="card-grid">
|
|
<template x-for="p in group.items" :key="p.id">
|
|
<div class="card provider-card" :class="providerCardClass(p)">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="card-header" style="margin:0" x-text="p.display_name"></div>
|
|
<span class="badge" :class="providerAuthClass(p)" x-text="providerAuthText(p)"></span>
|
|
</div>
|
|
<div class="card-meta" x-text="(p.model_count || 0) + ' model(s) available'"></div>
|
|
<div class="text-xs text-dim mt-2" x-show="p.api_key_env" x-text="'Env: ' + p.api_key_env"></div>
|
|
<!-- Key input for unconfigured providers -->
|
|
<template x-if="p.auth_status !== 'configured' && p.api_key_env">
|
|
<div class="key-input-group">
|
|
<input type="password" :placeholder="'Enter ' + p.api_key_env" x-model="providerKeyInputs[p.id]">
|
|
<button class="btn btn-primary btn-sm" @click="saveProviderKey(p)">Save</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="p.auth_status !== 'configured' && p.api_key_env">
|
|
<div class="text-xs text-dim mt-2">Or set <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px" x-text="p.api_key_env"></code> in your environment and restart</div>
|
|
</template>
|
|
<!-- Copilot OAuth button -->
|
|
<template x-if="p.id === 'github-copilot' && p.auth_status !== 'configured'">
|
|
<div class="mt-2">
|
|
<button class="btn btn-primary btn-sm" @click="startCopilotOAuth()" :disabled="copilotOAuth.polling" x-show="!copilotOAuth.userCode">Login with GitHub</button>
|
|
<div x-show="copilotOAuth.userCode" class="mt-2">
|
|
<div class="text-sm">Visit <a :href="copilotOAuth.verificationUri" target="_blank" x-text="copilotOAuth.verificationUri" style="color:var(--accent-light)"></a> and enter:</div>
|
|
<div style="font-size:24px;font-weight:bold;letter-spacing:4px;margin:8px 0;color:var(--accent-light)" x-text="copilotOAuth.userCode"></div>
|
|
<div class="text-xs text-dim"><span class="spinner" style="width:10px;height:10px;border-width:2px;display:inline-block;vertical-align:middle"></span> Waiting for authorization...</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Claude Code install hint -->
|
|
<template x-if="p.id === 'claude-code' && p.auth_status !== 'configured'">
|
|
<div class="mt-2 text-xs text-dim">Install: <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
|
</template>
|
|
<!-- Actions for configured providers -->
|
|
<template x-if="p.auth_status === 'configured'">
|
|
<div class="flex gap-2 mt-2">
|
|
<button class="btn btn-ghost btn-sm" @click="testProvider(p)" :disabled="providerTesting[p.id]">
|
|
<span x-show="!providerTesting[p.id]">Test</span>
|
|
<span x-show="providerTesting[p.id]" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" style="color:var(--error)" @click="removeProviderKey(p)">Remove Key</button>
|
|
</div>
|
|
</template>
|
|
<!-- No key needed -->
|
|
<template x-if="!p.api_key_env || p.key_required === false">
|
|
<div class="text-xs mt-2" style="color:var(--success)" x-show="p.auth_status !== 'configured' && p.auth_status !== 'not_set' && p.auth_status !== 'missing'">No API key needed — runs locally or is free</div>
|
|
</template>
|
|
<!-- Base URL editor for local providers -->
|
|
<template x-if="p.is_local">
|
|
<div class="mt-3" style="border-top:1px solid var(--border);padding-top:8px">
|
|
<div class="text-xs text-dim mb-1">Base URL</div>
|
|
<div class="key-input-group">
|
|
<input type="text" :placeholder="'http://localhost:...'" x-model="providerUrlInputs[p.id]" style="font-size:12px">
|
|
<button class="btn btn-primary btn-sm" @click="saveProviderUrl(p)" :disabled="providerUrlSaving[p.id]">
|
|
<span x-show="!providerUrlSaving[p.id]">Save</span>
|
|
<span x-show="providerUrlSaving[p.id]" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Add Custom Provider -->
|
|
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
|
<h4 style="margin-top:0">Add Custom Provider</h4>
|
|
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
|
<div>
|
|
<label class="text-xs text-dim">Provider Name</label>
|
|
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim">Base URL (required)</label>
|
|
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
|
|
</div>
|
|
</div>
|
|
<div class="mt-2">
|
|
<label class="text-xs text-dim">API Key (optional)</label>
|
|
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
|
|
</div>
|
|
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
|
|
<span x-show="!addingCustomProvider">Add Provider</span>
|
|
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
|
|
</div>
|
|
<div class="empty-state" x-show="!providers.length">
|
|
<h4>No providers found</h4>
|
|
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Models tab -->
|
|
<div x-show="tab === 'models'">
|
|
<div class="info-card">
|
|
<h4>Model Catalog</h4>
|
|
<p>Browse all available models across providers. Models marked "Available" have their provider configured and ready to use.</p>
|
|
</div>
|
|
<div class="flex gap-2 mb-4" style="flex-wrap:wrap">
|
|
<div class="search-input" style="flex:1;min-width:200px">
|
|
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
|
<input placeholder="Search models..." x-model="modelSearch">
|
|
</div>
|
|
<select class="form-select" style="width:160px" x-model="modelProviderFilter">
|
|
<option value="">All Providers</option>
|
|
<template x-for="pn in uniqueProviderNames" :key="pn">
|
|
<option :value="pn" x-text="pn"></option>
|
|
</template>
|
|
</select>
|
|
<select class="form-select" style="width:140px" x-model="modelTierFilter">
|
|
<option value="">All Tiers</option>
|
|
<template x-for="t in uniqueTiers" :key="t">
|
|
<option :value="t" x-text="t"></option>
|
|
</template>
|
|
</select>
|
|
<button class="btn btn-primary btn-sm" @click="showCustomModelForm = !showCustomModelForm" x-text="showCustomModelForm ? 'Cancel' : '+ Custom Model'"></button>
|
|
</div>
|
|
<!-- Custom model form -->
|
|
<div x-show="showCustomModelForm" class="info-card mb-4" style="border:1px solid var(--accent,#7c3aed)">
|
|
<h4 style="margin-top:0">Add Custom Model</h4>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
|
<div>
|
|
<label class="text-xs text-dim">Model ID (required)</label>
|
|
<input class="form-input" x-model="customModelId" placeholder="e.g. my-org/my-model">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim">Provider</label>
|
|
<input class="form-input" x-model="customModelProvider" placeholder="openrouter">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim">Context Window</label>
|
|
<input class="form-input" type="number" x-model.number="customModelContext" placeholder="128000">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim">Max Output Tokens</label>
|
|
<input class="form-input" type="number" x-model.number="customModelMaxOutput" placeholder="8192">
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm mt-2" @click="addCustomModel()" :disabled="!customModelId.trim()">Add Model</button>
|
|
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
|
</div>
|
|
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
|
<div x-show="!filteredModels.length && !loading" style="text-align:center;padding:32px 16px">
|
|
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
|
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
|
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
|
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
|
|
</div>
|
|
<div class="table-wrap" x-show="filteredModels.length">
|
|
<table>
|
|
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
|
|
<tbody>
|
|
<template x-for="m in filteredModels" :key="m.id">
|
|
<tr>
|
|
<td style="font-size:11px"><div class="font-bold" x-text="m.display_name || m.id"></div><div class="text-xs text-dim" style="font-family:var(--font-mono);opacity:0.7;user-select:all" x-show="m.display_name && m.display_name !== m.id" x-text="m.id"></div></td>
|
|
<td class="text-dim" x-text="m.provider"></td>
|
|
<td><span class="tier-badge" :class="tierBadgeClass(m.tier)" x-text="m.tier || '-'"></span></td>
|
|
<td class="text-xs" x-text="formatContext(m.context_window)"></td>
|
|
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
|
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
|
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
|
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tools tab -->
|
|
<div x-show="tab === 'tools'">
|
|
<div class="search-input mb-4">
|
|
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
|
<input placeholder="Search tools..." x-model="toolSearch">
|
|
</div>
|
|
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
|
|
<div x-show="!filteredTools.length && !loading" style="text-align:center;padding:32px 16px">
|
|
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🔧</div>
|
|
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
|
|
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
|
|
<button class="btn btn-ghost btn-sm mt-2" x-show="tools.length && toolSearch" @click="toolSearch=''">Clear Search</button>
|
|
</div>
|
|
<div class="table-wrap" x-show="filteredTools.length">
|
|
<table>
|
|
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="t in filteredTools" :key="t.name">
|
|
<tr>
|
|
<td class="font-bold" style="white-space:nowrap" x-text="t.name"></td>
|
|
<td class="text-dim" x-text="t.description"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- System Info tab -->
|
|
<div x-show="tab === 'info'">
|
|
<div class="stats-row">
|
|
<div class="stat-card"><div class="stat-value" x-text="sysInfo.version || '-'"></div><div class="stat-label">Version</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="sysInfo.platform || '-'"></div><div class="stat-label">Platform</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="formatUptime(sysInfo.uptime_seconds)"></div><div class="stat-label">Uptime</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="sysInfo.agent_count || 0"></div><div class="stat-label">Agents</div></div>
|
|
</div>
|
|
<div class="card mt-4" x-show="sysInfo.default_provider">
|
|
<div class="card-header">Default Model</div>
|
|
<div class="card-meta" x-text="sysInfo.default_provider + ' : ' + sysInfo.default_model"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Config tab -->
|
|
<div x-show="tab === 'config'">
|
|
<div class="info-card">
|
|
<h4>Runtime Configuration</h4>
|
|
<p>View and edit the active configuration. Changes are applied immediately. For advanced edits, modify <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">config.toml</code> in your OpenFang data directory.</p>
|
|
</div>
|
|
|
|
<!-- Dynamic config form (when schema is available) -->
|
|
<template x-if="configSchema">
|
|
<div>
|
|
<template x-for="(fields, section) in configSchema" :key="section">
|
|
<div class="card mb-4">
|
|
<div class="card-header" style="text-transform:capitalize" x-text="section.replace(/_/g, ' ')"></div>
|
|
<div class="detail-grid" style="margin-top:12px">
|
|
<template x-for="field in (fields.fields || [])" :key="section + '.' + field.name">
|
|
<div class="detail-row" style="align-items:center">
|
|
<span class="detail-label" x-text="field.label || field.name"></span>
|
|
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
|
<template x-if="field.type === 'boolean'">
|
|
<label class="form-checkbox" style="margin:0">
|
|
<input type="checkbox"
|
|
:checked="configValues[section] && configValues[section][field.name]"
|
|
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.checked; markConfigDirty(section, field.name)">
|
|
</label>
|
|
</template>
|
|
<template x-if="field.type === 'number'">
|
|
<input class="form-input" type="number" style="width:120px"
|
|
:value="configValues[section] && configValues[section][field.name]"
|
|
@input="configValues[section] = configValues[section] || {}; configValues[section][field.name] = Number($event.target.value); markConfigDirty(section, field.name)">
|
|
</template>
|
|
<template x-if="field.type === 'select' && field.options">
|
|
<select class="form-select" style="width:180px"
|
|
:value="configValues[section] && configValues[section][field.name]"
|
|
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
|
|
<template x-for="opt in field.options" :key="typeof opt === 'object' ? opt.id : opt">
|
|
<option :value="typeof opt === 'object' ? opt.id : opt" x-text="typeof opt === 'object' ? (opt.name || opt.display_name || opt.id) : opt" :selected="configValues[section] && configValues[section][field.name] === (typeof opt === 'object' ? opt.id : opt)"></option>
|
|
</template>
|
|
</select>
|
|
</template>
|
|
<template x-if="!field.type || field.type === 'string'">
|
|
<input class="form-input" type="text" style="flex:1"
|
|
:value="configValues[section] && configValues[section][field.name]"
|
|
@input="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
|
|
</template>
|
|
<button class="btn btn-primary btn-sm" x-show="isConfigDirty(section, field.name)"
|
|
@click="saveConfigField(section, field.name, configValues[section] && configValues[section][field.name])"
|
|
:disabled="configSaving[section + '.' + field.name]">
|
|
<span x-show="!configSaving[section + '.' + field.name]">Save</span>
|
|
<span x-show="configSaving[section + '.' + field.name]" class="spinner" style="width:12px;height:12px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
<div class="text-xs text-dim" x-show="field.description" x-text="field.description" style="grid-column:1/-1;padding-left:2px"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Raw JSON fallback -->
|
|
<div class="card mt-4" style="border-left:3px solid var(--border)">
|
|
<div class="card-header" style="font-size:12px;cursor:pointer" @click="$el.nextElementSibling.style.display = $el.nextElementSibling.style.display === 'none' ? 'block' : 'none'">Raw Config JSON (click to toggle)</div>
|
|
<pre style="font-size:11px;white-space:pre-wrap;overflow:auto;max-height:400px;color:var(--text-dim);line-height:1.5;display:none" x-text="JSON.stringify(config, null, 2)"></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Security tab -->
|
|
<div x-show="tab === 'security'">
|
|
<div x-show="secLoading" class="loading-state"><div class="spinner"></div><span>Loading security data...</span></div>
|
|
<div x-show="!secLoading">
|
|
<div class="security-hero">
|
|
<div class="security-hero-shield"><svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
|
|
<div>
|
|
<div class="security-hero-title">Defense in Depth</div>
|
|
<div class="security-hero-desc">OpenFang implements 15 layered security features across the entire stack — from network ingress to agent sandboxing to cryptographic audit trails. Core protections cannot be disabled.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="security-section">
|
|
<div class="security-section-header">
|
|
<span class="security-shield shield-core"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
|
|
<div>
|
|
<div class="font-bold">Core Protections</div>
|
|
<div class="text-xs text-dim">Always active. Cannot be disabled.</div>
|
|
</div>
|
|
</div>
|
|
<div class="security-grid">
|
|
<template x-for="f in coreFeatures" :key="f.key">
|
|
<div class="security-card">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="security-card-name" x-text="f.name"></div>
|
|
<span class="sec-badge sec-badge-core">ALWAYS ON</span>
|
|
</div>
|
|
<div class="security-card-desc" x-text="f.description"></div>
|
|
<div class="security-card-threat">
|
|
<span class="text-xs font-bold" style="color:var(--error)">Protects against:</span>
|
|
<span class="text-xs" x-text="f.threat"></span>
|
|
</div>
|
|
<div class="text-xs text-dim" style="margin-top:6px;opacity:0.5" x-text="f.impl"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="security-section">
|
|
<div class="security-section-header">
|
|
<span class="security-shield shield-config"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
|
<div>
|
|
<div class="font-bold">Configurable Controls</div>
|
|
<div class="text-xs text-dim">Active with tunable parameters.</div>
|
|
</div>
|
|
</div>
|
|
<div class="security-grid">
|
|
<template x-for="f in configurableFeatures" :key="f.key">
|
|
<div class="security-card">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="security-card-name" x-text="f.name"></div>
|
|
<span class="sec-badge sec-badge-config">CONFIGURABLE</span>
|
|
</div>
|
|
<div class="security-card-desc" x-text="f.description"></div>
|
|
<div class="security-card-value" x-text="formatConfigValue(f)"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="security-section">
|
|
<div class="security-section-header">
|
|
<span class="security-shield shield-monitor"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/><path d="m9 14 2 2 4-4"/></svg></span>
|
|
<div>
|
|
<div class="font-bold">Monitoring & Analysis</div>
|
|
<div class="text-xs text-dim">Active monitoring systems.</div>
|
|
</div>
|
|
</div>
|
|
<div class="security-grid">
|
|
<template x-for="f in monitoringFeatures" :key="f.key">
|
|
<div class="security-card">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="security-card-name" x-text="f.name"></div>
|
|
<span class="sec-badge sec-badge-monitor">MONITORING</span>
|
|
</div>
|
|
<div class="security-card-desc" x-text="f.description"></div>
|
|
<div class="security-card-value" x-text="formatMonitoringValue(f)"></div>
|
|
</div>
|
|
</template>
|
|
<div class="security-card" style="border-color:var(--accent)">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="security-card-name">Audit Chain Integrity</div>
|
|
<button class="btn btn-ghost btn-sm" @click="verifyAuditChain()" :disabled="verifyingChain">
|
|
<span x-show="!verifyingChain">Verify Now</span>
|
|
<span x-show="verifyingChain" class="spinner" style="width:12px;height:12px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
<div class="security-card-desc">Run cryptographic verification of the entire SHA-256 Merkle hash chain.</div>
|
|
<div x-show="chainResult" class="mt-2">
|
|
<span class="sec-badge" :class="chainResult && chainResult.valid ? 'sec-badge-core' : 'sec-badge-warn'" x-text="chainResult && chainResult.valid ? 'CHAIN VALID — ' + (chainResult.entries || 0) + ' entries verified' : 'CHAIN BROKEN — ' + (chainResult ? chainResult.error : '')"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:6px">Security Dependencies</div>
|
|
<div class="text-xs text-dim" style="line-height:1.8">
|
|
<code style="color:var(--accent-light)">sha2</code> SHA-256 <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
|
<code style="color:var(--accent-light)">hmac</code> HMAC-SHA256 <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
|
<code style="color:var(--accent-light)">subtle</code> constant-time <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
|
<code style="color:var(--accent-light)">ed25519-dalek</code> signing <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
|
<code style="color:var(--accent-light)">zeroize</code> secret wiping <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
|
<code style="color:var(--accent-light)">rand</code> randomness <svg width="3" height="3" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.4"><circle cx="12" cy="12" r="10"/></svg>
|
|
<code style="color:var(--accent-light)">governor</code> rate limiting
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Network tab -->
|
|
<div x-show="tab === 'network'" x-data="{
|
|
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
|
|
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
|
|
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
|
|
async discoverA2a() {
|
|
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
|
|
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
|
this.a2aDiscovering = false;
|
|
}
|
|
}" x-init="loadNetStatus(); loadA2aAgents()">
|
|
<!-- OFP Status -->
|
|
<div class="info-card" style="display:flex;justify-content:space-between;align-items:center">
|
|
<div>
|
|
<h4>Peer Networking (OFP)</h4>
|
|
<p>Link multiple OpenFang instances into a mesh via the OFP wire protocol.</p>
|
|
</div>
|
|
<span class="badge" :class="netStatus && netStatus.enabled ? 'badge-success' : 'badge-muted'" x-text="netStatus && netStatus.enabled ? 'Enabled' : 'Disabled'" style="font-size:12px;padding:4px 10px"></span>
|
|
</div>
|
|
<div x-show="netStatus && netStatus.enabled" class="text-xs text-dim mb-2" style="margin-top:-8px">
|
|
Node: <span x-text="netStatus?.node_id?.substring(0,8) + '...'" class="font-bold"></span> <svg width="4" height="4" viewBox="0 0 24 24" fill="currentColor" style="display:inline-block;vertical-align:middle;opacity:0.5"><circle cx="12" cy="12" r="10"/></svg>
|
|
Listening on <span x-text="netStatus?.listen_address" class="font-bold"></span>
|
|
</div>
|
|
<div x-show="peersLoading" class="loading-state"><div class="spinner"></div><span>Loading peers...</span></div>
|
|
<div x-show="!peersLoading && peersLoadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="peersLoadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadPeers()">Retry</button>
|
|
</div>
|
|
<div x-show="!peersLoading && !peersLoadError">
|
|
<div class="stats-row">
|
|
<div class="stat-card"><div class="stat-value" x-text="peers.filter(function(p){return p.state === 'Connected'}).length"></div><div class="stat-label">Connected</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="peers.length"></div><div class="stat-label">Total Peers</div></div>
|
|
</div>
|
|
<div class="table-wrap" x-show="peers.length">
|
|
<table>
|
|
<thead><tr><th>Node</th><th>Address</th><th>State</th><th>Agents</th><th>Protocol</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="p in peers" :key="p.node_id">
|
|
<tr>
|
|
<td><span class="font-bold" x-text="p.node_name"></span><br><span class="text-xs text-dim" x-text="p.node_id"></span></td>
|
|
<td x-text="p.address"></td>
|
|
<td><span class="badge" :class="p.state === 'Connected' ? 'badge-connected' : 'badge-disconnected'" x-text="p.state"></span></td>
|
|
<td x-text="(p.agents || []).length"></td>
|
|
<td x-text="'v' + p.protocol_version"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!peers.length">
|
|
<h4>No peers connected</h4>
|
|
<p class="hint">Add a <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">[network]</code> section to config.toml with <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">shared_secret</code> and peer addresses.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- A2A External Agents -->
|
|
<div style="margin-top:24px;border-top:1px solid var(--border);padding-top:16px">
|
|
<h4 style="margin-bottom:8px">A2A External Agents</h4>
|
|
<p class="text-xs text-dim mb-2">Discovered agents on other OpenFang/A2A-compatible instances that this node can communicate with.</p>
|
|
<div class="flex gap-2 mb-3">
|
|
<input class="form-input" style="flex:1" placeholder="https://remote-agent.example.com" x-model="a2aDiscoverUrl">
|
|
<button class="btn btn-primary btn-sm" @click="discoverA2a()" :disabled="a2aDiscovering">
|
|
<span x-show="!a2aDiscovering">Discover</span>
|
|
<span x-show="a2aDiscovering">...</span>
|
|
</button>
|
|
</div>
|
|
<div class="table-wrap" x-show="a2aAgents.length">
|
|
<table>
|
|
<thead><tr><th>Name</th><th>URL</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="a in a2aAgents" :key="a.url">
|
|
<tr>
|
|
<td class="font-bold" x-text="a.name"></td>
|
|
<td class="text-xs text-dim" x-text="a.url"></td>
|
|
<td class="text-xs" x-text="(a.description || '').substring(0,80)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="text-xs text-dim" x-show="!a2aAgents.length">No external agents discovered yet. Enter a URL above to discover one.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Budget tab -->
|
|
<div x-show="tab === 'budget'" x-data="{
|
|
budgetData: null, agentRanking: [], budgetLoading: true,
|
|
editMode: false,
|
|
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
|
|
saving: false,
|
|
async loadBudget() {
|
|
this.budgetLoading = true;
|
|
try {
|
|
let [b, a] = await Promise.all([
|
|
OpenFangAPI.get('/api/budget'),
|
|
OpenFangAPI.get('/api/budget/agents')
|
|
]);
|
|
this.budgetData = b;
|
|
this.agentRanking = a.agents || [];
|
|
} catch(e) {}
|
|
this.budgetLoading = false;
|
|
},
|
|
startEdit() {
|
|
this.editHourly = this.budgetData.hourly_limit || 0;
|
|
this.editDaily = this.budgetData.daily_limit || 0;
|
|
this.editMonthly = this.budgetData.monthly_limit || 0;
|
|
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
|
|
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
|
|
this.editMode = true;
|
|
},
|
|
async saveBudget() {
|
|
this.saving = true;
|
|
try {
|
|
let body = {};
|
|
if (+this.editHourly !== this.budgetData.hourly_limit) body.max_hourly_usd = +this.editHourly;
|
|
if (+this.editDaily !== this.budgetData.daily_limit) body.max_daily_usd = +this.editDaily;
|
|
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
|
|
let alertVal = (+this.editAlert) / 100;
|
|
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
|
|
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
|
|
await OpenFangAPI.put('/api/budget', body);
|
|
this.editMode = false;
|
|
await this.loadBudget();
|
|
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
|
|
this.saving = false;
|
|
},
|
|
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
|
|
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
|
|
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
|
|
}" x-init="loadBudget()">
|
|
<div class="info-card" style="display:flex;justify-content:space-between;align-items:start">
|
|
<div>
|
|
<h4>Budget & Spending Limits</h4>
|
|
<p>Monitor and control spending across all agents.</p>
|
|
</div>
|
|
<button class="btn btn-sm" @click="editMode ? saveBudget() : startEdit()" :disabled="saving" x-text="editMode ? (saving ? 'Saving...' : 'Save') : 'Edit Limits'" style="white-space:nowrap"></button>
|
|
</div>
|
|
<div x-show="budgetLoading" class="loading-state"><div class="spinner"></div><span>Loading budget...</span></div>
|
|
<template x-if="!budgetLoading && budgetData">
|
|
<div>
|
|
<!-- Global budget meters -->
|
|
<div class="stats-row" style="margin-bottom:16px">
|
|
<div class="stat-card" style="flex:1">
|
|
<div class="stat-label">Hourly</div>
|
|
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.hourly_spend || 0).toFixed(4)"></div>
|
|
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.hourly_limit)"></div>
|
|
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.hourly_limit > 0">
|
|
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.hourly_pct*100,100)+'%', background: pctColor(budgetData.hourly_pct)}"></div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card" style="flex:1">
|
|
<div class="stat-label">Daily</div>
|
|
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.daily_spend || 0).toFixed(4)"></div>
|
|
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.daily_limit)"></div>
|
|
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.daily_limit > 0">
|
|
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.daily_pct*100,100)+'%', background: pctColor(budgetData.daily_pct)}"></div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card" style="flex:1">
|
|
<div class="stat-label">Monthly</div>
|
|
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.monthly_spend || 0).toFixed(4)"></div>
|
|
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.monthly_limit)"></div>
|
|
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.monthly_limit > 0">
|
|
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.monthly_pct*100,100)+'%', background: pctColor(budgetData.monthly_pct)}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
|
|
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
|
|
</div>
|
|
<div class="text-xs text-dim mb-3" x-show="!editMode">
|
|
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
|
|
</div>
|
|
|
|
<!-- Edit limits form -->
|
|
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
|
|
<div class="stats-row" style="margin-bottom:8px;gap:8px">
|
|
<div style="flex:1">
|
|
<label class="text-xs text-dim">Hourly Limit ($)</label>
|
|
<input type="number" step="0.1" min="0" x-model="editHourly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
|
</div>
|
|
<div style="flex:1">
|
|
<label class="text-xs text-dim">Daily Limit ($)</label>
|
|
<input type="number" step="1" min="0" x-model="editDaily" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
|
</div>
|
|
<div style="flex:1">
|
|
<label class="text-xs text-dim">Monthly Limit ($)</label>
|
|
<input type="number" step="1" min="0" x-model="editMonthly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
|
</div>
|
|
<div style="flex:0.6">
|
|
<label class="text-xs text-dim">Alert (%)</label>
|
|
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom:8px">
|
|
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
|
|
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
|
|
</div>
|
|
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
|
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
|
|
</div>
|
|
|
|
<!-- Per-agent cost ranking -->
|
|
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
|
|
<div class="table-wrap" x-show="agentRanking.length">
|
|
<table>
|
|
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="a in agentRanking" :key="a.agent_id">
|
|
<tr>
|
|
<td class="font-bold" x-text="a.name"></td>
|
|
<td x-text="'$' + (a.daily_cost_usd || 0).toFixed(4)"></td>
|
|
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
|
|
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
|
|
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
|
|
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="text-xs text-dim" x-show="!agentRanking.length">No spending recorded today.</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Migration tab -->
|
|
<div x-show="tab === 'migration'">
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)" x-show="migStep === 'intro'">
|
|
<div class="font-bold" style="font-size:14px;margin-bottom:6px">Migrate from OpenClaw</div>
|
|
<div class="text-sm text-dim" style="line-height:1.7">
|
|
Seamlessly transfer your agents, memory, workspace files, and channel configurations from OpenClaw to OpenFang.
|
|
</div>
|
|
<ul class="text-sm text-dim" style="margin:8px 0 0 16px;line-height:1.8">
|
|
<li>Converts agent.yaml to agent.toml with proper capabilities</li>
|
|
<li>Maps tools (read_file → file_read, execute_command → shell_exec, etc.)</li>
|
|
<li>Merges channel configs into config.toml</li>
|
|
<li>Copies workspace files and memory data</li>
|
|
</ul>
|
|
<div class="flex gap-2 mt-4">
|
|
<button class="btn btn-primary" @click="autoDetect()" :disabled="detecting">
|
|
<span x-show="!detecting">Auto-Detect OpenClaw</span>
|
|
<span x-show="detecting">Scanning...</span>
|
|
</button>
|
|
<button class="btn btn-ghost" @click="migStep = 'manual'">Enter Path Manually</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-4" x-show="migStep === 'manual'" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:14px;margin-bottom:8px">Specify OpenClaw Path</div>
|
|
<div class="form-group">
|
|
<label>OpenClaw Home Directory</label>
|
|
<input class="form-input" type="text" x-model="sourcePath" placeholder="~/.openclaw" style="font-family:monospace;font-size:12px">
|
|
</div>
|
|
<div class="form-group mt-2">
|
|
<label>OpenFang Target Directory</label>
|
|
<input class="form-input" type="text" x-model="targetPath" placeholder="~/.openfang (default)" style="font-family:monospace;font-size:12px">
|
|
</div>
|
|
<div class="flex gap-2 mt-4">
|
|
<button class="btn btn-primary" @click="scanPath()" :disabled="!sourcePath || scanning">
|
|
<span x-show="!scanning">Scan Directory</span>
|
|
<span x-show="scanning">Scanning...</span>
|
|
</button>
|
|
<button class="btn btn-ghost" @click="migStep = 'intro'">Back</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template x-if="migStep === 'preview' && scanResult">
|
|
<div>
|
|
<div class="card mb-4" style="border-left:3px solid var(--success, #22c55e)">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="font-bold" style="font-size:14px">OpenClaw Workspace Found</div>
|
|
<span class="badge badge-connected">Ready to Migrate</span>
|
|
</div>
|
|
<div class="text-sm text-dim" style="font-family:monospace" x-text="scanResult.path"></div>
|
|
</div>
|
|
<div class="stats-row mb-4">
|
|
<div class="stat-card"><div class="stat-value" x-text="scanResult.agents ? scanResult.agents.length : 0"></div><div class="stat-label">Agents</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="scanResult.channels ? scanResult.channels.length : 0"></div><div class="stat-label">Channels</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="scanResult.skills ? scanResult.skills.length : 0"></div><div class="stat-label">Skills</div></div>
|
|
</div>
|
|
<div class="flex gap-2 mb-4">
|
|
<button class="btn btn-primary" @click="runMigration(false)" :disabled="migrating">
|
|
<span x-show="!migrating">Migrate Now</span>
|
|
<span x-show="migrating">Migrating...</span>
|
|
</button>
|
|
<button class="btn btn-ghost" @click="runMigration(true)" :disabled="migrating">Dry Run</button>
|
|
<button class="btn btn-ghost" @click="migStep = 'intro'; scanResult = null">Start Over</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="migStep === 'result' && migResult">
|
|
<div>
|
|
<div class="card mb-4" :style="'border-left:3px solid ' + (migResult.status === 'completed' ? 'var(--success, #22c55e)' : 'var(--error)')">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div class="font-bold" style="font-size:14px" x-text="migResult.dry_run ? 'Dry Run Complete' : 'Migration Complete!'"></div>
|
|
<span class="badge" :class="migResult.status === 'completed' ? 'badge-connected' : 'badge-crashed'" x-text="migResult.status === 'completed' ? 'SUCCESS' : 'FAILED'"></span>
|
|
</div>
|
|
<div class="text-sm text-dim" x-show="migResult.error" style="color:var(--error)" x-text="migResult.error"></div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary" x-show="migResult.dry_run" @click="runMigration(false)" :disabled="migrating">Run Migration for Real</button>
|
|
<button class="btn btn-ghost" @click="migStep = 'intro'; migResult = null; scanResult = null">Start New Migration</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="card" x-show="migStep === 'not_found'" style="border-left:3px solid var(--warning, #f59e0b)">
|
|
<div class="font-bold mb-2" style="font-size:14px">OpenClaw Not Found</div>
|
|
<div class="text-sm text-dim">Could not auto-detect an OpenClaw installation.</div>
|
|
<div class="flex gap-2 mt-4">
|
|
<button class="btn btn-primary" @click="migStep = 'manual'">Enter Path Manually</button>
|
|
<button class="btn btn-ghost" @click="migStep = 'intro'">Back</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Analytics -->
|
|
<template x-if="page === 'analytics'">
|
|
<div x-data="analyticsPage">
|
|
<div class="page-header"><h2>Analytics</h2></div>
|
|
<div class="page-body" x-init="loadUsage()">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading usage data...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
<div class="stats-row">
|
|
<div class="stat-card"><div class="stat-value" x-text="formatTokens((summary.total_input_tokens || 0) + (summary.total_output_tokens || 0))"></div><div class="stat-label">Total Tokens</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="formatCost(summary.total_cost_usd)"></div><div class="stat-label">Estimated Cost</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="summary.call_count || 0"></div><div class="stat-label">API Calls</div></div>
|
|
<div class="stat-card"><div class="stat-value" x-text="summary.total_tool_calls || 0"></div><div class="stat-label">Tool Calls</div></div>
|
|
</div>
|
|
<div class="tabs mt-4">
|
|
<div class="tab" :class="{ active: tab === 'summary' }" @click="tab = 'summary'">Summary</div>
|
|
<div class="tab" :class="{ active: tab === 'by-model' }" @click="tab = 'by-model'">By Model</div>
|
|
<div class="tab" :class="{ active: tab === 'by-agent' }" @click="tab = 'by-agent'">By Agent</div>
|
|
<div class="tab" :class="{ active: tab === 'costs' }" @click="tab = 'costs'">Costs</div>
|
|
</div>
|
|
<div x-show="tab === 'summary'">
|
|
<div class="card mt-4">
|
|
<div class="card-header">Token Breakdown</div>
|
|
<div class="detail-grid" style="margin-top:8px">
|
|
<div class="detail-row"><span class="detail-label">Input Tokens</span><span class="detail-value" x-text="formatTokens(summary.total_input_tokens)"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Output Tokens</span><span class="detail-value" x-text="formatTokens(summary.total_output_tokens)"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Total Cost</span><span class="detail-value" x-text="formatCost(summary.total_cost_usd)"></span></div>
|
|
<div class="detail-row"><span class="detail-label">API Calls</span><span class="detail-value" x-text="summary.call_count || 0"></span></div>
|
|
<div class="detail-row"><span class="detail-label">Tool Calls</span><span class="detail-value" x-text="summary.total_tool_calls || 0"></span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div x-show="tab === 'by-model'">
|
|
<div class="table-wrap mt-4" x-show="byModel.length">
|
|
<table>
|
|
<thead><tr><th>Model</th><th>Calls</th><th>Input Tokens</th><th>Output Tokens</th><th>Cost</th><th style="width:30%">Usage</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="m in byModel" :key="m.model">
|
|
<tr>
|
|
<td class="font-bold" style="font-size:11px" x-text="m.model"></td>
|
|
<td x-text="m.call_count"></td>
|
|
<td x-text="formatTokens(m.total_input_tokens)"></td>
|
|
<td x-text="formatTokens(m.total_output_tokens)"></td>
|
|
<td x-text="formatCost(m.total_cost_usd)"></td>
|
|
<td><div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden"><div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="{ width: barWidth(m) }"></div></div></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!byModel.length"><p>No model usage data yet.</p></div>
|
|
</div>
|
|
<div x-show="tab === 'by-agent'">
|
|
<div class="table-wrap mt-4" x-show="byAgent.length">
|
|
<table>
|
|
<thead><tr><th>Agent</th><th>Total Tokens</th><th>Tool Calls</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="a in byAgent" :key="a.agent_id">
|
|
<tr>
|
|
<td class="font-bold" x-text="a.name"></td>
|
|
<td x-text="a.total_tokens ? a.total_tokens.toLocaleString() : '0'"></td>
|
|
<td x-text="a.tool_calls || 0"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!byAgent.length"><p>No agent usage data yet.</p></div>
|
|
</div>
|
|
|
|
<!-- Costs Tab -->
|
|
<div x-show="tab === 'costs'">
|
|
|
|
<!-- Cost Summary Cards -->
|
|
<div class="stats-row mt-4">
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="formatCost(summary.total_cost_usd)"></div>
|
|
<div class="stat-label">Total Spend</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="formatCost(todayCost)"></div>
|
|
<div class="stat-label">Today's Spend</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="formatCost(projectedMonthlyCost())"></div>
|
|
<div class="stat-label">Projected Monthly</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="formatCost(avgCostPerMessage())"></div>
|
|
<div class="stat-label">Avg Cost / Message</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row: Donut + Bar side by side -->
|
|
<div class="cost-charts-row mt-4">
|
|
|
|
<!-- Donut Chart: Cost by Provider -->
|
|
<div class="card cost-chart-panel">
|
|
<div class="card-header">Cost by Provider</div>
|
|
<div x-show="costByProvider().length === 0" class="text-sm text-dim" style="padding:20px;text-align:center">No cost data yet.</div>
|
|
<div x-show="costByProvider().length > 0" class="donut-chart-wrap">
|
|
<div class="donut-chart">
|
|
<svg viewBox="0 0 160 160" width="160" height="160">
|
|
<g x-html="donutSegmentsSvg()"></g>
|
|
<!-- Center text -->
|
|
<text x="80" y="76" text-anchor="middle" fill="var(--text)" style="font-size:14px;font-weight:700;font-family:var(--font-mono)" x-text="formatCost(summary.total_cost_usd)"></text>
|
|
<text x="80" y="92" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">TOTAL</text>
|
|
</svg>
|
|
</div>
|
|
<div class="donut-legend">
|
|
<template x-for="(seg, idx) in donutSegments()" :key="'legend-' + seg.provider">
|
|
<div class="donut-legend-item">
|
|
<span class="donut-legend-swatch" :style="'background:' + seg.color"></span>
|
|
<span class="donut-legend-label" x-text="seg.provider"></span>
|
|
<span class="donut-legend-pct" x-text="seg.percent + '%'"></span>
|
|
<span class="donut-legend-cost text-dim" x-text="formatCost(seg.cost)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bar Chart: Daily Cost (last 7 days) -->
|
|
<div class="card cost-chart-panel">
|
|
<div class="card-header">Daily Cost (Last 7 Days)</div>
|
|
<div x-show="barChartData().length === 0" class="text-sm text-dim" style="padding:20px;text-align:center">No daily data yet.</div>
|
|
<div x-show="barChartData().length > 0" class="bar-chart">
|
|
<svg :viewBox="'0 0 ' + (barChartData().length * 50 + 20) + ' 180'" :width="barChartData().length * 50 + 20" height="180">
|
|
<!-- Baseline -->
|
|
<line x1="10" :x2="barChartData().length * 50 + 10" y1="150" y2="150" stroke="var(--border)" stroke-width="1"/>
|
|
<g x-html="barChartSvg()"></g>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cost by Model Table -->
|
|
<div class="card mt-4">
|
|
<div class="card-header">Cost by Model</div>
|
|
<div class="table-wrap" x-show="costByModelSorted().length" style="border:none;margin-top:8px">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Model</th>
|
|
<th>Provider</th>
|
|
<th>Tier</th>
|
|
<th>Input Tokens</th>
|
|
<th>Output Tokens</th>
|
|
<th>Calls</th>
|
|
<th>Cost</th>
|
|
<th style="width:20%">Cost Share</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="m in costByModelSorted()" :key="'cost-' + m.model">
|
|
<tr>
|
|
<td class="font-bold" style="font-size:11px" x-text="m.model"></td>
|
|
<td><span class="badge badge-muted" style="font-size:9px" x-text="_extractProvider(m.model)"></span></td>
|
|
<td>
|
|
<span class="tier-badge"
|
|
:class="'tier-' + modelTier(m.model)"
|
|
x-text="modelTier(m.model)"></span>
|
|
</td>
|
|
<td x-text="formatTokens(m.total_input_tokens)"></td>
|
|
<td x-text="formatTokens(m.total_output_tokens)"></td>
|
|
<td x-text="m.call_count"></td>
|
|
<td class="font-bold" x-text="formatCost(m.total_cost_usd)"></td>
|
|
<td>
|
|
<div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden">
|
|
<div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="{ width: costBarWidth(m) }"></div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div x-show="!costByModelSorted().length" class="text-sm text-dim" style="padding:20px;text-align:center">No model cost data yet.</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Sessions -->
|
|
<template x-if="page === 'sessions'">
|
|
<div x-data="sessionsPage">
|
|
<div class="page-header">
|
|
<h2>Sessions</h2>
|
|
<input class="form-input" style="width:200px" placeholder="Filter by agent..." x-model="searchFilter" x-show="tab === 'sessions'">
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tab" :class="{ active: tab === 'sessions' }" @click="tab = 'sessions'">Sessions</div>
|
|
<div class="tab" :class="{ active: tab === 'memory' }" @click="tab = 'memory'">Memory</div>
|
|
</div>
|
|
<div class="page-body" x-init="loadSessions()">
|
|
<!-- Sessions tab -->
|
|
<div x-show="tab === 'sessions'">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading sessions...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:4px">Conversation Sessions</div>
|
|
<div class="text-sm text-dim" style="line-height:1.6">
|
|
Each conversation with an agent creates a session. Sessions store the full message history so you can resume conversations later, or review past interactions.
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap" x-show="filteredSessions.length">
|
|
<table>
|
|
<thead><tr><th>Session</th><th>Agent</th><th>Messages</th><th>Created</th><th>Actions</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="s in filteredSessions" :key="s.session_id">
|
|
<tr>
|
|
<td class="text-xs truncate" style="font-family:monospace;max-width:120px" x-text="s.session_id ? s.session_id.substring(0, 8) + '...' : '-'" :title="s.session_id"></td>
|
|
<td class="font-bold" x-text="s.agent_name || s.agent_id"></td>
|
|
<td x-text="s.message_count"></td>
|
|
<td class="text-xs" x-text="s.created_at ? new Date(s.created_at).toLocaleString() : '-'"></td>
|
|
<td>
|
|
<button class="btn btn-primary btn-sm" @click="openInChat(s)">Chat</button>
|
|
<button class="btn btn-danger btn-sm" @click="deleteSession(s.session_id)">Delete</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!filteredSessions.length && !searchFilter">
|
|
<div class="empty-state-icon">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2-10 5 10 5 10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/></svg>
|
|
</div>
|
|
<h3>No sessions yet</h3>
|
|
<p>Sessions are created when you chat with agents. Start a conversation to see session history here.</p>
|
|
<button class="btn btn-primary" @click="location.hash='agents'">Start Chatting</button>
|
|
</div>
|
|
<div class="empty-state" x-show="!filteredSessions.length && searchFilter"><p>No sessions match your filter.</p></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Memory tab -->
|
|
<div x-show="tab === 'memory'">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<div class="info-card" style="flex:1;margin-bottom:0">
|
|
<h4>Agent Memory</h4>
|
|
<p>Each agent has its own key-value memory store. Agents use memory to persist preferences, notes, and context between conversations.</p>
|
|
</div>
|
|
<select class="form-select" style="width:200px;margin-left:16px" x-model="memAgentId" @change="loadKv()">
|
|
<option value="">Select agent...</option>
|
|
<template x-for="a in $store.app.agents" :key="a.id">
|
|
<option :value="a.id" x-text="a.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div x-show="memLoading" class="loading-state"><div class="spinner"></div><span>Loading memory...</span></div>
|
|
<div x-show="!memLoading && memLoadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="memLoadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadKv()">Retry</button>
|
|
</div>
|
|
<div x-show="memAgentId && !memLoading && !memLoadError">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<span class="text-sm text-dim" x-text="kvPairs.length + ' key(s)'"></span>
|
|
<button class="btn btn-primary btn-sm" @click="showAdd = true">+ Add Key</button>
|
|
</div>
|
|
<div class="table-wrap" x-show="kvPairs.length">
|
|
<table>
|
|
<thead><tr><th>Key</th><th>Value</th><th style="width:140px">Actions</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="kv in kvPairs" :key="kv.key">
|
|
<tr>
|
|
<td class="font-bold" style="white-space:nowrap" x-text="kv.key"></td>
|
|
<td>
|
|
<template x-if="editingKey !== kv.key">
|
|
<pre style="font-size:11px;max-width:400px;overflow:auto;white-space:pre-wrap;margin:0;color:var(--text-dim)" x-text="typeof kv.value === 'object' ? JSON.stringify(kv.value, null, 2) : String(kv.value)"></pre>
|
|
</template>
|
|
<template x-if="editingKey === kv.key">
|
|
<div>
|
|
<textarea class="form-textarea" x-model="editingValue" style="font-size:11px;min-height:60px;font-family:var(--font-mono)"></textarea>
|
|
<div class="flex gap-2 mt-2">
|
|
<button class="btn btn-primary btn-sm" @click="saveEdit()">Save</button>
|
|
<button class="btn btn-ghost btn-sm" @click="cancelEdit()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</td>
|
|
<td>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="startEdit(kv)" x-show="editingKey !== kv.key">Edit</button>
|
|
<button class="btn btn-danger btn-sm" @click="deleteKey(kv.key)" x-show="editingKey !== kv.key">Delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!kvPairs.length">
|
|
<h4>No keys stored</h4>
|
|
<p class="hint">This agent has no memory entries yet. Agents create memory entries automatically during conversations, or you can add them manually.</p>
|
|
<button class="btn btn-primary mt-4" @click="showAdd = true">+ Add First Key</button>
|
|
</div>
|
|
<template x-if="showAdd">
|
|
<div class="modal-overlay" @click.self="showAdd = false" @keydown.escape.window="showAdd = false">
|
|
<div class="modal">
|
|
<div class="modal-header"><h3>Add Key</h3><button class="modal-close" @click="showAdd = false"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
|
|
<div class="form-group"><label>Key</label><input class="form-input" x-model="newKey" placeholder="my_key"></div>
|
|
<div class="form-group"><label>Value (JSON)</label><textarea class="form-textarea" x-model="newValue" placeholder='"hello"'></textarea></div>
|
|
<button class="btn btn-primary btn-block" @click="addKey()">Save</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty-state" x-show="!memAgentId && !memLoading">
|
|
<h4>Select an Agent</h4>
|
|
<p class="hint">Choose an agent from the dropdown above to browse and edit its memory store.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Logs -->
|
|
<template x-if="page === 'logs'">
|
|
<div x-data="logsPage">
|
|
<div class="page-header">
|
|
<h2>Logs</h2>
|
|
<div class="flex gap-2 items-center flex-wrap" x-show="tab === 'live'">
|
|
<!-- Connection status indicator -->
|
|
<span class="live-indicator" :class="connectionClass">
|
|
<span class="live-dot"></span>
|
|
<span x-text="connectionLabel"></span>
|
|
</span>
|
|
<select class="form-select" style="width:100px" x-model="levelFilter">
|
|
<option value="">All</option>
|
|
<option value="info">INFO</option>
|
|
<option value="warn">WARN</option>
|
|
<option value="error">ERROR</option>
|
|
</select>
|
|
<input class="form-input" style="width:180px" placeholder="Search..." x-model="textFilter">
|
|
<button class="btn btn-ghost btn-sm" @click="togglePause()" x-text="streamPaused ? 'Resume' : 'Pause'"></button>
|
|
<button class="btn btn-ghost btn-sm" @click="clearLogs()">Clear</button>
|
|
<button class="btn btn-ghost btn-sm" @click="exportLogs()">Export</button>
|
|
<div class="toggle" :class="{ active: autoRefresh }" @click="autoRefresh = !autoRefresh" title="Auto-scroll"></div>
|
|
<span class="text-xs text-dim" x-text="autoRefresh ? 'Auto-scroll' : 'Scroll locked'"></span>
|
|
</div>
|
|
<div class="flex gap-2 items-center" x-show="tab === 'audit'">
|
|
<button class="btn btn-ghost btn-sm" @click="verifyChain()">Verify Chain</button>
|
|
<span class="badge" :class="chainValid === true ? 'badge-running' : chainValid === false ? 'badge-crashed' : ''" x-text="chainValid === true ? 'VALID' : chainValid === false ? 'BROKEN' : ''" x-show="chainValid !== null"></span>
|
|
</div>
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tab" :class="{ active: tab === 'live' }" @click="tab = 'live'">Live</div>
|
|
<div class="tab" :class="{ active: tab === 'audit' }" @click="tab = 'audit'; if (!auditEntries.length && !auditLoading) loadAudit()">Audit Trail</div>
|
|
</div>
|
|
<div class="page-body" x-init="startStreaming()">
|
|
<!-- Live logs tab -->
|
|
<div x-show="tab === 'live'">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Connecting to log stream...</span></div>
|
|
<div x-show="!loading && loadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="loadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !loadError">
|
|
<div class="card" style="font-family:monospace;max-height:70vh;overflow-y:auto" id="log-container" @mouseenter="hovering = true" @mouseleave="hovering = false">
|
|
<template x-for="entry in filteredEntries" :key="entry.seq">
|
|
<div class="log-entry">
|
|
<span class="log-timestamp" x-text="new Date(entry.timestamp).toLocaleTimeString()"></span>
|
|
<span class="log-level" :class="'log-level-' + classifyLevel(entry.action)" x-text="classifyLevel(entry.action).toUpperCase()"></span>
|
|
<span class="text-xs" style="color:var(--text-dim);margin-right:6px" x-text="'[' + entry.action + ']'"></span>
|
|
<span class="text-xs" x-text="entry.detail"></span>
|
|
</div>
|
|
</template>
|
|
<div class="empty-state" x-show="!filteredEntries.length" style="padding:20px">
|
|
<h4>No log entries yet</h4>
|
|
<p class="hint">Activity will appear here as agents run.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit trail tab -->
|
|
<div x-show="tab === 'audit'">
|
|
<div x-show="auditLoading" class="loading-state"><div class="spinner"></div><span>Loading audit log...</span></div>
|
|
<div x-show="!auditLoading && auditLoadError" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="auditLoadError"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadAudit()">Retry</button>
|
|
</div>
|
|
<div x-show="!auditLoading && !auditLoadError">
|
|
<div class="card mb-4" style="border-left:3px solid var(--accent)">
|
|
<div class="font-bold" style="font-size:13px;margin-bottom:4px">Tamper-Evident Audit Trail</div>
|
|
<div class="text-sm text-dim" style="line-height:1.6">
|
|
Every agent action is logged with a cryptographic hash chain. Use "Verify Chain" to confirm no entries have been altered or deleted.
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mb-4 items-center">
|
|
<select class="form-select" style="width:180px" x-model="filterAction">
|
|
<option value="">All Actions</option>
|
|
<option value="AgentSpawn">Agent Created</option>
|
|
<option value="AgentKill">Agent Stopped</option>
|
|
<option value="AgentMessage">Message</option>
|
|
<option value="ToolInvoke">Tool Used</option>
|
|
<option value="NetworkAccess">Network Access</option>
|
|
<option value="ShellExec">Shell Command</option>
|
|
<option value="FileAccess">File Access</option>
|
|
<option value="MemoryAccess">Memory Access</option>
|
|
<option value="AuthAttempt">Login Attempt</option>
|
|
</select>
|
|
<span class="text-sm text-dim" x-text="filteredAuditEntries.length + ' of ' + auditEntries.length + ' entries'"></span>
|
|
<span class="text-xs text-dim" x-show="tipHash" x-text="'tip: ' + tipHash.substring(0, 16) + '...'"></span>
|
|
</div>
|
|
<div class="table-wrap" x-show="filteredAuditEntries.length">
|
|
<table>
|
|
<thead><tr><th>#</th><th>Timestamp</th><th>Agent</th><th>Action</th><th>Detail</th><th>Outcome</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="e in filteredAuditEntries" :key="e.seq">
|
|
<tr>
|
|
<td x-text="e.seq"></td>
|
|
<td class="text-xs" style="white-space:nowrap" x-text="new Date(e.timestamp).toLocaleString()"></td>
|
|
<td class="truncate" style="max-width:120px" x-text="auditAgentName(e.agent_id)" :title="e.agent_id"></td>
|
|
<td><span class="badge badge-created" x-text="friendlyAction(e.action)"></span></td>
|
|
<td class="truncate" style="max-width:200px" x-text="e.detail" :title="e.detail"></td>
|
|
<td x-text="e.outcome"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="empty-state" x-show="!auditEntries.length">
|
|
<h4>No audit entries yet</h4>
|
|
<p class="hint">Activity will appear here as agents operate.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Comms -->
|
|
<template x-if="page === 'comms'">
|
|
<div x-data="commsPage" x-init="loadData()" @page-leave.window="stopSSE()">
|
|
<div class="page-header">
|
|
<h2>Agent Comms</h2>
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-primary btn-sm" @click="openSendModal()">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
|
|
Send Message
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="openTaskModal()">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
|
|
Post Task
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()" title="Refresh">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="page-body">
|
|
<!-- Loading -->
|
|
<div x-show="loading" style="animation:fadeIn 0.2s">
|
|
<div class="card mb-4"><div class="skeleton skeleton-text" style="width:160px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:120px"></div></div>
|
|
<div class="card"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:200px"></div></div>
|
|
</div>
|
|
<!-- Error -->
|
|
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
|
|
<h3 style="color:var(--error)">Connection Error</h3>
|
|
<p class="text-xs text-dim" x-text="loadError"></p>
|
|
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
|
|
</div>
|
|
<!-- Content -->
|
|
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
|
|
<!-- Topology -->
|
|
<div class="card mb-4">
|
|
<div class="card-header">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
|
|
Agent Topology
|
|
<span class="badge badge-dim" style="margin-left:8px;font-weight:400" x-text="topology.nodes.length + ' agents'"></span>
|
|
</div>
|
|
<div style="padding:8px 0;font-family:var(--font-mono);font-size:12px;line-height:1.8">
|
|
<template x-if="topology.nodes.length === 0">
|
|
<div class="text-dim" style="text-align:center;padding:24px">No agents running</div>
|
|
</template>
|
|
<template x-for="root in rootNodes()" :key="root.id">
|
|
<div class="comms-topo-tree">
|
|
<div class="comms-topo-node" :title="root.id">
|
|
<span :class="stateBadgeClass(root.state)" style="font-size:10px;padding:1px 6px" x-text="root.state"></span>
|
|
<strong x-text="root.name" style="margin:0 4px"></strong>
|
|
<span class="text-dim" x-text="root.model"></span>
|
|
<template x-for="peer in peersOf(root.id)" :key="peer.id">
|
|
<span class="text-dim" style="margin-left:8px" x-text="'\u2194 ' + peer.name"></span>
|
|
</template>
|
|
</div>
|
|
<template x-for="(child, ci) in childrenOf(root.id)" :key="child.id">
|
|
<div class="comms-topo-child">
|
|
<span class="comms-topo-branch" x-text="ci < childrenOf(root.id).length - 1 ? '\u251c\u2500\u2500 ' : '\u2514\u2500\u2500 '"></span>
|
|
<span :class="stateBadgeClass(child.state)" style="font-size:10px;padding:1px 6px" x-text="child.state"></span>
|
|
<strong x-text="child.name" style="margin:0 4px"></strong>
|
|
<span class="text-dim" x-text="child.model"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Live Event Feed -->
|
|
<div class="card">
|
|
<div class="card-header flex justify-between items-center">
|
|
<div>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
|
Live Event Feed
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="badge badge-success" style="font-size:9px;padding:2px 6px;animation:pulse-ring 2s infinite">LIVE</span>
|
|
<span class="text-xs text-dim" x-text="events.length + ' events'"></span>
|
|
</div>
|
|
</div>
|
|
<div style="max-height:400px;overflow-y:auto">
|
|
<template x-if="events.length === 0">
|
|
<div class="text-dim" style="text-align:center;padding:24px">No inter-agent events yet</div>
|
|
</template>
|
|
<template x-for="ev in events" :key="ev.id">
|
|
<div class="comms-event-row">
|
|
<span class="comms-event-time text-xs text-dim" x-text="timeAgo(ev.timestamp)"></span>
|
|
<span :class="eventBadgeClass(ev.kind)" style="font-size:10px;padding:1px 6px;min-width:70px;text-align:center" x-text="eventLabel(ev.kind)"></span>
|
|
<span style="font-weight:600;font-size:12px" x-text="ev.source_name"></span>
|
|
<span class="text-dim" x-show="ev.target_name" x-text="'\u2192 ' + ev.target_name"></span>
|
|
<span class="comms-event-detail text-dim text-xs" x-text="ev.detail" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Send Message Modal -->
|
|
<div style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" :style="{ display: showSendModal ? 'flex' : 'none' }" x-transition>
|
|
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
|
<div class="card-header">Send Agent Message</div>
|
|
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
|
<div>
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">From Agent</label>
|
|
<select x-model="sendFrom" class="input" style="width:100%">
|
|
<option value="">Select agent...</option>
|
|
<template x-for="n in topology.nodes" :key="n.id">
|
|
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">To Agent</label>
|
|
<select x-model="sendTo" class="input" style="width:100%">
|
|
<option value="">Select agent...</option>
|
|
<template x-for="n in topology.nodes" :key="n.id">
|
|
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Message</label>
|
|
<textarea x-model="sendMsg" class="input" rows="3" placeholder="Type a message..." style="width:100%;resize:vertical"></textarea>
|
|
</div>
|
|
<div class="flex gap-2" style="justify-content:flex-end">
|
|
<button class="btn btn-ghost btn-sm" @click="showSendModal=false">Cancel</button>
|
|
<button class="btn btn-primary btn-sm" @click="submitSend()" :disabled="sendLoading || !sendFrom || !sendTo || !sendMsg.trim()">
|
|
<span x-show="sendLoading">Sending...</span>
|
|
<span x-show="!sendLoading">Send</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Post Task Modal -->
|
|
<div style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" :style="{ display: showTaskModal ? 'flex' : 'none' }" @click.self="showTaskModal=false" x-transition>
|
|
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
|
<div class="card-header">Post Task</div>
|
|
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
|
<div>
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Title</label>
|
|
<input type="text" x-model="taskTitle" class="input" placeholder="Task title..." style="width:100%">
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Description</label>
|
|
<textarea x-model="taskDesc" class="input" rows="3" placeholder="Task description..." style="width:100%;resize:vertical"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Assign To (optional)</label>
|
|
<select x-model="taskAssign" class="input" style="width:100%">
|
|
<option value="">Unassigned</option>
|
|
<template x-for="n in topology.nodes" :key="n.id">
|
|
<option :value="n.id" x-text="n.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="flex gap-2" style="justify-content:flex-end">
|
|
<button class="btn btn-ghost btn-sm" @click="showTaskModal=false">Cancel</button>
|
|
<button class="btn btn-primary btn-sm" @click="submitTask()" :disabled="taskLoading || !taskTitle.trim()">
|
|
<span x-show="taskLoading">Posting...</span>
|
|
<span x-show="!taskLoading">Post Task</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Page: Setup Wizard -->
|
|
<template x-if="page === 'wizard'">
|
|
<div x-data="wizardPage">
|
|
<div class="page-header">
|
|
<h2>Setup Wizard</h2>
|
|
<button class="btn btn-ghost btn-sm" @click="finishAndDismiss()">Skip Setup</button>
|
|
</div>
|
|
<div class="page-body" x-init="loadData()">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading...</span></div>
|
|
<div x-show="!loading && error" class="error-state">
|
|
<span class="error-icon">!</span>
|
|
<p x-text="error"></p>
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Retry</button>
|
|
</div>
|
|
<div x-show="!loading && !error">
|
|
|
|
<!-- Progress bar -->
|
|
<div class="wizard-progress">
|
|
<template x-for="n in totalSteps" :key="n">
|
|
<div class="wizard-progress-step" :class="{ 'wiz-active': step === n, 'wiz-done': step > n }" @click="goToStep(n)">
|
|
<div class="wizard-progress-circle">
|
|
<span x-show="step <= n" x-text="n"></span>
|
|
<span x-show="step > n">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="display:block"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</span>
|
|
</div>
|
|
<span class="wizard-progress-label" x-text="stepLabel(n)"></span>
|
|
</div>
|
|
</template>
|
|
<div class="wizard-progress-line">
|
|
<div class="wizard-progress-line-fill" :style="'width:' + ((step - 1) / (totalSteps - 1) * 100) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 1: Welcome -->
|
|
<div class="wizard-step" x-show="step === 1">
|
|
<div class="wizard-card" style="text-align:center;max-width:600px;margin:0 auto">
|
|
<img src="/logo.png" alt="OpenFang" style="width:80px;height:80px;margin:0 auto 20px;display:block;opacity:0.85">
|
|
<h3 style="font-size:22px;font-weight:700;margin-bottom:12px;color:var(--accent)">Welcome to OpenFang</h3>
|
|
<p style="font-size:13px;color:var(--text-dim);line-height:1.8;max-width:480px;margin:0 auto 24px">
|
|
OpenFang is an open-source Agent Operating System. It lets you run AI agents that can chat, use tools, access memory, and connect to messaging channels — all from a single dashboard.
|
|
</p>
|
|
<div class="card" style="text-align:left;margin-bottom:20px">
|
|
<div class="card-header">This wizard will help you:</div>
|
|
<div style="margin-top:8px">
|
|
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)">
|
|
<span class="badge badge-info" style="min-width:20px;justify-content:center">1</span>
|
|
<span style="font-size:12px">Connect an LLM provider (Anthropic, OpenAI, Gemini, etc.)</span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)">
|
|
<span class="badge badge-info" style="min-width:20px;justify-content:center">2</span>
|
|
<span style="font-size:12px">Create your first AI agent from 10 templates</span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)">
|
|
<span class="badge badge-info" style="min-width:20px;justify-content:center">3</span>
|
|
<span style="font-size:12px">Try it out with a quick test message</span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px;padding:8px 0">
|
|
<span class="badge badge-info" style="min-width:20px;justify-content:center">4</span>
|
|
<span style="font-size:12px">Optionally connect a messaging channel (Telegram, Discord, Slack)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p style="font-size:11px;color:var(--text-muted)">Takes about 2 minutes. You can skip any step and configure later.</p>
|
|
</div>
|
|
<div class="wizard-nav">
|
|
<div></div>
|
|
<button class="btn btn-primary" @click="nextStep()">Get Started</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Provider Setup -->
|
|
<div class="wizard-step" x-show="step === 2">
|
|
<div class="wizard-card">
|
|
<h3 style="font-size:16px;font-weight:700;margin-bottom:4px">Connect an LLM Provider</h3>
|
|
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;line-height:1.6">
|
|
OpenFang needs at least one LLM provider to power your agents. Select a provider and enter your API key.
|
|
</p>
|
|
|
|
<div class="info-card" x-show="hasConfiguredProvider" style="border-left-color:var(--success)">
|
|
<h4 style="color:var(--success)">Provider Already Configured</h4>
|
|
<p>You already have at least one provider set up. You can continue to the next step or configure additional providers.</p>
|
|
</div>
|
|
|
|
<div style="margin-bottom:16px">
|
|
<div class="text-xs font-bold text-dim mb-2" style="text-transform:uppercase;letter-spacing:0.5px">Popular Providers</div>
|
|
<div class="card-grid" style="grid-template-columns:repeat(auto-fill, minmax(200px, 1fr))">
|
|
<template x-for="p in popularProviders" :key="p.id">
|
|
<div class="card wizard-provider-card" :class="{ 'wizard-provider-selected': selectedProvider === p.id, 'provider-card configured': providerIsConfigured(p) }" @click="selectProvider(p.id)" style="cursor:pointer;padding:12px">
|
|
<div class="flex justify-between items-center">
|
|
<span class="font-bold" style="font-size:13px" x-text="p.display_name"></span>
|
|
<span class="badge badge-success" x-show="providerIsConfigured(p)" style="font-size:8px">READY</span>
|
|
</div>
|
|
<div class="text-xs text-dim mt-1" x-text="(p.model_count || 0) + ' models'"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom:16px" x-show="otherProviders.length">
|
|
<div class="text-xs font-bold text-dim mb-2" style="text-transform:uppercase;letter-spacing:0.5px">Other Providers</div>
|
|
<div class="card-grid" style="grid-template-columns:repeat(auto-fill, minmax(200px, 1fr))">
|
|
<template x-for="p in otherProviders" :key="p.id">
|
|
<div class="card wizard-provider-card" :class="{ 'wizard-provider-selected': selectedProvider === p.id, 'provider-card configured': providerIsConfigured(p) }" @click="selectProvider(p.id)" style="cursor:pointer;padding:12px">
|
|
<div class="flex justify-between items-center">
|
|
<span class="font-bold" style="font-size:13px" x-text="p.display_name"></span>
|
|
<span class="badge badge-success" x-show="providerIsConfigured(p)" style="font-size:8px">READY</span>
|
|
</div>
|
|
<div class="text-xs text-dim mt-1" x-text="(p.model_count || 0) + ' models'"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
|
|
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
|
<div class="card-header">Configure Claude Code</div>
|
|
<div class="text-xs text-dim mb-2" style="line-height:1.8">
|
|
Claude Code uses its own CLI authentication — no API key needed.
|
|
</div>
|
|
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
|
|
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
|
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
|
|
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
|
|
<span x-show="!testingProvider">Detect Claude Code</span>
|
|
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
<div x-show="testResult" class="mt-2">
|
|
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
|
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you’ve run: <code>npm install -g @anthropic-ai/claude-code && claude auth</code></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
|
|
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
|
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
|
|
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
|
|
Environment variable: <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px" x-text="selectedProviderObj.api_key_env"></code>
|
|
</div>
|
|
<div class="text-xs mb-3" x-show="providerHelp(selectedProvider)" style="color:var(--accent-light)">
|
|
<a :href="providerHelp(selectedProvider) ? providerHelp(selectedProvider).url : '#'" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:underline" x-text="providerHelp(selectedProvider) ? providerHelp(selectedProvider).text : ''"></a>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>API Key</label>
|
|
<div class="key-input-group">
|
|
<input type="password" :placeholder="'Enter your ' + selectedProviderObj.display_name + ' API key'" x-model="apiKeyInput" @keydown.enter="saveKey()">
|
|
<button class="btn btn-primary btn-sm" @click="saveKey()" :disabled="savingKey || !apiKeyInput.trim()">
|
|
<span x-show="!savingKey">Save & Test</span>
|
|
<span x-show="savingKey" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div x-show="testResult" class="mt-2">
|
|
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Connected successfully<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
|
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px"><span x-text="testResult ? (testResult.error || 'Connection failed') : ''"></span></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="selectedProviderObj && providerIsConfigured(selectedProviderObj)">
|
|
<div class="card" style="border-left:3px solid var(--success);margin-top:16px">
|
|
<div class="flex items-center gap-2">
|
|
<div style="color:var(--success);display:flex;align-items:center;justify-content:center;width:24px;height:24px">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</div>
|
|
<div>
|
|
<div class="font-bold" style="font-size:13px" x-text="selectedProviderObj.display_name + ' is configured and ready'"></div>
|
|
<div class="text-xs text-dim">You can test the connection or continue to the next step.</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mt-2">
|
|
<button class="btn btn-ghost btn-sm" @click="testKey()" :disabled="testingProvider">
|
|
<span x-show="!testingProvider">Test Connection</span>
|
|
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
<div x-show="testResult" class="mt-2">
|
|
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Connected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
|
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px"><span x-text="testResult ? (testResult.error || 'Connection failed') : ''"></span></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="wizard-nav">
|
|
<button class="btn btn-ghost" @click="prevStep()">Back</button>
|
|
<button class="btn btn-primary" @click="nextStep()" :disabled="!canGoNext"><span x-text="hasConfiguredProvider || keySaved ? 'Next' : 'Skip'"></span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Create First Agent -->
|
|
<div class="wizard-step" x-show="step === 3">
|
|
<div class="wizard-card">
|
|
<h3 style="font-size:16px;font-weight:700;margin-bottom:4px">Create Your First Agent</h3>
|
|
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;line-height:1.6">Pick a template to get started quickly. You can customize the agent later or create more from the Agents page.</p>
|
|
|
|
<!-- Category filter pills -->
|
|
<div class="wizard-category-pills">
|
|
<template x-for="cat in templateCategories" :key="cat">
|
|
<button class="wizard-category-pill" :class="{ active: templateCategory === cat }" @click="templateCategory = cat" x-text="cat"></button>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="card-grid" style="grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));margin-bottom:20px">
|
|
<template x-for="(tpl, i) in filteredTemplates" :key="tpl.id">
|
|
<div class="card wizard-template-card" :class="{ 'wizard-template-selected': selectedTemplate === templates.indexOf(tpl) }" @click="selectTemplate(templates.indexOf(tpl))" style="cursor:pointer">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="channel-icon" style="background:var(--accent);color:var(--bg-primary);font-weight:700" x-text="tpl.icon"></span>
|
|
<div>
|
|
<span class="font-bold" style="font-size:13px" x-text="tpl.name"></span>
|
|
<span class="category-badge" style="margin-left:6px" x-text="tpl.category"></span>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-dim" style="line-height:1.6" x-text="tpl.description"></div>
|
|
<div class="flex justify-between items-center mt-2">
|
|
<span class="text-xs" style="color:var(--text-muted)" x-text="tpl.provider + ' / ' + tpl.model"></span>
|
|
<span class="badge badge-muted" x-text="tpl.profile"></span>
|
|
</div>
|
|
<div class="text-xs mt-1 text-dim" x-show="profileInfo(tpl.profile).desc" x-text="profileInfo(tpl.profile).desc"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="card" style="border-left:3px solid var(--accent)">
|
|
<div class="form-group" style="margin-bottom:8px">
|
|
<label>Agent Name</label>
|
|
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
|
|
</div>
|
|
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
|
|
<div class="mt-2">
|
|
<button class="btn btn-primary" @click="createAgent()" :disabled="creatingAgent || !agentName.trim()">
|
|
<span x-show="!creatingAgent">Create Agent</span>
|
|
<span x-show="creatingAgent" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
<div x-show="createdAgent" class="mt-2">
|
|
<div class="badge badge-success" style="padding:6px 12px">Agent "<span x-text="createdAgent ? createdAgent.name : ''"></span>" created successfully</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="wizard-nav">
|
|
<button class="btn btn-ghost" @click="prevStep()">Back</button>
|
|
<button class="btn btn-primary" @click="nextStep()"><span x-text="createdAgent ? 'Next: Try It' : 'Skip'"></span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4: Try It (mini chat) -->
|
|
<div class="wizard-step" x-show="step === 4">
|
|
<div class="wizard-card">
|
|
<h3 style="font-size:16px;font-weight:700;margin-bottom:4px">Try Your Agent</h3>
|
|
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;line-height:1.6">
|
|
Send a quick message to test your new agent. Try one of the suggestions below or type your own.
|
|
</p>
|
|
|
|
<!-- Suggested message chips -->
|
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">
|
|
<template x-for="(s, si) in currentSuggestions" :key="si">
|
|
<button class="suggest-chip" @click="sendTryItMessage(s)" :disabled="tryItSending" x-text="s"></button>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Mini chat messages -->
|
|
<div class="tryit-messages" style="min-height:60px">
|
|
<template x-for="(msg, mi) in tryItMessages" :key="mi">
|
|
<div class="tryit-msg" :class="msg.role === 'user' ? 'tryit-msg-user' : 'tryit-msg-agent'" x-text="msg.text"></div>
|
|
</template>
|
|
<div x-show="tryItSending" class="tryit-msg tryit-msg-agent" style="opacity:0.5">Thinking...</div>
|
|
</div>
|
|
|
|
<!-- Input -->
|
|
<div style="display:flex;gap:8px;margin-top:12px">
|
|
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
|
|
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
|
|
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
|
|
</div>
|
|
</div>
|
|
<div class="wizard-nav">
|
|
<button class="btn btn-ghost" @click="prevStep()">Back</button>
|
|
<button class="btn btn-primary" @click="nextStep()">Continue</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 5: Channel Setup (Optional) -->
|
|
<div class="wizard-step" x-show="step === 5">
|
|
<div class="wizard-card">
|
|
<h3 style="font-size:16px;font-weight:700;margin-bottom:4px">Connect a Channel <span class="badge badge-muted">Optional</span></h3>
|
|
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px;line-height:1.6">Channels let your agent communicate via messaging platforms. This is optional — you can always use the built-in web chat.</p>
|
|
|
|
<div class="card-grid" style="grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));margin-bottom:16px">
|
|
<template x-for="ch in channelOptions" :key="ch.name">
|
|
<div class="card wizard-template-card" :class="{ 'wizard-template-selected': channelType === ch.name }" @click="selectChannel(ch.name)" style="cursor:pointer">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="channel-icon" x-text="ch.icon"></span>
|
|
<span class="font-bold" style="font-size:13px" x-text="ch.display_name"></span>
|
|
</div>
|
|
<div class="text-xs text-dim" style="line-height:1.6" x-text="ch.description"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<template x-if="selectedChannelObj">
|
|
<div class="card" style="border-left:3px solid var(--accent)">
|
|
<div class="card-header" x-text="'Configure ' + selectedChannelObj.display_name"></div>
|
|
<div class="text-xs text-dim mb-2" x-text="selectedChannelObj.help"></div>
|
|
<div class="form-group">
|
|
<label x-text="selectedChannelObj.token_label"></label>
|
|
<div class="key-input-group">
|
|
<input type="password" :placeholder="selectedChannelObj.token_placeholder" x-model="channelToken" @keydown.enter="configureChannel()">
|
|
<button class="btn btn-primary btn-sm" @click="configureChannel()" :disabled="configuringChannel || !channelToken.trim()">
|
|
<span x-show="!configuringChannel">Save</span>
|
|
<span x-show="configuringChannel" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-dim" x-text="'Or set ' + selectedChannelObj.token_env + ' in your environment'"></div>
|
|
<div x-show="channelConfigured" class="mt-2">
|
|
<div class="badge badge-success" style="padding:6px 12px"><span x-text="selectedChannelObj ? selectedChannelObj.display_name : ''"></span> configured and activated.</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="info-card" x-show="!channelType">
|
|
<p>You can skip this step. The built-in web chat is always available from the <strong>Agents</strong> page. Add channels any time from <strong>Settings → Channels</strong>.</p>
|
|
</div>
|
|
</div>
|
|
<div class="wizard-nav">
|
|
<button class="btn btn-ghost" @click="prevStep()">Back</button>
|
|
<button class="btn btn-primary" @click="nextStep()"><span x-text="channelConfigured ? 'Next' : 'Skip'"></span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 6: Done -->
|
|
<div class="wizard-step" x-show="step === 6">
|
|
<div class="wizard-card" style="text-align:center;max-width:560px;margin:0 auto">
|
|
<div style="margin-bottom:12px;color:var(--success);display:flex;justify-content:center">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</div>
|
|
<h3 style="font-size:20px;font-weight:700;margin-bottom:8px;color:var(--accent)">You're All Set!</h3>
|
|
<p style="font-size:13px;color:var(--text-dim);line-height:1.8;margin-bottom:24px">OpenFang is configured and ready to go. Here is a summary of what was set up:</p>
|
|
|
|
<div class="card" style="text-align:left;margin-bottom:20px">
|
|
<div class="detail-grid">
|
|
<div class="detail-row">
|
|
<span class="detail-label">LLM Provider</span>
|
|
<span class="detail-value">
|
|
<span x-show="setupSummary.provider" x-text="setupSummary.provider"></span>
|
|
<span x-show="!setupSummary.provider && hasConfiguredProvider" class="badge badge-success">Pre-configured</span>
|
|
<span x-show="!setupSummary.provider && !hasConfiguredProvider" class="badge badge-warn">Skipped</span>
|
|
</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">First Agent</span>
|
|
<span class="detail-value">
|
|
<span x-show="setupSummary.agent" x-text="setupSummary.agent"></span>
|
|
<span x-show="!setupSummary.agent" class="badge badge-warn">Skipped</span>
|
|
</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">Channel</span>
|
|
<span class="detail-value">
|
|
<span x-show="setupSummary.channel" x-text="setupSummary.channel"></span>
|
|
<span x-show="!setupSummary.channel" class="badge badge-muted">None (web chat available)</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="text-align:left;margin-bottom:20px">
|
|
<div class="card-header">Next Steps</div>
|
|
<div style="margin-top:8px;font-size:12px;color:var(--text-dim);line-height:1.8">
|
|
<div style="padding:4px 0" x-show="createdAgent">• Open <strong>Agents</strong> to start talking to your agent</div>
|
|
<div style="padding:4px 0" x-show="!createdAgent">• Go to <strong>Agents</strong> to create your first agent</div>
|
|
<div style="padding:4px 0">• Browse <strong>Skills</strong> to add capabilities (web search, code execution, etc.)</div>
|
|
<div style="padding:4px 0">• Check <strong>Settings</strong> for advanced configuration</div>
|
|
<div style="padding:4px 0" x-show="!setupSummary.channel">• Visit <strong>Channels</strong> to connect messaging platforms</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-2" style="justify-content:center">
|
|
<button class="btn btn-primary" @click="finish()" x-text="createdAgent ? 'Start Chatting' : 'Go to Dashboard'"></button>
|
|
<button class="btn btn-ghost" @click="prevStep()">Back</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ─── Runtime page ─── -->
|
|
<template x-if="page === 'runtime'">
|
|
<div x-data="runtimePage">
|
|
<div class="page-header"><h2>Runtime</h2></div>
|
|
<div class="page-body" x-init="loadData()">
|
|
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
|
|
<div x-show="!loading">
|
|
<div class="grid grid-cols-4 gap-4 mb-6">
|
|
<div class="card stat-card">
|
|
<div class="stat-label">Uptime</div>
|
|
<div class="stat-value" x-text="uptime"></div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-label">Agents</div>
|
|
<div class="stat-value" x-text="agentCount"></div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-label">Version</div>
|
|
<div class="stat-value" x-text="version"></div>
|
|
</div>
|
|
<div class="card stat-card">
|
|
<div class="stat-label">Default Model</div>
|
|
<div class="stat-value" style="font-size:13px" x-text="defaultModel"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div class="card-header">System</div>
|
|
<table class="table" style="margin-top:8px">
|
|
<tbody>
|
|
<tr><td style="width:180px;font-weight:500">Platform</td><td x-text="platform"></td></tr>
|
|
<tr><td style="font-weight:500">Architecture</td><td x-text="arch"></td></tr>
|
|
<tr><td style="font-weight:500">API Listen</td><td x-text="apiListen"></td></tr>
|
|
<tr><td style="font-weight:500">Home Directory</td><td x-text="homeDir"></td></tr>
|
|
<tr><td style="font-weight:500">Log Level</td><td x-text="logLevel"></td></tr>
|
|
<tr><td style="font-weight:500">Network</td><td x-text="networkEnabled ? 'Enabled' : 'Disabled'"></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card" style="margin-bottom:16px">
|
|
<div class="card-header">Providers</div>
|
|
<table class="table" style="margin-top:8px">
|
|
<thead><tr><th>Provider</th><th>Status</th><th>Models</th><th>Latency</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="p in providers" :key="p.id">
|
|
<tr>
|
|
<td x-text="p.display_name || p.id"></td>
|
|
<td><span class="badge" :class="p.reachable ? 'badge-success' : (p.auth_status === 'Configured' ? 'badge-success' : 'badge-dim')" x-text="p.reachable ? 'Online' : (p.auth_status === 'Configured' ? 'Ready' : 'Not configured')"></span></td>
|
|
<td x-text="p.model_count"></td>
|
|
<td x-text="p.latency_ms ? p.latency_ms + 'ms' : '-'"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-ghost btn-sm" @click="loadData()">Refresh</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Toast notification container -->
|
|
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
|
|
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|