feat: Add AgentBar arc dock with per-agent terminal frame and voice modal
- Add ui.json configs for Main (purple) and Ejecutor (red) agents - AgentBar: fused arc-shaped dock at bottom with dynamic glow - Quick press opens styled terminal frame mockup - Hold opens voice modal with Web Speech API streaming transcription - Responsive: full-width mobile, max-width on tablet/desktop/4K - Agents API: serve uiConfig from ui.json in agent directories - Agents page: route, store, toolbar integration
This commit is contained in:
720
frontend/src/components/AgentBar.vue
Normal file
720
frontend/src/components/AgentBar.vue
Normal file
@@ -0,0 +1,720 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
|
||||
// Web Speech API types
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
resultIndex: number
|
||||
results: SpeechRecognitionResultList
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||
onend: (() => void) | null
|
||||
start(): void
|
||||
stop(): void
|
||||
abort(): void
|
||||
}
|
||||
|
||||
interface UiConfig {
|
||||
label: string
|
||||
shortLabel: string
|
||||
color: string
|
||||
gradient: string
|
||||
terminalBg: string
|
||||
terminalBorder: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
directory: string
|
||||
uiConfig: UiConfig | null
|
||||
}
|
||||
|
||||
const agents = ref<Agent[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Terminal frame state
|
||||
const terminalAgent = ref<Agent | null>(null)
|
||||
const showTerminal = ref(false)
|
||||
|
||||
// Voice modal state
|
||||
const voiceAgent = ref<Agent | null>(null)
|
||||
const showVoice = ref(false)
|
||||
const isRecording = ref(false)
|
||||
const transcript = ref('')
|
||||
const interimTranscript = ref('')
|
||||
let recognition: SpeechRecognition | null = null
|
||||
|
||||
// Hold detection
|
||||
let holdTimer: number | null = null
|
||||
let holdTriggered = false
|
||||
const HOLD_THRESHOLD = 400
|
||||
|
||||
const enabledAgents = computed(() =>
|
||||
agents.value.filter(a => a.uiConfig?.enabled)
|
||||
)
|
||||
|
||||
// Dynamic glow based on agent colors
|
||||
const barGlowStyle = computed(() => {
|
||||
const list = enabledAgents.value
|
||||
if (!list.length) return {}
|
||||
const glows = list.map(a => {
|
||||
const c = a.uiConfig?.color || '#6366f1'
|
||||
return `0 -6px 30px ${c}50, 0 -2px 15px ${c}30`
|
||||
})
|
||||
return {
|
||||
boxShadow: `${glows.join(', ')}, 0 -10px 50px rgba(0,0,0,0.4)`
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
const res = await fetch('/api/agents')
|
||||
if (!res.ok) return
|
||||
const data: Agent[] = await res.json()
|
||||
agents.value = data
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Terminal Frame ---
|
||||
function openTerminal(agent: Agent) {
|
||||
terminalAgent.value = agent
|
||||
showTerminal.value = true
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
showTerminal.value = false
|
||||
terminalAgent.value = null
|
||||
}
|
||||
|
||||
// --- Voice Modal ---
|
||||
function openVoice(agent: Agent) {
|
||||
voiceAgent.value = agent
|
||||
showVoice.value = true
|
||||
startRecognition()
|
||||
}
|
||||
|
||||
function closeVoice() {
|
||||
stopRecognition()
|
||||
showVoice.value = false
|
||||
voiceAgent.value = null
|
||||
transcript.value = ''
|
||||
interimTranscript.value = ''
|
||||
}
|
||||
|
||||
function initSpeechRecognition(): SpeechRecognition | null {
|
||||
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (!SR) return null
|
||||
|
||||
const rec = new SR()
|
||||
rec.continuous = true
|
||||
rec.interimResults = true
|
||||
rec.lang = 'es-419'
|
||||
|
||||
rec.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let interim = ''
|
||||
let final = ''
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
if (!result || !result[0]) continue
|
||||
if (result.isFinal) {
|
||||
final += result[0].transcript + ' '
|
||||
} else {
|
||||
interim += result[0].transcript
|
||||
}
|
||||
}
|
||||
if (final) transcript.value += final
|
||||
interimTranscript.value = interim
|
||||
}
|
||||
|
||||
rec.onerror = () => {
|
||||
isRecording.value = false
|
||||
}
|
||||
|
||||
rec.onend = () => {
|
||||
if (isRecording.value) {
|
||||
try { rec.start() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
function startRecognition() {
|
||||
if (!recognition) recognition = initSpeechRecognition()
|
||||
if (!recognition) return
|
||||
transcript.value = ''
|
||||
interimTranscript.value = ''
|
||||
try {
|
||||
recognition.start()
|
||||
isRecording.value = true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function stopRecognition() {
|
||||
if (recognition) {
|
||||
isRecording.value = false
|
||||
try { recognition.stop() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pill interaction ---
|
||||
function handlePointerDown(agent: Agent) {
|
||||
holdTriggered = false
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTriggered = true
|
||||
openVoice(agent)
|
||||
}, HOLD_THRESHOLD)
|
||||
}
|
||||
|
||||
function handlePointerUp(agent: Agent) {
|
||||
if (holdTimer) {
|
||||
clearTimeout(holdTimer)
|
||||
holdTimer = null
|
||||
}
|
||||
if (!holdTriggered) {
|
||||
openTerminal(agent)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
if (holdTimer) {
|
||||
clearTimeout(holdTimer)
|
||||
holdTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
function onResize() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
stopRecognition()
|
||||
recognition = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Agent Bar — arc dock -->
|
||||
<div v-if="enabledAgents.length" class="agent-bar">
|
||||
<div class="agent-bar-inner" :style="barGlowStyle">
|
||||
<button
|
||||
v-for="agent in enabledAgents"
|
||||
:key="agent.id"
|
||||
class="agent-pill"
|
||||
:style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }"
|
||||
@pointerdown.prevent="handlePointerDown(agent)"
|
||||
@pointerup="handlePointerUp(agent)"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span class="pill-label">
|
||||
{{ isMobile ? agent.uiConfig?.shortLabel : agent.uiConfig?.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Frame Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="frame-fade">
|
||||
<div v-if="showTerminal && terminalAgent" class="terminal-overlay" @click.self="closeTerminal">
|
||||
<div
|
||||
class="terminal-frame"
|
||||
:style="{
|
||||
'--term-bg': terminalAgent.uiConfig?.terminalBg || '#0f0a1a',
|
||||
'--term-border': terminalAgent.uiConfig?.terminalBorder || '#6366f1',
|
||||
'--term-color': terminalAgent.uiConfig?.color || '#6366f1'
|
||||
}"
|
||||
>
|
||||
<div class="term-titlebar">
|
||||
<div class="term-title-left">
|
||||
<span class="term-dot red"></span>
|
||||
<span class="term-dot yellow"></span>
|
||||
<span class="term-dot green"></span>
|
||||
<span class="term-name">{{ terminalAgent.uiConfig?.label || terminalAgent.name }}</span>
|
||||
</div>
|
||||
<button class="term-close" @click="closeTerminal">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<div class="term-cursor-line">
|
||||
<span class="term-prompt" :style="{ color: terminalAgent.uiConfig?.color }">~$</span>
|
||||
<span class="term-cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Voice Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="voice-fade">
|
||||
<div v-if="showVoice && voiceAgent" class="voice-overlay" @click.self="closeVoice">
|
||||
<div
|
||||
class="voice-frame"
|
||||
:style="{
|
||||
'--voice-color': voiceAgent.uiConfig?.color || '#6366f1',
|
||||
'--voice-gradient': voiceAgent.uiConfig?.gradient || 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
'--voice-bg': voiceAgent.uiConfig?.terminalBg || '#0f0a1a'
|
||||
}"
|
||||
>
|
||||
<div class="voice-header">
|
||||
<div class="voice-agent-badge" :style="{ background: voiceAgent.uiConfig?.gradient }">
|
||||
{{ voiceAgent.uiConfig?.shortLabel }}
|
||||
</div>
|
||||
<span class="voice-title">Voice</span>
|
||||
<div class="voice-rec-indicator" :class="{ active: isRecording }"></div>
|
||||
<button class="voice-close" @click="closeVoice">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="voice-body">
|
||||
<div class="voice-transcript" :class="{ empty: !transcript && !interimTranscript }">
|
||||
<span class="voice-final">{{ transcript }}</span>
|
||||
<span class="voice-interim">{{ interimTranscript }}</span>
|
||||
<span v-if="!transcript && !interimTranscript" class="voice-placeholder">
|
||||
Listening...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ====================== AGENT BAR — ARC DOCK ====================== */
|
||||
.agent-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9990;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-bar-inner {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
/* Arc: wide elliptical curve at top, flat bottom */
|
||||
border-radius: 50% 50% 0 0 / 22px 22px 0 0;
|
||||
/* Top edge highlight */
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sheen line following the arc */
|
||||
.agent-bar-inner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
flex: 1;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.15s ease, brightness 0.15s ease;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
position: relative;
|
||||
/* Safe area for phones with gesture bar */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Divider between pills */
|
||||
.agent-pill + .agent-pill {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.agent-pill:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.agent-pill:active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ---- Responsive: Tablet (768+) ---- */
|
||||
@media (min-width: 768px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 480px;
|
||||
height: 56px;
|
||||
border-radius: 50% 50% 0 0 / 26px 26px 0 0;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Responsive: Desktop (1200+) ---- */
|
||||
@media (min-width: 1200px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 580px;
|
||||
height: 62px;
|
||||
border-radius: 50% 50% 0 0 / 30px 30px 0 0;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Responsive: 4K / TV (2400+) ---- */
|
||||
@media (min-width: 2400px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 740px;
|
||||
height: 76px;
|
||||
border-radius: 50% 50% 0 0 / 38px 38px 0 0;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================== TERMINAL FRAME ====================== */
|
||||
.terminal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
width: 640px;
|
||||
max-width: 92vw;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--term-border);
|
||||
box-shadow:
|
||||
0 0 30px color-mix(in srgb, var(--term-border) 30%, transparent),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.term-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: color-mix(in srgb, var(--term-bg) 80%, var(--term-border) 20%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.term-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.term-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.term-dot.red { background: #ff5f57; }
|
||||
.term-dot.yellow { background: #ffbd2e; }
|
||||
.term-dot.green { background: #28c840; }
|
||||
|
||||
.term-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-left: 4px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.term-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.term-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.term-body {
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
background: var(--term-bg);
|
||||
font-family: 'Consolas', 'Monaco', 'Cascadia Code', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.term-cursor-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.term-prompt {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.term-cursor {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ====================== VOICE MODAL ====================== */
|
||||
.voice-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.voice-frame {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--voice-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--voice-color) 40%, transparent);
|
||||
box-shadow:
|
||||
0 0 40px color-mix(in srgb, var(--voice-color) 25%, transparent),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--voice-bg) 70%, var(--voice-color) 30%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.voice-agent-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.voice-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.voice-rec-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-rec-indicator.active {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 8px #ef4444;
|
||||
animation: rec-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 8px #ef4444; }
|
||||
50% { opacity: 0.5; box-shadow: 0 0 16px #ef4444; }
|
||||
}
|
||||
|
||||
.voice-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.voice-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.voice-body {
|
||||
min-height: 160px;
|
||||
max-height: 300px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.voice-transcript {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.voice-transcript.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.voice-final {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.voice-interim {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.voice-placeholder {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
animation: placeholder-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes placeholder-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ====================== TRANSITIONS ====================== */
|
||||
.frame-fade-enter-active,
|
||||
.voice-fade-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.frame-fade-leave-active,
|
||||
.voice-fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.frame-fade-enter-from,
|
||||
.frame-fade-leave-to,
|
||||
.voice-fade-enter-from,
|
||||
.voice-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.frame-fade-enter-active .terminal-frame {
|
||||
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.frame-fade-leave-active .terminal-frame {
|
||||
animation: frame-scale-out 0.15s ease forwards;
|
||||
}
|
||||
|
||||
.voice-fade-enter-active .voice-frame {
|
||||
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.voice-fade-leave-active .voice-frame {
|
||||
animation: frame-scale-out 0.15s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes frame-scale-in {
|
||||
from { transform: scale(0.92); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes frame-scale-out {
|
||||
from { transform: scale(1); opacity: 1; }
|
||||
to { transform: scale(0.95); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
488
frontend/src/pages/AgentsPage.vue
Normal file
488
frontend/src/pages/AgentsPage.vue
Normal file
@@ -0,0 +1,488 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useAgentsStore, CATEGORY_META, formatSize } from '../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (store.isDirty) store.saveFile()
|
||||
}
|
||||
}
|
||||
|
||||
function shortPath(path: string): string {
|
||||
// Remove the agent directory prefix to show just the meaningful part
|
||||
const parts = path.split('/')
|
||||
if (parts.length <= 2) return parts[parts.length - 1]
|
||||
return parts.slice(1).join('/')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAgents()
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agents-page">
|
||||
<!-- Sidebar -->
|
||||
<aside class="agents-sidebar">
|
||||
<!-- Agent tabs -->
|
||||
<div class="agent-tabs">
|
||||
<button
|
||||
v-for="agent in store.agents"
|
||||
:key="agent.id"
|
||||
class="agent-tab"
|
||||
:class="{ active: store.selectedAgentId === agent.id }"
|
||||
@click="store.selectAgent(agent.id)"
|
||||
>
|
||||
<span class="agent-tab-name">{{ agent.name }}</span>
|
||||
<span class="agent-tab-count">{{ agent.files.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loading" class="sidebar-loading">Loading agents...</div>
|
||||
|
||||
<!-- Category groups -->
|
||||
<div v-else-if="store.selectedAgent" class="category-list">
|
||||
<div v-for="cat in store.groupedFiles" :key="cat.key" class="category-group">
|
||||
<button class="category-header" @click="store.toggleCategory(cat.key)">
|
||||
<svg
|
||||
class="category-icon"
|
||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
:style="{ color: cat.color }"
|
||||
>
|
||||
<path v-for="(d, i) in cat.icon.split('|')" :key="i" :d="d"/>
|
||||
</svg>
|
||||
<span class="category-label">{{ cat.label }}</span>
|
||||
<span class="category-count" :style="{ background: cat.color + '18', color: cat.color }">{{ cat.files.length }}</span>
|
||||
<svg
|
||||
class="category-chevron"
|
||||
:class="{ collapsed: store.collapsedCategories.has(cat.key) }"
|
||||
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="!store.collapsedCategories.has(cat.key)" class="category-files">
|
||||
<button
|
||||
v-for="file in cat.files"
|
||||
:key="file.path"
|
||||
class="file-row"
|
||||
:class="{ active: store.openFile?.path === file.path }"
|
||||
@click="store.loadFile(store.selectedAgentId!, file)"
|
||||
:title="file.path"
|
||||
>
|
||||
<span class="file-type-dot" :style="{ background: cat.color }"></span>
|
||||
<span class="file-label">{{ shortPath(file.path) }}</span>
|
||||
<span class="file-size">{{ formatSize(file.size) }}</span>
|
||||
<span v-if="store.openFile?.path === file.path && store.isDirty" class="unsaved-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.groupedFiles.length" class="sidebar-empty">No files found</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Editor -->
|
||||
<main class="editor-panel">
|
||||
<template v-if="store.openFile">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">
|
||||
<span
|
||||
class="editor-cat-dot"
|
||||
:style="{ background: CATEGORY_META[store.openFile.category]?.color }"
|
||||
></span>
|
||||
<span class="editor-path">{{ store.openFile.path }}</span>
|
||||
<span v-if="store.isDirty" class="unsaved-badge">Unsaved</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
:disabled="!store.isDirty"
|
||||
@click="store.revertFile()"
|
||||
>Revert</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!store.isDirty || store.saving"
|
||||
@click="store.saveFile()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="editor-error">{{ store.error }}</div>
|
||||
|
||||
<textarea
|
||||
class="editor-textarea"
|
||||
v-model="store.openFile.content"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</template>
|
||||
|
||||
<div v-else class="editor-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<p>Select a file to view or edit</p>
|
||||
<span class="editor-hint">Ctrl+S to save</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agents-page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.agents-sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Agent tabs */
|
||||
.agent-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0.75rem 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.agent-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.agent-tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--border-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-tab-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-tab-count {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-tab.active .agent-tab-count {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
/* Category list */
|
||||
.category-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.category-group {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.category-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.category-chevron {
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* File rows */
|
||||
.category-files {
|
||||
padding: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3125rem 0.875rem 0.3125rem 2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.file-row.active {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.file-type-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-row.active .file-type-dot {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unsaved-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #f59e0b;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-loading,
|
||||
.sidebar-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* ── Editor ── */
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-cat-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-path {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unsaved-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.editor-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
flex: 1;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor-empty p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-hint {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -53,6 +53,11 @@ const router = createRouter({
|
||||
path: '/git',
|
||||
name: 'git',
|
||||
component: () => import('../pages/GitPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/agents',
|
||||
name: 'agents',
|
||||
component: () => import('../pages/AgentsPage.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ import { setRouter } from './tools/handlers/globalHandlers'
|
||||
import { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
|
||||
import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions'
|
||||
|
||||
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'git'
|
||||
export type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'terminal' | 'tools' | 'git' | 'agents'
|
||||
|
||||
// Internal webmcp functions (not exported for external use)
|
||||
let webmcpInstance: any = null
|
||||
@@ -164,7 +164,8 @@ const pageCategories: Record<PageName, ToolCategory[]> = {
|
||||
source: ['global', 'torch', 'source', 'terminal'],
|
||||
terminal: ['global', 'torch', 'terminal'],
|
||||
tools: ['global', 'torch', 'terminal'],
|
||||
git: ['global', 'torch', 'git', 'terminal']
|
||||
git: ['global', 'torch', 'git', 'terminal'],
|
||||
agents: ['global', 'torch', 'terminal']
|
||||
}
|
||||
|
||||
let currentPage: PageName | null = null
|
||||
|
||||
@@ -41,7 +41,8 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
|
||||
database: 'Database - Explorador de base de datos',
|
||||
source: 'Source - Navegador de codigo fuente',
|
||||
terminal: 'Terminal - Consola de comandos',
|
||||
tools: 'Tools - Gestion de herramientas MCP'
|
||||
tools: 'Tools - Gestion de herramientas MCP',
|
||||
agents: 'Agents - Editor de configuracion de agentes Claude Code'
|
||||
}
|
||||
|
||||
const pageName = route.name as string || 'unknown'
|
||||
@@ -62,7 +63,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
|
||||
properties: {
|
||||
page: {
|
||||
type: 'string',
|
||||
enum: ['home', 'canvas', 'components', 'themes', 'database', 'source', 'terminal', 'tools'],
|
||||
enum: ['home', 'canvas', 'components', 'themes', 'database', 'source', 'terminal', 'tools', 'agents'],
|
||||
description: 'Pagina a la que navegar'
|
||||
}
|
||||
},
|
||||
@@ -81,7 +82,8 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
|
||||
database: '/database',
|
||||
source: '/source',
|
||||
terminal: '/terminal',
|
||||
tools: '/tools'
|
||||
tools: '/tools',
|
||||
agents: '/agents'
|
||||
}
|
||||
|
||||
const path = routes[args.page]
|
||||
|
||||
204
frontend/src/stores/agents.ts
Normal file
204
frontend/src/stores/agents.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
type FileCategory = 'config' | 'instructions' | 'plugins' | 'history' | 'debug' | 'cache' | 'sessions' | 'backups' | 'other'
|
||||
|
||||
interface AgentFile {
|
||||
name: string
|
||||
path: string
|
||||
type: 'json' | 'markdown' | 'text' | 'jsonl'
|
||||
category: FileCategory
|
||||
size: number
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
directory: string
|
||||
files: AgentFile[]
|
||||
}
|
||||
|
||||
interface OpenFile {
|
||||
path: string
|
||||
name: string
|
||||
type: string
|
||||
content: string
|
||||
originalContent: string
|
||||
agentId: string
|
||||
category: FileCategory
|
||||
}
|
||||
|
||||
export interface CategoryInfo {
|
||||
key: FileCategory
|
||||
label: string
|
||||
icon: string // SVG path
|
||||
color: string
|
||||
files: AgentFile[]
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<FileCategory, { label: string; icon: string; color: string; order: number }> = {
|
||||
config: { label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0-6 0', color: '#6366f1', order: 0 },
|
||||
instructions: { label: 'Instructions', icon: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20|M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z', color: '#3b82f6', order: 1 },
|
||||
plugins: { label: 'Plugins', icon: 'M12 2L2 7l10 5 10-5-10-5z|M2 17l10 5 10-5|M2 12l10 5 10-5', color: '#8b5cf6', order: 2 },
|
||||
history: { label: 'History', icon: 'M12 8v4l3 3|M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z', color: '#f59e0b', order: 3 },
|
||||
debug: { label: 'Debug logs', icon: 'M12 12m-1 0a1 1 0 1 0 2 0 1 1 0 0 0-2 0|M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z', color: '#ef4444', order: 4 },
|
||||
cache: { label: 'Cache', icon: 'M22 12H2|M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z', color: '#06b6d4', order: 5 },
|
||||
sessions: { label: 'Session data', icon: 'M4 17l6-6 4 4 6-6|M4 17h16', color: '#10b981', order: 6 },
|
||||
backups: { label: 'Backups', icon: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8|M21 3v5h-5', color: '#9ca3af', order: 7 },
|
||||
other: { label: 'Other files', icon: 'M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z|M14 2v6h6', color: '#78716c', order: 8 }
|
||||
}
|
||||
|
||||
export { CATEGORY_META }
|
||||
export type { FileCategory, AgentFile, Agent }
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export { formatSize }
|
||||
|
||||
export const useAgentsStore = defineStore('agents', () => {
|
||||
const agents = ref<Agent[]>([])
|
||||
const selectedAgentId = ref<string | null>(null)
|
||||
const openFile = ref<OpenFile | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const collapsedCategories = ref<Set<string>>(new Set())
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!openFile.value) return false
|
||||
return openFile.value.content !== openFile.value.originalContent
|
||||
})
|
||||
|
||||
const selectedAgent = computed(() => {
|
||||
if (!selectedAgentId.value) return null
|
||||
return agents.value.find(a => a.id === selectedAgentId.value) || null
|
||||
})
|
||||
|
||||
const groupedFiles = computed((): CategoryInfo[] => {
|
||||
const agent = selectedAgent.value
|
||||
if (!agent) return []
|
||||
|
||||
const groups = new Map<FileCategory, AgentFile[]>()
|
||||
for (const file of agent.files) {
|
||||
const cat = file.category
|
||||
if (!groups.has(cat)) groups.set(cat, [])
|
||||
groups.get(cat)!.push(file)
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.map(([key, files]) => ({
|
||||
key,
|
||||
label: CATEGORY_META[key].label,
|
||||
icon: CATEGORY_META[key].icon,
|
||||
color: CATEGORY_META[key].color,
|
||||
files
|
||||
}))
|
||||
.sort((a, b) => CATEGORY_META[a.key].order - CATEGORY_META[b.key].order)
|
||||
})
|
||||
|
||||
async function fetchAgents() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch('/api/agents')
|
||||
if (!res.ok) throw new Error('Failed to fetch agents')
|
||||
agents.value = await res.json()
|
||||
if (agents.value.length && !selectedAgentId.value) {
|
||||
selectedAgentId.value = agents.value[0].id
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectAgent(id: string) {
|
||||
selectedAgentId.value = id
|
||||
openFile.value = null
|
||||
}
|
||||
|
||||
async function loadFile(agentId: string, file: AgentFile) {
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch(`/api/agents/file?path=${encodeURIComponent(file.path)}`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to load file')
|
||||
}
|
||||
const data = await res.json()
|
||||
openFile.value = {
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
content: data.content,
|
||||
originalContent: data.content,
|
||||
agentId,
|
||||
category: file.category
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (!openFile.value || !isDirty.value) return
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch('/api/agents/file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: openFile.value.path,
|
||||
content: openFile.value.content
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Failed to save file')
|
||||
}
|
||||
openFile.value.originalContent = openFile.value.content
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function revertFile() {
|
||||
if (!openFile.value) return
|
||||
openFile.value.content = openFile.value.originalContent
|
||||
}
|
||||
|
||||
function toggleCategory(key: string) {
|
||||
if (collapsedCategories.value.has(key)) {
|
||||
collapsedCategories.value.delete(key)
|
||||
} else {
|
||||
collapsedCategories.value.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
selectedAgentId,
|
||||
selectedAgent,
|
||||
openFile,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
collapsedCategories,
|
||||
isDirty,
|
||||
groupedFiles,
|
||||
fetchAgents,
|
||||
selectAgent,
|
||||
loadFile,
|
||||
saveFile,
|
||||
revertFile,
|
||||
toggleCategory
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user