Remove dead code: DevView, dev.css, unused formatUsage, gate viewer init
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d6337c1ece
commit
323bb0113f
@ -1,275 +0,0 @@
|
||||
.dev-view {
|
||||
padding: var(--space-inset) 0;
|
||||
}
|
||||
|
||||
.dev-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-page);
|
||||
}
|
||||
.dev-header h2 { margin-bottom: 0; }
|
||||
|
||||
.dev-view h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-page);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dev-section { margin-bottom: var(--space-page); }
|
||||
|
||||
.dev-section h3 {
|
||||
color: var(--text-dim);
|
||||
margin-bottom: var(--space-gap);
|
||||
}
|
||||
|
||||
.dev-actions { display: flex; gap: var(--space-gap); flex-wrap: wrap; }
|
||||
|
||||
/* Credits widget */
|
||||
.credits-widget {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius, 6px);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.credits-bar-track {
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.credits-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.credits-row {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
}
|
||||
.credits-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.credits-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted, var(--text-dim));
|
||||
}
|
||||
.credits-amount {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.credits-used { color: var(--error); }
|
||||
.credits-remaining { color: var(--success-dim, var(--accent)); }
|
||||
|
||||
/* Table */
|
||||
.dev-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.dev-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dev-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||
.dev-table tr:last-child td { border-bottom: none; }
|
||||
.dev-table .agent-id { font-weight: 600; }
|
||||
.dev-table code {
|
||||
background: var(--border);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Dev flags */
|
||||
.dev-flags { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.dev-flag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.dev-flag input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dev-flag span { font-family: var(--font-mono); }
|
||||
|
||||
.takeover-token {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.dev-loading { color: var(--text-dim); }
|
||||
.dev-error { color: var(--error); }
|
||||
|
||||
.dev-refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.dev-refresh-btn:hover { color: var(--text); border-color: var(--text-dim); }
|
||||
.dev-refresh-btn:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
.dev-disco-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dev-disco-btn:hover { background: var(--error)22; }
|
||||
.dev-disco-btn:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* Theme buttons */
|
||||
.dev-theme-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
padding: 6px 18px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dev-theme-btn:hover { color: var(--text); border-color: var(--text-dim); }
|
||||
.dev-theme-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Table horizontal scroll wrapper */
|
||||
.dev-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Breakout confirmation modal */
|
||||
.breakout-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.breakout-modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 32px;
|
||||
min-width: 300px;
|
||||
text-align: center;
|
||||
}
|
||||
.breakout-modal h3 { color: var(--text); margin-bottom: 12px; }
|
||||
.breakout-modal p { color: var(--text-dim); margin: 4px 0; }
|
||||
.breakout-nonce {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.15em;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.breakout-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ── MCP Counter ── */
|
||||
.counter-widget { transition: opacity 0.3s; }
|
||||
.counter-widget.muted { opacity: 0.35; }
|
||||
.counter-controls { display: flex; align-items: center; gap: 16px; }
|
||||
.counter-btn {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
border: 1px solid var(--text-dim); background: transparent;
|
||||
color: var(--text); font-size: 1.5rem; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.counter-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
.counter-btn:disabled { cursor: not-allowed; opacity: 0.3; }
|
||||
.counter-value {
|
||||
font-size: 2.5rem; font-variant-numeric: tabular-nums;
|
||||
min-width: 3ch; text-align: center; color: var(--text);
|
||||
}
|
||||
.counter-challenge {
|
||||
margin-top: 12px; display: flex; align-items: center; gap: 12px;
|
||||
animation: counter-pulse 1s ease-in-out infinite alternate;
|
||||
}
|
||||
.counter-message { color: var(--accent); font-weight: 600; font-size: 0.9rem; }
|
||||
.counter-timer {
|
||||
font-variant-numeric: tabular-nums; color: var(--text-dim);
|
||||
font-size: 0.85rem; min-width: 3ch;
|
||||
}
|
||||
.counter-hint { margin-top: 8px; font-size: 0.8rem; opacity: 0.4; }
|
||||
@keyframes counter-pulse {
|
||||
from { opacity: 0.7; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.counter-widget.flash {
|
||||
animation: counter-flash 0.5s ease-out 3;
|
||||
}
|
||||
@keyframes counter-flash {
|
||||
0% { box-shadow: 0 0 0 0 var(--accent); }
|
||||
50% { box-shadow: 0 0 20px 4px var(--accent); }
|
||||
100% { box-shadow: 0 0 0 0 var(--accent); }
|
||||
}
|
||||
|
||||
/* ── Confetti ── */
|
||||
.confetti-container {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 9999; overflow: hidden;
|
||||
}
|
||||
.confetti-piece {
|
||||
position: absolute; width: 10px; height: 10px; top: -20px;
|
||||
animation: confetti-fall 3s ease-in forwards;
|
||||
}
|
||||
@keyframes confetti-fall {
|
||||
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Action Picker ── */
|
||||
.action-picker { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.action-pick-btn {
|
||||
padding: 8px 16px; border-radius: 6px;
|
||||
border: 1px solid var(--accent); background: transparent;
|
||||
color: var(--accent); font-size: 0.85rem; cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.action-pick-btn:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
|
||||
.action-pick-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 639px) {
|
||||
.dev-theme-btn, .dev-disco-btn { min-height: 44px; }
|
||||
.dev-flag { min-height: 44px; }
|
||||
}
|
||||
@ -1,51 +1,4 @@
|
||||
import { computed, type Ref } from 'vue';
|
||||
|
||||
import type { Agent } from './agents'; // Import Agent interface
|
||||
|
||||
export function formatUsage(u: any, agentId: string | null = null, allAgents: Agent[] | null = null): string {
|
||||
if (!u) return '';
|
||||
const inn = u.input_tokens ?? u.in ?? null;
|
||||
const out = u.output_tokens ?? u.out ?? null;
|
||||
|
||||
let inCost = 0, outCost = 0, totalCost = 0;
|
||||
if (agentId && allAgents && inn !== null && out !== null) {
|
||||
const agent = allAgents.find(a => a.id === agentId);
|
||||
if (agent && agent.promptPrice !== null && agent.completionPrice !== null) {
|
||||
inCost = (inn / 1_000_000) * agent.promptPrice;
|
||||
outCost = (out / 1_000_000) * agent.completionPrice;
|
||||
totalCost = inCost + outCost;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to existing cost from backend if no agent pricing
|
||||
if (totalCost === 0) {
|
||||
totalCost = typeof u.cost === 'object' ? (u.cost?.total ?? 0) : Number(u.cost || 0);
|
||||
}
|
||||
|
||||
const showCosts = totalCost > 0.0000001;
|
||||
const inCostStr = showCosts && inCost > 0 ? `$${inCost.toFixed(4)}` : '';
|
||||
const outCostStr = showCosts && outCost > 0 ? `$${outCost.toFixed(4)}` : '';
|
||||
|
||||
if (inn !== null && out !== null) {
|
||||
const inFmt = inn >= 1000 ? (inn / 1000).toFixed(1) + 'k' : String(inn);
|
||||
const outFmt = out >= 1000 ? (out / 1000).toFixed(1) + 'k' : String(out);
|
||||
|
||||
let pricingStr = '';
|
||||
if (agentId && allAgents) {
|
||||
const agent = allAgents.find(a => a.id === agentId);
|
||||
if (agent && agent.promptPrice !== null && agent.completionPrice !== null) {
|
||||
pricingStr = ` (${agent.promptPrice.toFixed(2)}/${agent.completionPrice.toFixed(2)})`;
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [`${inFmt} in${inCostStr ? ` (${inCostStr})` : ''}`, `${outFmt} out${outCostStr ? ` (${outCostStr})` : ''}`];
|
||||
if (showCosts) parts.push(`$${totalCost.toFixed(4)}${pricingStr}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
const total = u.total_tokens ?? u.total ?? 0;
|
||||
return `${total} tokens${showCosts ? ` · $${totalCost.toFixed(4)}` : ''}`;
|
||||
}
|
||||
|
||||
import pkg from '../../package.json';
|
||||
|
||||
declare const __BUILD__: string;
|
||||
|
||||
@ -10,6 +10,7 @@ import { useWebSocket } from './composables/ws';
|
||||
import { useAgents } from './composables/agents';
|
||||
import { useAuth } from './composables/auth';
|
||||
import { useViewerStore } from './store/viewer';
|
||||
import tenant from './tenant';
|
||||
|
||||
// Viewer last-selected path — reactive singleton so App.vue nav link stays current
|
||||
export const lastViewerPath = ref(typeof localStorage !== 'undefined' ? localStorage.getItem('viewer_last_path') || '' : '');
|
||||
@ -31,8 +32,9 @@ agents.setCurrentUser(ws.currentUser);
|
||||
// Auth — on login/auto-connect, start HTTP transport
|
||||
export const auth = useAuth(() => {
|
||||
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
|
||||
// Pre-warm viewer token in background so agent→viewer is instant
|
||||
if (tenant.features.viewer) {
|
||||
useViewerStore().acquire();
|
||||
}
|
||||
});
|
||||
// Auto-connect if valid token exists from previous session (deferred so Pinia is ready)
|
||||
setTimeout(() => auth.tryAutoConnect(), 0);
|
||||
|
||||
@ -1,508 +0,0 @@
|
||||
<template>
|
||||
<OverlayScrollbarsComponent class="dev-view" :options="scrollbarOptions" element="div" v-if="isLoggedIn">
|
||||
<div class="page">
|
||||
<div class="dev-header">
|
||||
<h2>/dev</h2>
|
||||
<div class="dev-actions">
|
||||
<RouterLink to="/agents" class="dev-disco-btn"><HomeIcon class="w-4 h-4 inline" /> Home</RouterLink>
|
||||
<button class="dev-disco-btn" @click="disco" :disabled="discoing"><BoltIcon class="w-4 h-4 inline" /> Disconnect Gateway</button>
|
||||
<button class="dev-disco-btn" @click="discoChat" :disabled="discoChatting"><SignalSlashIcon class="w-4 h-4 inline" /> Disconnect Chat</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Theme -->
|
||||
<div class="dev-section">
|
||||
<h3>Theme</h3>
|
||||
<div class="dev-actions">
|
||||
<button
|
||||
class="dev-theme-btn"
|
||||
:class="{ active: theme === 'loop42' }"
|
||||
@click="setTheme('loop42')"
|
||||
><component :is="THEME_ICONS.loop42" class="w-4 h-4 inline" /> loop42</button>
|
||||
<button
|
||||
class="dev-theme-btn"
|
||||
:class="{ active: theme === 'titan' }"
|
||||
@click="setTheme('titan')"
|
||||
><component :is="THEME_ICONS.titan" class="w-4 h-4 inline" /> Titan</button>
|
||||
<button
|
||||
class="dev-theme-btn"
|
||||
:class="{ active: theme === 'eras' }"
|
||||
@click="setTheme('eras')"
|
||||
><component :is="THEME_ICONS.eras" class="w-4 h-4 inline" /> ERAS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dev Flags -->
|
||||
<div class="dev-section">
|
||||
<h3>Dev Flags</h3>
|
||||
<div class="dev-flags">
|
||||
<label class="dev-flag">
|
||||
<input type="checkbox" v-model="devFlags.showGrid" />
|
||||
<span>showGrid</span>
|
||||
</label>
|
||||
<label class="dev-flag">
|
||||
<input type="checkbox" v-model="devFlags.showDebugInfo" />
|
||||
<span>showDebugInfo</span>
|
||||
</label>
|
||||
<label class="dev-flag">
|
||||
<input type="checkbox" v-model="devFlags.showHud" />
|
||||
<span>showHud</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Takeover -->
|
||||
<div class="dev-section">
|
||||
<h3>Takeover</h3>
|
||||
<div class="dev-actions" v-if="!takeoverToken">
|
||||
<button class="dev-theme-btn" @click="handleInitTakeover"><LinkIcon class="w-4 h-4 inline" /> Enable Takeover</button>
|
||||
</div>
|
||||
<div v-else class="dev-actions" style="gap: var(--space-page); align-items: center; flex-wrap: wrap;">
|
||||
<code class="takeover-token">{{ takeoverToken }}</code>
|
||||
<button class="dev-theme-btn active" @click="handleRevoke"><XMarkIcon class="w-4 h-4 inline" /> Revoke</button>
|
||||
<button class="dev-theme-btn" :class="{ active: captureActive }" @click="handleToggleCapture">
|
||||
<CameraIcon class="w-4 h-4 inline" /> {{ captureActive ? 'Capture ON' : 'Enable Capture' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Access -->
|
||||
<div class="dev-section" v-if="systemRequests.length > 0 || systemGranted">
|
||||
<h3>System Access</h3>
|
||||
<div v-if="systemGranted" class="dev-system-granted">
|
||||
Access granted to {{ systemGranted.user }} — active until session end.
|
||||
</div>
|
||||
<div v-for="req in systemRequests" :key="req.requestId" class="dev-system-request">
|
||||
<div class="dev-system-code">{{ req.userCode }}</div>
|
||||
<div class="dev-system-desc">{{ req.description }}</div>
|
||||
<div class="dev-system-expiry">expires in {{ Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000)) }}s</div>
|
||||
<div class="dev-actions" style="margin-top: 8px;">
|
||||
<button class="dev-theme-btn" @click="denySystemRequest(req.requestId)">Deny</button>
|
||||
<button class="dev-theme-btn active" @click="approveSystemRequest(req.requestId)">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Counter -->
|
||||
<div class="dev-section">
|
||||
<h3>MCP Counter</h3>
|
||||
<div class="counter-widget" :class="{ muted: counterMuted }">
|
||||
<div class="counter-controls">
|
||||
<button class="counter-btn" @click="counterAction('decrement')" :disabled="counterMuted || counterBusy">−</button>
|
||||
<span class="counter-value" id="mcp-counter-value">{{ counterValue }}</span>
|
||||
<button class="counter-btn" @click="counterAction('increment')" :disabled="counterMuted || counterBusy">+</button>
|
||||
</div>
|
||||
<div v-if="challengeMessage" class="counter-challenge">
|
||||
<span class="counter-message">{{ challengeMessage }}</span>
|
||||
<span v-if="challengeTimer > 0" class="counter-timer">{{ challengeTimer }}s</span>
|
||||
</div>
|
||||
<div v-else class="counter-hint">Waiting for Claude...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Picker -->
|
||||
<div class="dev-section" v-if="actionPicker.title">
|
||||
<h3>{{ actionPicker.title }}</h3>
|
||||
<div class="action-picker">
|
||||
<button
|
||||
v-for="(opt, i) in actionPicker.options"
|
||||
:key="i"
|
||||
class="action-pick-btn"
|
||||
:disabled="actionPickerBusy"
|
||||
@click="pickAction(opt.id)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
<div class="dev-section">
|
||||
<h3>OpenRouter Credits</h3>
|
||||
<div v-if="loading && !stats" class="dev-loading">Loading…</div>
|
||||
<div v-else-if="statsError" class="dev-error">{{ statsError }}</div>
|
||||
<div v-else-if="stats" class="credits-widget">
|
||||
<div class="credits-bar-track">
|
||||
<div class="credits-bar-fill" :style="{ width: usedPct + '%' }"></div>
|
||||
</div>
|
||||
<div class="credits-row">
|
||||
<div class="credits-stat">
|
||||
<span class="credits-label">Used</span>
|
||||
<span class="credits-amount credits-used">${{ fmt(stats.credits.used) }}</span>
|
||||
</div>
|
||||
<div class="credits-stat">
|
||||
<span class="credits-label">Remaining</span>
|
||||
<span class="credits-amount credits-remaining">${{ fmt(stats.credits.remaining) }}</span>
|
||||
</div>
|
||||
<div class="credits-stat">
|
||||
<span class="credits-label">Total</span>
|
||||
<span class="credits-amount">${{ fmt(stats.credits.total) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents table -->
|
||||
<div class="dev-section" v-if="stats">
|
||||
<h3>Agents</h3>
|
||||
<div class="dev-table-wrap">
|
||||
<table class="dev-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Model</th>
|
||||
<th>Context</th>
|
||||
<th>Prompt / 1M</th>
|
||||
<th>Completion / 1M</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in stats.agents" :key="a.id">
|
||||
<td class="agent-id">{{ a.id }}</td>
|
||||
<td><code>{{ a.modelId || a.model }}</code></td>
|
||||
<td>{{ a.contextLength ? (a.contextLength / 1000).toFixed(0) + 'k' : '—' }}</td>
|
||||
<td>{{ a.promptPrice !== null ? '$' + a.promptPrice.toFixed(3) : '—' }}</td>
|
||||
<td>{{ a.completionPrice !== null ? '$' + a.completionPrice.toFixed(3) : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Breakout confirmation modal (inside v-if="isLoggedIn") -->
|
||||
<Teleport to="body">
|
||||
<div v-if="breakoutReq" class="breakout-modal-overlay" @click.self="denyBreakout">
|
||||
<div class="breakout-modal">
|
||||
<h3>Open Breakout</h3>
|
||||
<p>Name: <strong>{{ breakoutReq.name }}</strong></p>
|
||||
<p>Size: <strong>{{ breakoutReq.preset }}</strong></p>
|
||||
<p class="breakout-nonce">{{ breakoutReq.nonce }}</p>
|
||||
<div class="breakout-modal-actions">
|
||||
<button class="dev-theme-btn" @click="denyBreakout">Cancel</button>
|
||||
<button class="dev-theme-btn active" @click="confirmBreakout">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
<div v-else class="not-logged-in">
|
||||
<p><LockClosedIcon class="w-5 h-5 inline" /> Not logged in</p>
|
||||
<RouterLink to="/login">Sign in →</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DevView' });
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ws, auth, takeover } from '../store';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||
import { scrollbarOptions } from '../composables/useScrollbar';
|
||||
import { BoltIcon, SignalSlashIcon, LockClosedIcon, LinkIcon, XMarkIcon, CameraIcon, HomeIcon } from '@heroicons/vue/20/solid';
|
||||
import { THEME_ICONS } from '../composables/useTheme';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { useDevFlags } from '../composables/useDevFlags';
|
||||
import { getApiBase } from '../utils/apiBase';
|
||||
|
||||
const route = useRoute();
|
||||
const { isLoggedIn } = auth;
|
||||
const { connected, send: wsSend, onMessage } = ws;
|
||||
const { theme, setTheme } = useTheme();
|
||||
const devFlags = useDevFlags();
|
||||
|
||||
// ── Takeover (delegates to composables) ──
|
||||
const takeoverToken = takeover.token;
|
||||
const captureActive = takeover.capture.isActive;
|
||||
const breakoutReq = takeover.breakout.pendingRequest;
|
||||
|
||||
function handleInitTakeover() { takeover.init(); }
|
||||
function handleRevoke() { takeover.revoke(); }
|
||||
|
||||
// Breakout modal confirm/deny (for CLI-initiated openBreakout)
|
||||
function confirmBreakout() { breakoutReq.value?.resolve(true); }
|
||||
function denyBreakout() { breakoutReq.value?.resolve(false); }
|
||||
|
||||
// Capture toggle
|
||||
async function handleToggleCapture() {
|
||||
if (captureActive.value) {
|
||||
takeover.capture.disable();
|
||||
} else {
|
||||
await takeover.capture.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// ── System Access ──
|
||||
const SESSION_KEY = 'nyx_session';
|
||||
const SESSION_TOKEN = () => localStorage.getItem(SESSION_KEY) ?? '';
|
||||
|
||||
interface SystemRequest { requestId: string; userCode: string; description: string; expiresAt: number; }
|
||||
const systemRequests = ref<SystemRequest[]>([]);
|
||||
const systemGranted = ref<{ user: string } | null>(null);
|
||||
|
||||
async function fetchPendingSystemRequests() {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/system/pending`, {
|
||||
headers: { Authorization: `Bearer ${SESSION_TOKEN()}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const { pending } = await res.json();
|
||||
systemRequests.value = pending ?? [];
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function approveSystemRequest(requestId: string) {
|
||||
const res = await fetch(`${getApiBase()}/api/system/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionToken: SESSION_TOKEN(), requestId }),
|
||||
});
|
||||
if (res.ok) systemRequests.value = systemRequests.value.filter(r => r.requestId !== requestId);
|
||||
}
|
||||
|
||||
async function denySystemRequest(requestId: string) {
|
||||
await fetch(`${getApiBase()}/api/system/deny`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionToken: SESSION_TOKEN(), requestId }),
|
||||
});
|
||||
systemRequests.value = systemRequests.value.filter(r => r.requestId !== requestId);
|
||||
}
|
||||
|
||||
// ── MCP Counter ──
|
||||
const counterValue = ref(Math.floor(Math.random() * 11)); // 0..10
|
||||
const counterBusy = ref(false);
|
||||
const counterMuted = ref(true);
|
||||
const challengeMessage = ref('');
|
||||
const challengeTimer = ref(0);
|
||||
let challengeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let challengeGen = 0;
|
||||
|
||||
// ── Action Picker ──
|
||||
const actionPicker = ref<{ title: string; options: { id: string; label: string }[] }>({ title: '', options: [] });
|
||||
const actionPickerBusy = ref(false);
|
||||
async function pickAction(id: string) {
|
||||
actionPickerBusy.value = true;
|
||||
actionPicker.value = { title: '', options: [] };
|
||||
try {
|
||||
await fetch(`${getApiBase()}/api/dev/counter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SESSION_TOKEN()}` },
|
||||
body: JSON.stringify({ action: 'pick', pick: id }),
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
actionPickerBusy.value = false;
|
||||
}
|
||||
|
||||
async function counterAction(action: 'increment' | 'decrement') {
|
||||
counterMuted.value = true;
|
||||
challengeGen++;
|
||||
if (challengeInterval) { clearInterval(challengeInterval); challengeInterval = null; }
|
||||
challengeMessage.value = '';
|
||||
challengeTimer.value = 0;
|
||||
counterBusy.value = true;
|
||||
try {
|
||||
await fetch(`${getApiBase()}/api/dev/counter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SESSION_TOKEN()}` },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
counterBusy.value = false;
|
||||
}
|
||||
|
||||
// ── Stats ──
|
||||
const loading = ref(false);
|
||||
const statsError = ref('');
|
||||
const stats = ref<any>(null);
|
||||
|
||||
function fmt(v: number | null) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return v.toFixed(2);
|
||||
}
|
||||
|
||||
const usedPct = computed(() => {
|
||||
const t = stats.value?.credits?.total;
|
||||
const u = stats.value?.credits?.used;
|
||||
if (!t) return 0;
|
||||
return Math.min(100, (u / t) * 100);
|
||||
});
|
||||
|
||||
function load() {
|
||||
if (!connected.value) return;
|
||||
loading.value = true;
|
||||
statsError.value = '';
|
||||
wsSend({ type: 'stats_request' });
|
||||
}
|
||||
|
||||
const discoing = ref(false);
|
||||
const discoChatting = ref(false);
|
||||
|
||||
function disco() {
|
||||
if (!connected.value) return;
|
||||
discoing.value = true;
|
||||
wsSend({ type: 'disco_request' });
|
||||
setTimeout(() => { discoing.value = false; }, 2000);
|
||||
}
|
||||
|
||||
function discoChat() {
|
||||
if (!connected.value) return;
|
||||
discoChatting.value = true;
|
||||
wsSend({ type: 'disco_chat_request' });
|
||||
setTimeout(() => { discoChatting.value = false; }, 3000);
|
||||
}
|
||||
|
||||
// ── Polling lifecycle ──
|
||||
let unsubscribeWs: (() => void) | null = null;
|
||||
let refreshInterval: number | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
fetchPendingSystemRequests();
|
||||
|
||||
unsubscribeWs = onMessage((data: any) => {
|
||||
if (data.type === 'system_access_request') {
|
||||
if (!systemRequests.value.find(r => r.requestId === data.requestId))
|
||||
systemRequests.value.push(data);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'counter_update') {
|
||||
counterValue.value = data.value ?? counterValue.value;
|
||||
return;
|
||||
}
|
||||
if (data.type === 'counter_challenge') {
|
||||
challengeMessage.value = data.message || 'DECIDE NOW!';
|
||||
challengeTimer.value = data.timeout || 30;
|
||||
counterMuted.value = false;
|
||||
// Flash + scroll into view
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.counter-widget');
|
||||
if (el) {
|
||||
el.classList.remove('flash');
|
||||
void (el as HTMLElement).offsetWidth; // reflow
|
||||
el.classList.add('flash');
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
if (challengeInterval) clearInterval(challengeInterval);
|
||||
const cid = ++challengeGen;
|
||||
challengeInterval = setInterval(() => {
|
||||
if (cid !== challengeGen) { clearInterval(challengeInterval!); return; }
|
||||
challengeTimer.value--;
|
||||
if (challengeTimer.value <= 0) {
|
||||
if (challengeInterval) { clearInterval(challengeInterval); challengeInterval = null; }
|
||||
counterMuted.value = true;
|
||||
challengeMessage.value = '';
|
||||
fetch(`${getApiBase()}/api/dev/counter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SESSION_TOKEN()}` },
|
||||
body: JSON.stringify({ action: 'timeout' }),
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'confetti') {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'confetti-container';
|
||||
const colors = ['#ff6b6b','#ffd93d','#6bcb77','#4d96ff','#ff922b','#cc5de8'];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const piece = document.createElement('div');
|
||||
piece.className = 'confetti-piece';
|
||||
piece.style.left = Math.random() * 100 + '%';
|
||||
piece.style.background = colors[Math.floor(Math.random() * colors.length)];
|
||||
piece.style.animationDelay = Math.random() * 2 + 's';
|
||||
piece.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
|
||||
container.appendChild(piece);
|
||||
}
|
||||
document.body.appendChild(container);
|
||||
setTimeout(() => container.remove(), 5000);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'action_picker') {
|
||||
actionPicker.value = { title: data.title || 'Next?', options: data.options || [] };
|
||||
actionPickerBusy.value = false;
|
||||
return;
|
||||
}
|
||||
if (data.type === 'counter_mute') {
|
||||
counterMuted.value = true;
|
||||
challengeMessage.value = data.message || '';
|
||||
if (challengeInterval) { clearInterval(challengeInterval); challengeInterval = null; }
|
||||
challengeTimer.value = 0;
|
||||
return;
|
||||
}
|
||||
if (data.type === 'stats') {
|
||||
loading.value = false;
|
||||
if (data.error) { statsError.value = data.error; }
|
||||
else { stats.value = data; }
|
||||
}
|
||||
});
|
||||
|
||||
const startRefresh = () => {
|
||||
load();
|
||||
refreshInterval = setInterval(load, 15000) as unknown as number;
|
||||
};
|
||||
|
||||
if (connected.value) {
|
||||
startRefresh();
|
||||
} else {
|
||||
const stop = watch(connected, (val) => {
|
||||
if (val) { stop(); startRefresh(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => route.name, (name, prev) => {
|
||||
if (name === 'dev' && !refreshInterval && connected.value) {
|
||||
load();
|
||||
refreshInterval = setInterval(load, 15000) as unknown as number;
|
||||
} else if (prev === 'dev' && name !== 'dev' && refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeWs) unsubscribeWs();
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dev-system-request {
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dev-system-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.4em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dev-system-desc {
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.dev-system-expiry {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dev-system-granted {
|
||||
font-size: 0.9em;
|
||||
color: var(--color-success-dim, #4ade80);
|
||||
padding: 6px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user