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:
@@ -5,6 +5,6 @@
|
|||||||
"repo": "anthropics/claude-plugins-official"
|
"repo": "anthropics/claude-plugins-official"
|
||||||
},
|
},
|
||||||
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
"installLocation": "C:\\Users\\jodar\\agent-ui\\.claude-ejecutor\\plugins\\marketplaces\\claude-plugins-official",
|
||||||
"lastUpdated": "2026-02-15T04:49:09.493Z"
|
"lastUpdated": "2026-02-15T08:27:07.485Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
.claude-ejecutor/ui.json
Normal file
9
.claude-ejecutor/ui.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"label": "Ejecutor",
|
||||||
|
"shortLabel": "EJ",
|
||||||
|
"color": "#ef4444",
|
||||||
|
"gradient": "linear-gradient(135deg, #ef4444, #dc2626)",
|
||||||
|
"terminalBg": "#0a0f1a",
|
||||||
|
"terminalBorder": "#ef4444",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
@@ -79,7 +79,10 @@
|
|||||||
"mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component",
|
"mcp__agent-ui__z590_nucleoriofrio_com-save_vue_component",
|
||||||
"mcp__agent-ui__z590_nucleoriofrio_com-resize_window",
|
"mcp__agent-ui__z590_nucleoriofrio_com-resize_window",
|
||||||
"mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot",
|
"mcp__agent-ui__z590_nucleoriofrio_com-save_canvas_snapshot",
|
||||||
"mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot"
|
"mcp__agent-ui__z590_nucleoriofrio_com-load_canvas_snapshot",
|
||||||
|
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvas_snapshots",
|
||||||
|
"mcp__agent-ui__z590_nucleoriofrio_com-list_canvases",
|
||||||
|
"mcp__agent-ui__z590_nucleoriofrio_com-list_vue_components"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": true,
|
"enableAllProjectMcpServers": true,
|
||||||
@@ -153,30 +156,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SubagentStart": [
|
|
||||||
{
|
|
||||||
"matcher": ".*",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"subagentStart\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
|
||||||
"timeout": 5000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"SubagentStop": [
|
|
||||||
{
|
|
||||||
"matcher": ".*",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "powershell -NoProfile -Command \"try { Invoke-RestMethod -Uri 'http://localhost:4101/api/claude-status' -Method POST -Body '{\\\"status\\\":\\\"subagentStop\\\"}' -ContentType 'application/json' -TimeoutSec 2 | Out-Null } catch {}\"",
|
|
||||||
"timeout": 5000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"PermissionRequest": [
|
"PermissionRequest": [
|
||||||
{
|
{
|
||||||
"matcher": ".*",
|
"matcher": ".*",
|
||||||
|
|||||||
9
.claude/ui.json
Normal file
9
.claude/ui.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"label": "Main",
|
||||||
|
"shortLabel": "M",
|
||||||
|
"color": "#6366f1",
|
||||||
|
"gradient": "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
|
"terminalBg": "#0f0a1a",
|
||||||
|
"terminalBorder": "#6366f1",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
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',
|
path: '/git',
|
||||||
name: 'git',
|
name: 'git',
|
||||||
component: () => import('../pages/GitPage.vue')
|
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 { setGiteaCredentials, clearGiteaCredentials } from './tools/handlers/sourceCodeHandlers'
|
||||||
import { ALL_TOOL_METAS, getAllToolNames, type ToolCategory } from './tools/toolDefinitions'
|
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)
|
// Internal webmcp functions (not exported for external use)
|
||||||
let webmcpInstance: any = null
|
let webmcpInstance: any = null
|
||||||
@@ -164,7 +164,8 @@ const pageCategories: Record<PageName, ToolCategory[]> = {
|
|||||||
source: ['global', 'torch', 'source', 'terminal'],
|
source: ['global', 'torch', 'source', 'terminal'],
|
||||||
terminal: ['global', 'torch', 'terminal'],
|
terminal: ['global', 'torch', 'terminal'],
|
||||||
tools: ['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
|
let currentPage: PageName | null = null
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
|
|||||||
database: 'Database - Explorador de base de datos',
|
database: 'Database - Explorador de base de datos',
|
||||||
source: 'Source - Navegador de codigo fuente',
|
source: 'Source - Navegador de codigo fuente',
|
||||||
terminal: 'Terminal - Consola de comandos',
|
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'
|
const pageName = route.name as string || 'unknown'
|
||||||
@@ -62,7 +63,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
|
|||||||
properties: {
|
properties: {
|
||||||
page: {
|
page: {
|
||||||
type: 'string',
|
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'
|
description: 'Pagina a la que navegar'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -81,7 +82,8 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
|
|||||||
database: '/database',
|
database: '/database',
|
||||||
source: '/source',
|
source: '/source',
|
||||||
terminal: '/terminal',
|
terminal: '/terminal',
|
||||||
tools: '/tools'
|
tools: '/tools',
|
||||||
|
agents: '/agents'
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = routes[args.page]
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
239
server/routes/agents.ts
Normal file
239
server/routes/agents.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||||
|
import { join, resolve, basename } from 'path'
|
||||||
|
|
||||||
|
const PROJECT_ROOT = resolve(import.meta.dir, '../..')
|
||||||
|
|
||||||
|
// Allowed config file patterns (for path validation on read/write)
|
||||||
|
const ALLOWED_PATTERNS = [
|
||||||
|
/^\.claude\/.*$/,
|
||||||
|
/^\.claude-[^/]+\/.*$/,
|
||||||
|
/^CLAUDE\.md$/,
|
||||||
|
/^\.mcp\.json$/
|
||||||
|
]
|
||||||
|
|
||||||
|
// Skip .git internals (hundreds of binary/pack files, useless for debugging)
|
||||||
|
const SKIP_DIRS = ['.git']
|
||||||
|
|
||||||
|
// Sensitive files blocked from read/write via API
|
||||||
|
const BLOCKED_FILES = ['.credentials.json']
|
||||||
|
|
||||||
|
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 UiConfig {
|
||||||
|
label: string
|
||||||
|
shortLabel: string
|
||||||
|
color: string
|
||||||
|
gradient: string
|
||||||
|
terminalBg: string
|
||||||
|
terminalBorder: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
directory: string
|
||||||
|
files: AgentFile[]
|
||||||
|
uiConfig: UiConfig | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileType(filename: string): AgentFile['type'] {
|
||||||
|
if (filename.endsWith('.jsonl')) return 'jsonl'
|
||||||
|
if (filename.endsWith('.json')) return 'json'
|
||||||
|
if (filename.endsWith('.md')) return 'markdown'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorizeFile(relPath: string, filename: string): FileCategory {
|
||||||
|
// Backups first (most specific)
|
||||||
|
if (filename.includes('.backup')) return 'backups'
|
||||||
|
|
||||||
|
// By directory
|
||||||
|
if (relPath.includes('/plugins/')) return 'plugins'
|
||||||
|
if (relPath.includes('/debug/')) return 'debug'
|
||||||
|
if (relPath.includes('/cache/')) return 'cache'
|
||||||
|
if (relPath.includes('/session-env/') || relPath.includes('/shell-snapshots/') || relPath.includes('/todos/') || relPath.includes('/projects/')) return 'sessions'
|
||||||
|
|
||||||
|
// By filename
|
||||||
|
if (filename === 'history.jsonl') return 'history'
|
||||||
|
if (filename.endsWith('.md')) return 'instructions'
|
||||||
|
if (filename === 'settings.json' || filename === '.claude.json' || filename === '.mcp.json') return 'config'
|
||||||
|
if (filename.endsWith('.json')) return 'config'
|
||||||
|
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlocked(filename: string): boolean {
|
||||||
|
return BLOCKED_FILES.includes(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedPath(relativePath: string): boolean {
|
||||||
|
const normalized = relativePath.replace(/\\/g, '/')
|
||||||
|
return ALLOWED_PATTERNS.some(p => p.test(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanDirectory(dir: string, relBase: string): AgentFile[] {
|
||||||
|
const files: AgentFile[] = []
|
||||||
|
if (!existsSync(dir)) return files
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry)
|
||||||
|
const relPath = `${relBase}/${entry}`
|
||||||
|
|
||||||
|
let stat
|
||||||
|
try { stat = statSync(fullPath) } catch { continue }
|
||||||
|
|
||||||
|
if (isBlocked(entry)) continue
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
files.push({
|
||||||
|
name: entry,
|
||||||
|
path: relPath,
|
||||||
|
type: getFileType(entry),
|
||||||
|
category: categorizeFile(relPath, entry),
|
||||||
|
size: stat.size
|
||||||
|
})
|
||||||
|
} else if (stat.isDirectory()) {
|
||||||
|
if (SKIP_DIRS.includes(entry)) continue
|
||||||
|
files.push(...scanDirectory(fullPath, relPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* permission errors */ }
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUiConfig(agentDir: string): UiConfig | null {
|
||||||
|
const uiPath = join(agentDir, 'ui.json')
|
||||||
|
if (!existsSync(uiPath)) return null
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(uiPath, 'utf-8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
label: data.label || '',
|
||||||
|
shortLabel: data.shortLabel || '',
|
||||||
|
color: data.color || '#6366f1',
|
||||||
|
gradient: data.gradient || '',
|
||||||
|
terminalBg: data.terminalBg || '#0f0a1a',
|
||||||
|
terminalBorder: data.terminalBorder || '#6366f1',
|
||||||
|
enabled: data.enabled !== false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverAgents(): Agent[] {
|
||||||
|
const agents: Agent[] = []
|
||||||
|
|
||||||
|
// Main agent (.claude/ + root files)
|
||||||
|
const claudeDir = join(PROJECT_ROOT, '.claude')
|
||||||
|
if (existsSync(claudeDir)) {
|
||||||
|
const mainAgent: Agent = {
|
||||||
|
id: 'main',
|
||||||
|
name: 'Claude Code (main)',
|
||||||
|
directory: '.claude',
|
||||||
|
files: scanDirectory(claudeDir, '.claude'),
|
||||||
|
uiConfig: readUiConfig(claudeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root CLAUDE.md
|
||||||
|
const claudeMd = join(PROJECT_ROOT, 'CLAUDE.md')
|
||||||
|
if (existsSync(claudeMd)) {
|
||||||
|
const stat = statSync(claudeMd)
|
||||||
|
mainAgent.files.unshift({
|
||||||
|
name: 'CLAUDE.md', path: 'CLAUDE.md', type: 'markdown',
|
||||||
|
category: 'instructions', size: stat.size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root .mcp.json
|
||||||
|
const mcpJson = join(PROJECT_ROOT, '.mcp.json')
|
||||||
|
if (existsSync(mcpJson)) {
|
||||||
|
const stat = statSync(mcpJson)
|
||||||
|
mainAgent.files.push({
|
||||||
|
name: '.mcp.json', path: '.mcp.json', type: 'json',
|
||||||
|
category: 'config', size: stat.size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
agents.push(mainAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other agents (.claude-*/)
|
||||||
|
try {
|
||||||
|
const rootEntries = readdirSync(PROJECT_ROOT)
|
||||||
|
for (const entry of rootEntries) {
|
||||||
|
if (!entry.startsWith('.claude-')) continue
|
||||||
|
const fullPath = join(PROJECT_ROOT, entry)
|
||||||
|
try { if (!statSync(fullPath).isDirectory()) continue } catch { continue }
|
||||||
|
|
||||||
|
const agentId = entry.replace('.claude-', '')
|
||||||
|
agents.push({
|
||||||
|
id: agentId,
|
||||||
|
name: agentId.charAt(0).toUpperCase() + agentId.slice(1),
|
||||||
|
directory: entry,
|
||||||
|
files: scanDirectory(fullPath, entry),
|
||||||
|
uiConfig: readUiConfig(fullPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
return agents
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgents(req: Request): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET') return null
|
||||||
|
return jsonResponse(discoverAgents())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAgentsFile(req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const filePath = url.searchParams.get('path')
|
||||||
|
if (!filePath) return errorResponse('Missing "path" query parameter')
|
||||||
|
|
||||||
|
const normalized = filePath.replace(/\\/g, '/')
|
||||||
|
if (!isAllowedPath(normalized)) return errorResponse('Access denied: path not allowed', 403)
|
||||||
|
if (isBlocked(basename(normalized))) return errorResponse('Access denied: sensitive file', 403)
|
||||||
|
|
||||||
|
const absolutePath = resolve(PROJECT_ROOT, normalized)
|
||||||
|
if (!absolutePath.startsWith(PROJECT_ROOT)) return errorResponse('Access denied: path traversal', 403)
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
if (!existsSync(absolutePath)) return errorResponse('File not found', 404)
|
||||||
|
try {
|
||||||
|
const content = await Bun.file(absolutePath).text()
|
||||||
|
return jsonResponse({ path: normalized, content })
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResponse(`Failed to read file: ${e.message}`, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
if (typeof body.content !== 'string') return errorResponse('Missing "content" field')
|
||||||
|
|
||||||
|
if (normalized.endsWith('.json')) {
|
||||||
|
try { JSON.parse(body.content) } catch { return errorResponse('Invalid JSON content') }
|
||||||
|
}
|
||||||
|
|
||||||
|
await Bun.write(absolutePath, body.content)
|
||||||
|
return jsonResponse({ success: true, path: normalized })
|
||||||
|
} catch (e: any) {
|
||||||
|
return errorResponse(`Failed to write file: ${e.message}`, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { handleRecordingsRoutes } from './recordings'
|
|||||||
import { handleClaudeStatus } from './claude-status'
|
import { handleClaudeStatus } from './claude-status'
|
||||||
import { handleSnapshots, handleSnapshotById } from './snapshots'
|
import { handleSnapshots, handleSnapshotById } from './snapshots'
|
||||||
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
|
import { handleGitStatus, handleGitDiff, handleGitLog, handleGitLogCommit, handleGitCompare, handleGitBranches, handleGitCurrentBranch, handleGitTree, handleGitFile } from './git'
|
||||||
|
import { handleAgents, handleAgentsFile } from './agents'
|
||||||
|
|
||||||
export async function handleRequest(req: Request): Promise<Response> {
|
export async function handleRequest(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
@@ -247,5 +248,15 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
return handleGitFile(url)
|
return handleGitFile(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
if (path === '/api/agents' && req.method === 'GET') {
|
||||||
|
return handleAgents(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/agents/file') {
|
||||||
|
const res = await handleAgentsFile(req, url)
|
||||||
|
if (res) return res
|
||||||
|
}
|
||||||
|
|
||||||
return notFoundResponse()
|
return notFoundResponse()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user