feat: collapse-all button in float transcript titlebar
Add titlebar button to collapse all conversation sections except the last user message. Special user messages (interrupted, meta/continue) are treated as section children rather than section leaders.
This commit is contained in:
@@ -1,504 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue'
|
|
||||||
import { endpoints } from '../config/endpoints'
|
|
||||||
import type { Agent, AgentStatusState, ClaudeStatus } from '../types/agent'
|
|
||||||
import { useVoiceCapture } from '../composables/useVoiceCapture'
|
|
||||||
import { useCanvasStore } from '../stores/canvas'
|
|
||||||
import FloatBubble from './agent/FloatBubble.vue'
|
|
||||||
import PromptBar from './agent/PromptBar.vue'
|
|
||||||
import TranscriptCard from './agent/TranscriptCard.vue'
|
|
||||||
|
|
||||||
const agents = ref<Agent[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
// Per-agent status tracking
|
|
||||||
const agentStatuses = reactive<Record<string, AgentStatusState>>({})
|
|
||||||
const agentTimers = new Map<string, Record<string, number>>()
|
|
||||||
|
|
||||||
// PromptBar state
|
|
||||||
const activeAgentId = ref<string | null>(null)
|
|
||||||
const activeAnchorRect = ref<DOMRect | null>(null)
|
|
||||||
const openInRecording = ref(false)
|
|
||||||
const promptBarRef = ref<InstanceType<typeof PromptBar> | null>(null)
|
|
||||||
const bubbleRefs = new Map<string, Element>()
|
|
||||||
|
|
||||||
function setBubbleRef(agentId: string, el: any) {
|
|
||||||
if (el?.$el) bubbleRefs.set(agentId, el.$el)
|
|
||||||
else if (el) bubbleRefs.set(agentId, el)
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoOpenForAgent(agentId: string) {
|
|
||||||
if (activeAgentId.value === agentId) return // Already open
|
|
||||||
const bubbleEl = bubbleRefs.get(agentId)
|
|
||||||
if (!bubbleEl) return
|
|
||||||
activeAnchorRect.value = bubbleEl.getBoundingClientRect()
|
|
||||||
openInRecording.value = false
|
|
||||||
activeAgentId.value = agentId
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRecordingActive = computed(() =>
|
|
||||||
promptBarRef.value?.isRecording ?? false
|
|
||||||
)
|
|
||||||
|
|
||||||
// Floating transcript state (long-press recording)
|
|
||||||
const canvasStore = useCanvasStore()
|
|
||||||
const floatingVoice = useVoiceCapture({
|
|
||||||
onNotification: (msg, type, dur) => canvasStore.showNotification(msg, type, dur)
|
|
||||||
})
|
|
||||||
const floatingAgentId = ref<string | null>(null)
|
|
||||||
const floatingAnchorRect = ref<DOMRect | null>(null)
|
|
||||||
|
|
||||||
const floatingAgent = computed(() =>
|
|
||||||
enabledAgents.value.find(a => a.id === floatingAgentId.value) || null
|
|
||||||
)
|
|
||||||
|
|
||||||
const isFloatingRecording = computed(() =>
|
|
||||||
!!floatingAgentId.value && floatingVoice.isRecording.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const floatingStyle = computed(() => {
|
|
||||||
if (!floatingAnchorRect.value) return {}
|
|
||||||
const rect = floatingAnchorRect.value
|
|
||||||
const bubbleCenterX = rect.left + rect.width / 2
|
|
||||||
const bottomOffset = window.innerHeight - rect.top + 12
|
|
||||||
const panelWidth = 320
|
|
||||||
let left = bubbleCenterX - panelWidth / 2
|
|
||||||
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12))
|
|
||||||
return {
|
|
||||||
position: 'fixed' as const,
|
|
||||||
bottom: `${bottomOffset}px`,
|
|
||||||
left: `${left}px`,
|
|
||||||
width: `${panelWidth}px`,
|
|
||||||
zIndex: 10000
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const enabledAgents = computed(() =>
|
|
||||||
agents.value.filter(a => a.uiConfig?.enabled)
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeAgent = computed(() =>
|
|
||||||
enabledAgents.value.find(a => a.id === activeAgentId.value) || null
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Status tracking helpers ---
|
|
||||||
|
|
||||||
function getAgentStatus(agentId: string): AgentStatusState {
|
|
||||||
if (!agentStatuses[agentId]) {
|
|
||||||
agentStatuses[agentId] = {
|
|
||||||
isProcessing: false,
|
|
||||||
isReading: false,
|
|
||||||
isWriting: false,
|
|
||||||
awaitingPermission: false,
|
|
||||||
showToolFlash: false,
|
|
||||||
showNotification: false,
|
|
||||||
currentTool: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return agentStatuses[agentId]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimers(agentId: string): Record<string, number> {
|
|
||||||
if (!agentTimers.has(agentId)) {
|
|
||||||
agentTimers.set(agentId, {})
|
|
||||||
}
|
|
||||||
return agentTimers.get(agentId)!
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAgentTimer(agentId: string, key: string) {
|
|
||||||
const timers = getTimers(agentId)
|
|
||||||
if (timers[key]) {
|
|
||||||
clearTimeout(timers[key])
|
|
||||||
delete timers[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAgentTimer(agentId: string, key: string, fn: () => void, ms: number) {
|
|
||||||
clearAgentTimer(agentId, key)
|
|
||||||
const timers = getTimers(agentId)
|
|
||||||
timers[key] = window.setTimeout(fn, ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerToolFlash(agentId: string) {
|
|
||||||
const s = getAgentStatus(agentId)
|
|
||||||
s.showToolFlash = true
|
|
||||||
setAgentTimer(agentId, 'toolFlash', () => {
|
|
||||||
s.showToolFlash = false
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- WebSocket ---
|
|
||||||
|
|
||||||
let statusWs: WebSocket | null = null
|
|
||||||
let reconnectTimeout: number | null = null
|
|
||||||
|
|
||||||
function connectStatusWs() {
|
|
||||||
if (statusWs?.readyState === WebSocket.OPEN) return
|
|
||||||
|
|
||||||
console.log('[AgentBar] Connecting to', endpoints.claudeStatus)
|
|
||||||
statusWs = new WebSocket(endpoints.claudeStatus)
|
|
||||||
|
|
||||||
statusWs.onopen = () => {
|
|
||||||
console.log('[AgentBar] WebSocket OPEN')
|
|
||||||
}
|
|
||||||
|
|
||||||
statusWs.onerror = (err) => {
|
|
||||||
console.error('[AgentBar] WebSocket ERROR', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
statusWs.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
console.log('[AgentBar] WS message:', msg.type, msg.status || '', msg.agent || '')
|
|
||||||
|
|
||||||
if (msg.type !== 'claude-status') return
|
|
||||||
|
|
||||||
const status = msg.status as ClaudeStatus
|
|
||||||
const agentName = (msg.agent || 'main') as string
|
|
||||||
const tool = msg.tool || null
|
|
||||||
|
|
||||||
console.log(`[AgentBar] Status: ${status}, agent: ${agentName}, tool: ${tool}`)
|
|
||||||
|
|
||||||
const agent = enabledAgents.value.find(a =>
|
|
||||||
a.id.toLowerCase() === agentName.toLowerCase() ||
|
|
||||||
a.name.toLowerCase() === agentName.toLowerCase() ||
|
|
||||||
a.uiConfig?.label.toLowerCase() === agentName.toLowerCase()
|
|
||||||
)
|
|
||||||
if (!agent) {
|
|
||||||
console.log(`[AgentBar] No matching enabled agent for "${agentName}", enabled:`, enabledAgents.value.map(a => a.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[AgentBar] Matched agent: ${agent.id}, applying status: ${status}`)
|
|
||||||
const s = getAgentStatus(agent.id)
|
|
||||||
s.currentTool = tool
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case 'processing':
|
|
||||||
case 'thinking':
|
|
||||||
s.isProcessing = true
|
|
||||||
setAgentTimer(agent.id, 'processing', () => {
|
|
||||||
s.isProcessing = false
|
|
||||||
}, 120000)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'idle':
|
|
||||||
s.isProcessing = false
|
|
||||||
s.isReading = false
|
|
||||||
s.isWriting = false
|
|
||||||
s.awaitingPermission = false
|
|
||||||
clearAgentTimer(agent.id, 'processing')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'permissionRequest':
|
|
||||||
s.awaitingPermission = true
|
|
||||||
// Auto-open PromptBar if not already open for this agent
|
|
||||||
autoOpenForAgent(agent.id)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'reading':
|
|
||||||
s.isReading = true
|
|
||||||
triggerToolFlash(agent.id)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'writing':
|
|
||||||
s.isWriting = true
|
|
||||||
triggerToolFlash(agent.id)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'toolUse':
|
|
||||||
triggerToolFlash(agent.id)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'toolDone':
|
|
||||||
s.isReading = false
|
|
||||||
s.isWriting = false
|
|
||||||
s.awaitingPermission = false
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'notification':
|
|
||||||
s.showNotification = true
|
|
||||||
setAgentTimer(agent.id, 'notification', () => {
|
|
||||||
s.showNotification = false
|
|
||||||
}, 2000)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
statusWs.onclose = () => {
|
|
||||||
reconnectTimeout = window.setTimeout(connectStatusWs, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Data fetching ---
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bubble interaction ---
|
|
||||||
|
|
||||||
function openPromptBar(agent: Agent, el: HTMLElement, recording: boolean) {
|
|
||||||
if (activeAgentId.value === agent.id && !recording) {
|
|
||||||
activeAgentId.value = null
|
|
||||||
activeAnchorRect.value = null
|
|
||||||
openInRecording.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeAnchorRect.value = el.getBoundingClientRect()
|
|
||||||
openInRecording.value = recording
|
|
||||||
activeAgentId.value = agent.id
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBubbleClick(agent: Agent, event: MouseEvent) {
|
|
||||||
openPromptBar(agent, event.currentTarget as HTMLElement, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBubbleHold(agent: Agent, el: HTMLElement) {
|
|
||||||
// Close PromptBar if open
|
|
||||||
if (activeAgentId.value) {
|
|
||||||
handlePromptClose()
|
|
||||||
}
|
|
||||||
// Open floating transcript
|
|
||||||
floatingAnchorRect.value = el.getBoundingClientRect()
|
|
||||||
floatingAgentId.value = agent.id
|
|
||||||
await floatingVoice.init()
|
|
||||||
floatingVoice.clearTranscript()
|
|
||||||
floatingVoice.startRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBubbleHoldRelease() {
|
|
||||||
if (!floatingAgentId.value || !floatingVoice.isRecording.value) return
|
|
||||||
// Buffer 500ms for trailing words, then stop and emit done via TranscriptCard
|
|
||||||
setTimeout(() => {
|
|
||||||
if (floatingVoice.isRecording.value) {
|
|
||||||
floatingVoice.stopRecording()
|
|
||||||
// Wait for final Whisper result if needed
|
|
||||||
const delay = floatingVoice.voiceMode.value === 'whisper' ? 800 : 200
|
|
||||||
setTimeout(() => {
|
|
||||||
const text = floatingVoice.transcript.value.trim()
|
|
||||||
closeFloating()
|
|
||||||
if (text) {
|
|
||||||
console.log(`[AgentBar] Voice submit to ${floatingAgent.value?.id}:`, text)
|
|
||||||
}
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFloatingDone(text: string) {
|
|
||||||
closeFloating()
|
|
||||||
if (text.trim()) {
|
|
||||||
console.log(`[AgentBar] Voice submit to ${floatingAgent.value?.id}:`, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeFloating() {
|
|
||||||
if (floatingVoice.isRecording.value) {
|
|
||||||
floatingVoice.stopRecording()
|
|
||||||
}
|
|
||||||
floatingVoice.cleanup()
|
|
||||||
floatingAgentId.value = null
|
|
||||||
floatingAnchorRect.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFloatingClose() {
|
|
||||||
closeFloating()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePromptClose() {
|
|
||||||
activeAgentId.value = null
|
|
||||||
activeAnchorRect.value = null
|
|
||||||
openInRecording.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePromptSubmit(text: string) {
|
|
||||||
console.log(`[AgentBar] Submit to ${activeAgentId.value}:`, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lifecycle ---
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchAgents()
|
|
||||||
connectStatusWs()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
statusWs?.close()
|
|
||||||
floatingVoice.cleanup()
|
|
||||||
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
|
||||||
for (const [, timers] of agentTimers) {
|
|
||||||
for (const key of Object.keys(timers)) {
|
|
||||||
clearTimeout(timers[key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
agentTimers.clear()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="enabledAgents.length" class="agent-bubbles">
|
|
||||||
<FloatBubble
|
|
||||||
v-for="agent in enabledAgents"
|
|
||||||
:key="agent.id"
|
|
||||||
:ref="(el: any) => setBubbleRef(agent.id, el)"
|
|
||||||
:agent="agent"
|
|
||||||
:status="agentStatuses[agent.id]"
|
|
||||||
:recording="(activeAgentId === agent.id && isRecordingActive) || (floatingAgentId === agent.id && isFloatingRecording)"
|
|
||||||
@click="handleBubbleClick(agent, $event)"
|
|
||||||
@hold="handleBubbleHold(agent, $event)"
|
|
||||||
@holdrelease="handleBubbleHoldRelease"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PromptBar
|
|
||||||
v-if="activeAgent"
|
|
||||||
:key="activeAgent.id"
|
|
||||||
ref="promptBarRef"
|
|
||||||
:agent="activeAgent"
|
|
||||||
:anchor-rect="activeAnchorRect"
|
|
||||||
:visible="!!activeAgentId"
|
|
||||||
:start-recording="openInRecording"
|
|
||||||
@close="handlePromptClose"
|
|
||||||
@submit="handlePromptSubmit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Floating transcript (long-press recording) -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="float-transcript">
|
|
||||||
<div v-if="floatingAgentId && floatingAnchorRect" class="float-transcript-backdrop" @click.self="handleFloatingClose">
|
|
||||||
<div class="float-transcript-panel" :style="floatingStyle">
|
|
||||||
<div class="ft-header">
|
|
||||||
<div
|
|
||||||
v-if="floatingAgent"
|
|
||||||
class="ft-badge"
|
|
||||||
:style="{ background: floatingAgent.uiConfig?.gradient || floatingAgent.uiConfig?.color }"
|
|
||||||
>
|
|
||||||
{{ floatingAgent.uiConfig?.shortLabel }}
|
|
||||||
</div>
|
|
||||||
<span class="ft-label">{{ floatingAgent?.uiConfig?.label || floatingAgent?.name }}</span>
|
|
||||||
</div>
|
|
||||||
<TranscriptCard :voice="floatingVoice" @done="handleFloatingDone" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.agent-bubbles {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
z-index: 9998;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating transcript */
|
|
||||||
.float-transcript-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-panel {
|
|
||||||
background: rgba(15, 10, 26, 0.9);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
box-shadow:
|
|
||||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
|
||||||
transform-origin: bottom center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ft-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ft-badge {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 7px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ft-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition */
|
|
||||||
.float-transcript-enter-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-enter-active .float-transcript-panel {
|
|
||||||
animation: ft-enter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-leave-active {
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-leave-active .float-transcript-panel {
|
|
||||||
animation: ft-leave 0.15s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ft-enter {
|
|
||||||
0% { opacity: 0; transform: translateY(10px) scale(0.9); }
|
|
||||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ft-leave {
|
|
||||||
0% { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
100% { opacity: 0; transform: translateY(8px) scale(0.95); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.agent-bubbles {
|
|
||||||
bottom: 80px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-transcript-panel {
|
|
||||||
left: 8px !important;
|
|
||||||
right: 8px;
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -606,6 +606,19 @@ onBeforeUnmount(() => {
|
|||||||
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
|
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="chatRef?.collapseAllExceptLast()"
|
||||||
|
:class="{ active: chatRef?.allCollapsed }"
|
||||||
|
class="collapse-all-btn"
|
||||||
|
title="Collapse all except last"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline v-if="chatRef?.allCollapsed" points="6 9 12 15 18 9"/>
|
||||||
|
<template v-else>
|
||||||
|
<polyline points="18 15 12 9 6 15"/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button @click.stop="chatRef?.toggleSelectMode()" :class="{ active: chatRef?.selectMode }" title="Select messages">
|
<button @click.stop="chatRef?.toggleSelectMode()" :class="{ active: chatRef?.selectMode }" title="Select messages">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
|
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
|
||||||
@@ -963,6 +976,22 @@ onBeforeUnmount(() => {
|
|||||||
color: #c4b5fd;
|
color: #c4b5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-controls .collapse-all-btn {
|
||||||
|
color: #2dd4bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-controls .collapse-all-btn:hover {
|
||||||
|
color: #5eead4;
|
||||||
|
background: rgba(45, 212, 191, 0.15);
|
||||||
|
border-color: rgba(45, 212, 191, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-controls .collapse-all-btn.active {
|
||||||
|
background: rgba(45, 212, 191, 0.2);
|
||||||
|
border-color: rgba(45, 212, 191, 0.3);
|
||||||
|
color: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
.window-controls .size-btn {
|
.window-controls .size-btn {
|
||||||
color: #0ea5e9;
|
color: #0ea5e9;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,514 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
|
||||||
import type { Agent } from '../../types/agent'
|
|
||||||
import type { AgentTerminal as AgentTerminalType } from '../../composables/useAgentTerminal'
|
|
||||||
import TerminalNavButtons from '../TerminalNavButtons.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
agent: Agent
|
|
||||||
agentTerminal: AgentTerminalType
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit('update:modelValue', val)
|
|
||||||
})
|
|
||||||
|
|
||||||
const renderer = props.agentTerminal.renderer
|
|
||||||
const terminalState = props.agentTerminal.terminalState
|
|
||||||
const connected = props.agentTerminal.connected
|
|
||||||
const agentRunning = props.agentTerminal.agentRunning
|
|
||||||
const startAgent = props.agentTerminal.startAgent
|
|
||||||
const stopAgent = props.agentTerminal.stopAgent
|
|
||||||
|
|
||||||
const showNavButtons = ref(false)
|
|
||||||
|
|
||||||
// Local ref for the xterm container - syncs to composable's containerRef
|
|
||||||
const terminalContainer = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
watch(terminalContainer, (el) => {
|
|
||||||
props.agentTerminal.containerRef.value = el
|
|
||||||
})
|
|
||||||
|
|
||||||
// Drag state
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const position = ref({ x: 0, y: 0 })
|
|
||||||
const hasCustomPosition = ref(false)
|
|
||||||
const dragOffset = ref({ x: 0, y: 0 })
|
|
||||||
|
|
||||||
// Resize state
|
|
||||||
const isResizing = ref(false)
|
|
||||||
const size = ref({ w: 560, h: 340 })
|
|
||||||
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
|
||||||
|
|
||||||
const windowRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// Style
|
|
||||||
const agentColor = computed(() => props.agent.uiConfig?.color || '#6366f1')
|
|
||||||
const agentBg = computed(() => props.agent.uiConfig?.terminalBg || '#0f0a1a')
|
|
||||||
const agentBorder = computed(() => props.agent.uiConfig?.terminalBorder || '#6366f1')
|
|
||||||
|
|
||||||
const statusDotClass = computed(() => {
|
|
||||||
switch (terminalState.value) {
|
|
||||||
case 'ready': return 'on'
|
|
||||||
case 'connecting':
|
|
||||||
case 'agent-starting': return 'wait'
|
|
||||||
case 'crashed': return 'error'
|
|
||||||
default: return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const terminalStyle = computed((): Record<string, string> => {
|
|
||||||
if (!hasCustomPosition.value) {
|
|
||||||
return {
|
|
||||||
width: `${size.value.w}px`,
|
|
||||||
height: `${size.value.h}px`,
|
|
||||||
bottom: '80px',
|
|
||||||
right: '16px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
width: `${size.value.w}px`,
|
|
||||||
height: `${size.value.h}px`,
|
|
||||||
top: `${position.value.y}px`,
|
|
||||||
left: `${position.value.x}px`,
|
|
||||||
bottom: 'auto',
|
|
||||||
right: 'auto'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Drag ──
|
|
||||||
|
|
||||||
function startDrag(e: MouseEvent | TouchEvent) {
|
|
||||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
|
||||||
if (e instanceof TouchEvent) e.preventDefault()
|
|
||||||
isDragging.value = true
|
|
||||||
|
|
||||||
const touch = e instanceof TouchEvent ? e.touches[0] : null
|
|
||||||
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
|
|
||||||
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
|
|
||||||
|
|
||||||
const rect = windowRef.value?.getBoundingClientRect()
|
|
||||||
if (rect) {
|
|
||||||
if (!hasCustomPosition.value) {
|
|
||||||
position.value = { x: rect.left, y: rect.top }
|
|
||||||
}
|
|
||||||
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', onDrag)
|
|
||||||
document.addEventListener('mouseup', stopDrag)
|
|
||||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
|
||||||
document.addEventListener('touchend', stopDrag)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrag(e: MouseEvent | TouchEvent) {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
if (e instanceof TouchEvent) e.preventDefault()
|
|
||||||
|
|
||||||
const touch = e instanceof TouchEvent ? e.touches[0] : null
|
|
||||||
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
|
|
||||||
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
|
|
||||||
|
|
||||||
const w = windowRef.value?.offsetWidth || 560
|
|
||||||
const h = windowRef.value?.offsetHeight || 340
|
|
||||||
position.value = {
|
|
||||||
x: Math.max(-w * 0.75, Math.min(clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
|
|
||||||
y: Math.max(-h * 0.75, Math.min(clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopDrag() {
|
|
||||||
isDragging.value = false
|
|
||||||
hasCustomPosition.value = true
|
|
||||||
document.removeEventListener('mousemove', onDrag)
|
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
|
||||||
document.removeEventListener('touchmove', onDrag)
|
|
||||||
document.removeEventListener('touchend', stopDrag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resize ──
|
|
||||||
|
|
||||||
function startResize(e: MouseEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
isResizing.value = true
|
|
||||||
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
|
|
||||||
document.addEventListener('mousemove', onResize)
|
|
||||||
document.addEventListener('mouseup', stopResize)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onResize(e: MouseEvent) {
|
|
||||||
if (!isResizing.value) return
|
|
||||||
size.value = {
|
|
||||||
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
|
|
||||||
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopResize() {
|
|
||||||
isResizing.value = false
|
|
||||||
document.removeEventListener('mousemove', onResize)
|
|
||||||
document.removeEventListener('mouseup', stopResize)
|
|
||||||
nextTick(() => renderer.fit())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Actions ──
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBuffer() {
|
|
||||||
renderer.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRestart() {
|
|
||||||
startAgent(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleNavButtons() {
|
|
||||||
showNavButtons.value = !showNavButtons.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nav button actions - send commands through the agent terminal
|
|
||||||
function navRunClaude() {
|
|
||||||
props.agentTerminal.sendInput('claude')
|
|
||||||
}
|
|
||||||
|
|
||||||
function navRunClaudeContinue() {
|
|
||||||
props.agentTerminal.sendInput('claude --continue')
|
|
||||||
}
|
|
||||||
|
|
||||||
function navRunClaudeResume() {
|
|
||||||
props.agentTerminal.sendInput('claude --resume')
|
|
||||||
}
|
|
||||||
|
|
||||||
function navRefresh() {
|
|
||||||
renderer.fit()
|
|
||||||
}
|
|
||||||
|
|
||||||
function navSendKey(key: string) {
|
|
||||||
const keyMap: Record<string, string> = {
|
|
||||||
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
|
|
||||||
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
|
|
||||||
}
|
|
||||||
const data = keyMap[key]
|
|
||||||
if (data) props.agentTerminal.sendRaw(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function navScroll(direction: 'up' | 'down' | 'end') {
|
|
||||||
if (direction === 'up') renderer.scrollLines(-10)
|
|
||||||
else if (direction === 'down') renderer.scrollLines(10)
|
|
||||||
else renderer.scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Watch open/close to init terminal ──
|
|
||||||
|
|
||||||
watch(isOpen, (open) => {
|
|
||||||
if (open) {
|
|
||||||
nextTick(() => {
|
|
||||||
// Initialize the renderer if not already done (container ref is now set)
|
|
||||||
if (!renderer.isReady.value) {
|
|
||||||
renderer.init()
|
|
||||||
}
|
|
||||||
// Wait for CSS transition then fit (+ focus only on desktop)
|
|
||||||
setTimeout(() => {
|
|
||||||
renderer.fit()
|
|
||||||
renderer.terminal.value?.refresh(0, renderer.terminal.value.rows - 1)
|
|
||||||
if (window.innerWidth > 1024) {
|
|
||||||
renderer.focus()
|
|
||||||
}
|
|
||||||
}, 150)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
renderer.blur()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('mousemove', onDrag)
|
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
|
||||||
document.removeEventListener('touchmove', onDrag)
|
|
||||||
document.removeEventListener('touchend', stopDrag)
|
|
||||||
document.removeEventListener('mousemove', onResize)
|
|
||||||
document.removeEventListener('mouseup', stopResize)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="at-slide">
|
|
||||||
<div
|
|
||||||
v-show="isOpen"
|
|
||||||
ref="windowRef"
|
|
||||||
class="agent-terminal"
|
|
||||||
:class="{ dragging: isDragging, resizing: isResizing }"
|
|
||||||
:style="terminalStyle"
|
|
||||||
>
|
|
||||||
<div class="at-glass" :style="{ borderColor: agentBorder + '40' }">
|
|
||||||
<!-- Titlebar -->
|
|
||||||
<div class="at-titlebar" @mousedown="startDrag" @touchstart="startDrag">
|
|
||||||
<div class="at-left">
|
|
||||||
<div
|
|
||||||
class="at-badge"
|
|
||||||
:style="{ background: agent.uiConfig?.gradient || agentColor }"
|
|
||||||
>{{ agent.uiConfig?.shortLabel || agent.id[0]?.toUpperCase() }}</div>
|
|
||||||
<span class="at-name">{{ agent.uiConfig?.label || agent.name }}</span>
|
|
||||||
<i class="at-dot" :class="statusDotClass"></i>
|
|
||||||
<span v-if="terminalState === 'agent-starting'" class="at-status-text">Starting...</span>
|
|
||||||
<span v-else-if="terminalState === 'connecting'" class="at-status-text">Connecting...</span>
|
|
||||||
<span v-else-if="terminalState === 'crashed'" class="at-status-text crashed">Crashed</span>
|
|
||||||
</div>
|
|
||||||
<div class="window-controls">
|
|
||||||
<button
|
|
||||||
class="wc-btn nav-toggle"
|
|
||||||
:class="{ active: showNavButtons }"
|
|
||||||
title="Toggle navigation"
|
|
||||||
@click.stop="toggleNavButtons"
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="wc-btn x" title="Close" @click.stop="close">
|
|
||||||
<svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Terminal content -->
|
|
||||||
<div class="at-content" :style="{ background: agentBg }">
|
|
||||||
<div ref="terminalContainer" class="at-term"></div>
|
|
||||||
|
|
||||||
<!-- Overlay states -->
|
|
||||||
<div v-if="terminalState === 'off'" class="at-overlay" @click="agentTerminal.connect()">
|
|
||||||
<div class="at-overlay-msg">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
|
|
||||||
<line x1="12" y1="2" x2="12" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
<span>Agent offline</span>
|
|
||||||
<small>Click to connect</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="terminalState === 'connecting'" class="at-overlay connecting">
|
|
||||||
<div class="at-overlay-msg">
|
|
||||||
<div class="at-spinner"></div>
|
|
||||||
<span>Connecting...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resize handle -->
|
|
||||||
<div class="at-resize" @mousedown="startResize"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav buttons bar (outside terminal, hangs from bottom) -->
|
|
||||||
<TerminalNavButtons
|
|
||||||
v-if="showNavButtons"
|
|
||||||
class="at-nav-popup"
|
|
||||||
:show-start="terminalState === 'crashed' || terminalState === 'off'"
|
|
||||||
:show-restart="agentRunning"
|
|
||||||
@request-token="agentTerminal.sendInput('genera token usando tu mcp')"
|
|
||||||
@run-claude="navRunClaude"
|
|
||||||
@run-claude-continue="navRunClaudeContinue"
|
|
||||||
@run-claude-resume="navRunClaudeResume"
|
|
||||||
@clear-buffer="clearBuffer"
|
|
||||||
@refresh="navRefresh"
|
|
||||||
@restart="handleRestart"
|
|
||||||
@send-key="navSendKey"
|
|
||||||
@scroll="navScroll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.agent-terminal {
|
|
||||||
position: fixed;
|
|
||||||
min-width: 400px;
|
|
||||||
min-height: 250px;
|
|
||||||
z-index: 10001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-glass {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: rgba(200, 215, 235, 0.35);
|
|
||||||
backdrop-filter: blur(24px) saturate(1.6);
|
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
|
||||||
box-shadow: 0 0 0 1px rgba(80, 120, 180, 0.25), 0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-titlebar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 4px 0 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
cursor: grab;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.at-titlebar { touch-action: none; }
|
|
||||||
.agent-terminal.dragging .at-titlebar { cursor: grabbing; }
|
|
||||||
|
|
||||||
.at-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font: 500 10px/1 system-ui, sans-serif;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-badge {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #999;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.at-dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
|
|
||||||
.at-dot.wait { background: #a80; animation: at-pulse 0.8s infinite; }
|
|
||||||
.at-dot.error { background: #e44; box-shadow: 0 0 4px #e44; }
|
|
||||||
|
|
||||||
.at-status-text {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.at-status-text.crashed { color: #c33; }
|
|
||||||
|
|
||||||
.window-controls { display: flex; gap: 1px; }
|
|
||||||
.wc-btn {
|
|
||||||
width: 20px;
|
|
||||||
height: 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 2px;
|
|
||||||
color: #333;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.wc-btn:hover { background: rgba(255, 255, 255, 0.5); }
|
|
||||||
.wc-btn.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
|
|
||||||
.wc-btn.nav-toggle { width: 44px; }
|
|
||||||
.wc-btn.nav-toggle.active { background: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
|
|
||||||
.wc-btn.nav-toggle:hover { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
|
|
||||||
|
|
||||||
.at-content {
|
|
||||||
flex: 1;
|
|
||||||
margin: 2px;
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-term { width: 100%; height: 100%; }
|
|
||||||
.at-term :deep(.xterm) { height: 100%; padding: 2px; }
|
|
||||||
.at-term :deep(.xterm-viewport) {
|
|
||||||
overflow-y: auto !important;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
.at-term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0, 0, 0, 0.2); }
|
|
||||||
.at-term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
|
|
||||||
|
|
||||||
.at-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.at-overlay.connecting { cursor: wait; }
|
|
||||||
|
|
||||||
.at-overlay-msg {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.at-overlay-msg svg { color: #ef4444; opacity: 0.8; }
|
|
||||||
.at-overlay-msg span { font-size: 13px; font-weight: 500; }
|
|
||||||
.at-overlay-msg small { font-size: 10px; color: #888; }
|
|
||||||
|
|
||||||
.at-spinner {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: at-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-resize {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: nwse-resize;
|
|
||||||
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 100%);
|
|
||||||
border-radius: 0 0 5px 0;
|
|
||||||
}
|
|
||||||
.at-resize:hover { background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.2) 100%); }
|
|
||||||
|
|
||||||
.agent-terminal.resizing { user-select: none; }
|
|
||||||
.agent-terminal.resizing .at-term { pointer-events: none; }
|
|
||||||
|
|
||||||
.at-nav-popup {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 100%;
|
|
||||||
border-radius: 0 0 6px 6px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-top: none;
|
|
||||||
backdrop-filter: blur(14px) saturate(1.3);
|
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(1.3);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.at-slide-enter-active, .at-slide-leave-active { transition: all 0.15s ease; }
|
|
||||||
.at-slide-enter-from, .at-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
|
|
||||||
|
|
||||||
@keyframes at-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
||||||
@keyframes at-spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, nextTick } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
placeholder?: string
|
|
||||||
recording?: boolean
|
|
||||||
historyActive?: boolean
|
|
||||||
settingsActive?: boolean
|
|
||||||
terminalActive?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
autofocus?: boolean
|
|
||||||
statusDot?: 'green' | 'yellow' | 'red' | 'gray' | ''
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
submit: [text: string]
|
|
||||||
mic: []
|
|
||||||
'toggle-history': []
|
|
||||||
'toggle-settings': []
|
|
||||||
'toggle-terminal': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const inputText = ref('')
|
|
||||||
const inputEl = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
const text = inputText.value.trim()
|
|
||||||
if (!text) return
|
|
||||||
emit('submit', text)
|
|
||||||
inputText.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function focus() {
|
|
||||||
inputEl.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMobile = () => window.innerWidth < 480
|
|
||||||
|
|
||||||
watch(() => props.autofocus, async (v) => {
|
|
||||||
if (v && !isMobile()) {
|
|
||||||
await nextTick()
|
|
||||||
focus()
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
defineExpose({ focus })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<i v-if="statusDot" class="ci-status-dot" :class="statusDot"></i>
|
|
||||||
<input
|
|
||||||
ref="inputEl"
|
|
||||||
v-model="inputText"
|
|
||||||
class="chat-input"
|
|
||||||
type="text"
|
|
||||||
:placeholder="placeholder || 'Escribe un mensaje...'"
|
|
||||||
:disabled="disabled"
|
|
||||||
@keydown.enter="handleSubmit"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="ci-btn ci-mic"
|
|
||||||
:class="{ recording }"
|
|
||||||
:title="recording ? 'Grabando...' : 'Grabar voz'"
|
|
||||||
@click="emit('mic')"
|
|
||||||
>
|
|
||||||
<svg 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">
|
|
||||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
||||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
||||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
||||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="ci-btn ci-send"
|
|
||||||
title="Enviar"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
<svg 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">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- History button hidden for now
|
|
||||||
<button
|
|
||||||
class="ci-btn ci-history"
|
|
||||||
:class="{ active: historyActive }"
|
|
||||||
title="Historial"
|
|
||||||
@click="emit('toggle-history')"
|
|
||||||
>
|
|
||||||
<svg 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">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<polyline points="12 6 12 12 16 14"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
-->
|
|
||||||
<!-- Settings and Terminal buttons moved to PromptBar header
|
|
||||||
<button
|
|
||||||
class="ci-btn ci-settings"
|
|
||||||
:class="{ active: settingsActive }"
|
|
||||||
title="Voice settings"
|
|
||||||
@click="emit('toggle-settings')"
|
|
||||||
>
|
|
||||||
<svg 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">
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
<path d="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 4.68 15a1.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 9 4.68a1.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 19.4 9a1.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"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="ci-btn ci-terminal"
|
|
||||||
:class="{ active: terminalActive }"
|
|
||||||
title="Terminal"
|
|
||||||
@click="emit('toggle-terminal')"
|
|
||||||
>
|
|
||||||
<svg 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">
|
|
||||||
<polyline points="4 17 10 11 4 5"/>
|
|
||||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
-->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.chat-input-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
flex: 1;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input:focus {
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-mic.recording {
|
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
border-color: rgba(239, 68, 68, 0.4);
|
|
||||||
color: #ef4444;
|
|
||||||
animation: mic-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-send:hover {
|
|
||||||
background: rgba(139, 92, 246, 0.2);
|
|
||||||
border-color: rgba(139, 92, 246, 0.3);
|
|
||||||
color: rgba(139, 92, 246, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-history.active,
|
|
||||||
.ci-settings.active,
|
|
||||||
.ci-terminal.active {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-terminal:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
border-color: rgba(16, 185, 129, 0.25);
|
|
||||||
color: rgba(16, 185, 129, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ci-status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ci-status-dot.green { background: #22c55e; box-shadow: 0 0 4px #22c55e; }
|
|
||||||
.ci-status-dot.yellow { background: #eab308; box-shadow: 0 0 4px #eab308; animation: ci-pulse 1s infinite; }
|
|
||||||
.ci-status-dot.red { background: #ef4444; box-shadow: 0 0 4px #ef4444; }
|
|
||||||
.ci-status-dot.gray { background: #6b7280; }
|
|
||||||
|
|
||||||
.chat-input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ci-pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes mic-pulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
|
|
||||||
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch, onMounted } from 'vue'
|
|
||||||
import type { Agent, TranscriptSession, TranscriptMessage } from '../../types/agent'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
agent: Agent
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const sessions = ref<TranscriptSession[]>([])
|
|
||||||
const activeSessionId = ref<string | null>(null)
|
|
||||||
const messages = ref<TranscriptMessage[]>([])
|
|
||||||
const stats = ref<{
|
|
||||||
model: string
|
|
||||||
duration: number
|
|
||||||
toolCallCount: number
|
|
||||||
totalInput: number
|
|
||||||
totalOutput: number
|
|
||||||
} | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
function formatSessionTime(iso: string): string {
|
|
||||||
if (!iso) return '--:--'
|
|
||||||
const d = new Date(iso)
|
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (!ms || ms <= 0) return '0s'
|
|
||||||
const totalSec = Math.floor(ms / 1000)
|
|
||||||
const m = Math.floor(totalSec / 60)
|
|
||||||
const s = totalSec % 60
|
|
||||||
if (m === 0) return `${s}s`
|
|
||||||
return `${m}m ${s}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
|
||||||
if (!n || n <= 0) return '0'
|
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
|
|
||||||
return String(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortModel(model: string): string {
|
|
||||||
if (!model) return '?'
|
|
||||||
if (model.includes('opus')) return 'opus-4'
|
|
||||||
if (model.includes('sonnet')) return 'sonnet-4'
|
|
||||||
if (model.includes('haiku')) return 'haiku-4'
|
|
||||||
// Fallback: last segment
|
|
||||||
const parts = model.split('-')
|
|
||||||
return parts.slice(-2).join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateContent(text: string, max = 200): string {
|
|
||||||
if (!text || text.length <= max) return text || ''
|
|
||||||
return text.slice(0, max) + '...'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fetch helpers ──
|
|
||||||
|
|
||||||
function getBaseUrl(sessionId: string): string {
|
|
||||||
const firstSession = sessions.value[0]
|
|
||||||
if (firstSession && sessionId === firstSession.id) {
|
|
||||||
return '/api/transcript/latest'
|
|
||||||
}
|
|
||||||
return `/api/transcript/${sessionId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSessions() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/transcript/sessions')
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
sessions.value = await res.json()
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = `Error cargando sesiones: ${e.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSessionData(sessionId: string) {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const base = getBaseUrl(sessionId)
|
|
||||||
const [msgRes, statsRes, tokensRes] = await Promise.all([
|
|
||||||
fetch(`${base}?section=messages`),
|
|
||||||
fetch(`${base}?section=stats`),
|
|
||||||
fetch(`${base}?section=tokens`)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!msgRes.ok || !statsRes.ok || !tokensRes.ok) {
|
|
||||||
throw new Error('Error fetching session data')
|
|
||||||
}
|
|
||||||
|
|
||||||
const [msgData, statsData, tokensData] = await Promise.all([
|
|
||||||
msgRes.json(),
|
|
||||||
statsRes.json(),
|
|
||||||
tokensRes.json()
|
|
||||||
])
|
|
||||||
|
|
||||||
messages.value = msgData.messages || []
|
|
||||||
stats.value = {
|
|
||||||
model: statsData.model || '',
|
|
||||||
duration: statsData.duration || 0,
|
|
||||||
toolCallCount: statsData.stats?.toolCallCount || 0,
|
|
||||||
totalInput: tokensData.tokens?.totalInput || 0,
|
|
||||||
totalOutput: tokensData.tokens?.totalOutput || 0
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = `Error cargando datos: ${e.message}`
|
|
||||||
messages.value = []
|
|
||||||
stats.value = null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ──
|
|
||||||
|
|
||||||
watch(activeSessionId, (id) => {
|
|
||||||
if (id) fetchSessionData(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchSessions()
|
|
||||||
if (sessions.value.length > 0) {
|
|
||||||
activeSessionId.value = sessions.value[0].id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="conversation-history">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="history-header">
|
|
||||||
<span class="history-title">Historial</span>
|
|
||||||
<span class="history-count">{{ messages.length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Session Pills -->
|
|
||||||
<div v-if="sessions.length > 1" class="session-pills">
|
|
||||||
<button
|
|
||||||
v-for="session in sessions"
|
|
||||||
:key="session.id"
|
|
||||||
class="session-pill"
|
|
||||||
:class="{ active: session.id === activeSessionId }"
|
|
||||||
@click="activeSessionId = session.id"
|
|
||||||
>
|
|
||||||
{{ formatSessionTime(session.startTime) }}
|
|
||||||
<span class="pill-count">({{ session.messageCount }})</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="loading" class="history-loading">
|
|
||||||
<span class="loading-dots">...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<div v-else-if="error" class="history-error">{{ error }}</div>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<div v-else-if="messages.length === 0 && !loading" class="history-empty">
|
|
||||||
Sin mensajes en esta sesión
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message List -->
|
|
||||||
<div v-else class="history-list">
|
|
||||||
<div
|
|
||||||
v-for="msg in messages"
|
|
||||||
:key="msg.uuid"
|
|
||||||
class="history-entry"
|
|
||||||
:class="msg.role === 'assistant' ? 'agent' : 'user'"
|
|
||||||
>
|
|
||||||
<div class="entry-meta">
|
|
||||||
<span
|
|
||||||
class="role-badge"
|
|
||||||
:class="msg.role === 'assistant' ? 'agent' : 'user'"
|
|
||||||
>
|
|
||||||
{{ msg.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
|
|
||||||
</span>
|
|
||||||
<span class="entry-time">{{ formatSessionTime(msg.timestamp) }}</span>
|
|
||||||
<span v-if="msg.toolCalls && msg.toolCalls.length > 0" class="tool-badge">
|
|
||||||
{{ msg.toolCalls.length }} tools
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="entry-content">{{ truncateContent(msg.content) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Bar -->
|
|
||||||
<div v-if="stats && !loading && messages.length > 0" class="stats-bar">
|
|
||||||
<span>{{ shortModel(stats.model) }}</span>
|
|
||||||
<span class="stats-sep">·</span>
|
|
||||||
<span>{{ formatDuration(stats.duration) }}</span>
|
|
||||||
<span class="stats-sep">·</span>
|
|
||||||
<span>{{ formatTokens(stats.totalInput + stats.totalOutput) }}</span>
|
|
||||||
<span class="stats-sep">·</span>
|
|
||||||
<span>{{ stats.toolCallCount }} tools</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.conversation-history {
|
|
||||||
margin-top: 8px;
|
|
||||||
animation: slide-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-count {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Session Pills */
|
|
||||||
.session-pills {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-pills::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-pill {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-pill:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-pill.active {
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
border-color: rgba(139, 92, 246, 0.3);
|
|
||||||
color: rgba(139, 92, 246, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-count {
|
|
||||||
opacity: 0.6;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message List */
|
|
||||||
.history-list {
|
|
||||||
max-height: 220px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry.agent {
|
|
||||||
background: rgba(139, 92, 246, 0.06);
|
|
||||||
border-color: rgba(139, 92, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-badge.user {
|
|
||||||
color: rgba(59, 130, 246, 0.9);
|
|
||||||
background: rgba(59, 130, 246, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-badge.agent {
|
|
||||||
color: rgba(139, 92, 246, 0.9);
|
|
||||||
background: rgba(139, 92, 246, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-time {
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-badge {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: rgba(245, 158, 11, 0.9);
|
|
||||||
background: rgba(245, 158, 11, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-content {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats Bar */
|
|
||||||
.stats-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-sep {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* States */
|
|
||||||
.history-loading {
|
|
||||||
padding: 16px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-dots {
|
|
||||||
animation: pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-error {
|
|
||||||
padding: 12px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(239, 68, 68, 0.7);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-empty {
|
|
||||||
padding: 16px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.25);
|
|
||||||
font-size: 11px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-in {
|
|
||||||
from { opacity: 0; transform: translateY(-8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 0.3; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,590 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onBeforeUnmount } from 'vue'
|
|
||||||
import type { Agent, AgentStatusState } from '../../types/agent'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
agent: Agent
|
|
||||||
status: AgentStatusState | undefined
|
|
||||||
recording?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
click: [event: MouseEvent]
|
|
||||||
hold: [el: HTMLElement]
|
|
||||||
holdrelease: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const HOLD_MS = 200
|
|
||||||
let holdTimer: number | null = null
|
|
||||||
let didHold = false
|
|
||||||
let holdTarget: HTMLElement | null = null
|
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
didHold = false
|
|
||||||
holdTarget = e.currentTarget as HTMLElement
|
|
||||||
holdTarget.setPointerCapture(e.pointerId)
|
|
||||||
holdTimer = window.setTimeout(() => {
|
|
||||||
didHold = true
|
|
||||||
emit('hold', holdTarget!)
|
|
||||||
}, HOLD_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp(e: PointerEvent) {
|
|
||||||
clearHold()
|
|
||||||
if (didHold) {
|
|
||||||
emit('holdrelease')
|
|
||||||
} else {
|
|
||||||
emit('click', e as unknown as MouseEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerCancel() {
|
|
||||||
clearHold()
|
|
||||||
if (didHold) {
|
|
||||||
emit('holdrelease')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearHold() {
|
|
||||||
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(clearHold)
|
|
||||||
|
|
||||||
function bubbleClasses() {
|
|
||||||
const s = props.status
|
|
||||||
const base: Record<string, boolean> = { recording: !!props.recording }
|
|
||||||
if (!s) return base
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
processing: s.isProcessing && !s.isReading && !s.isWriting && !s.awaitingPermission,
|
|
||||||
reading: s.isReading,
|
|
||||||
writing: s.isWriting,
|
|
||||||
permission: s.awaitingPermission,
|
|
||||||
notification: s.showNotification,
|
|
||||||
'tool-flash': s.showToolFlash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubbleStyle() {
|
|
||||||
const c = props.agent.uiConfig?.color || '#6366f1'
|
|
||||||
const s = props.status
|
|
||||||
|
|
||||||
if (props.recording) {
|
|
||||||
return {
|
|
||||||
background: props.agent.uiConfig?.gradient || c,
|
|
||||||
borderColor: 'rgba(239, 68, 68, 0.7)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s?.awaitingPermission) {
|
|
||||||
return { background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }
|
|
||||||
}
|
|
||||||
if (s?.isWriting) {
|
|
||||||
return { background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' }
|
|
||||||
}
|
|
||||||
if (s?.isReading) {
|
|
||||||
return { background: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' }
|
|
||||||
}
|
|
||||||
if (s?.isProcessing) {
|
|
||||||
return { background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
background: props.agent.uiConfig?.gradient || c,
|
|
||||||
boxShadow: `0 4px 15px ${c}66, 0 8px 30px ${c}4D, inset 0 1px 0 rgba(255,255,255,0.2)`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubbleHoverShadow() {
|
|
||||||
const c = props.agent.uiConfig?.color || '#6366f1'
|
|
||||||
return `0 8px 25px ${c}80, 0 15px 40px ${c}59, inset 0 1px 0 rgba(255,255,255,0.25)`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAnimating(): boolean {
|
|
||||||
const s = props.status
|
|
||||||
if (!s) return false
|
|
||||||
return s.isProcessing || s.isReading || s.isWriting || s.awaitingPermission || s.showNotification || s.showToolFlash
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubbleTitle() {
|
|
||||||
const s = props.status
|
|
||||||
const a = props.agent
|
|
||||||
if (s?.awaitingPermission) {
|
|
||||||
return `Permiso requerido: ${s.currentTool || 'herramienta'}`
|
|
||||||
}
|
|
||||||
if (s?.isProcessing) {
|
|
||||||
return `${a.uiConfig?.label}: ${s.currentTool || 'processing'}`
|
|
||||||
}
|
|
||||||
return a.uiConfig?.label || a.name
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
class="agent-bubble"
|
|
||||||
:class="bubbleClasses()"
|
|
||||||
:data-agent="agent.id"
|
|
||||||
:style="bubbleStyle()"
|
|
||||||
:title="bubbleTitle()"
|
|
||||||
@pointerdown.prevent="onPointerDown"
|
|
||||||
@pointerup="onPointerUp"
|
|
||||||
@pointercancel="onPointerCancel"
|
|
||||||
@touchstart.prevent
|
|
||||||
@contextmenu.prevent
|
|
||||||
@selectstart.prevent
|
|
||||||
@dragstart.prevent
|
|
||||||
@mouseenter="!isAnimating() && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleHoverShadow())"
|
|
||||||
@mouseleave="!isAnimating() && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleStyle().boxShadow || '')"
|
|
||||||
>
|
|
||||||
<!-- Recording: audio bars -->
|
|
||||||
<div v-if="recording" class="audio-bars">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Permission alert icon -->
|
|
||||||
<svg v-else-if="status?.awaitingPermission" class="bubble-status-icon permission-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Ejecutor processing: ember ring -->
|
|
||||||
<div v-else-if="agent.id === 'ejecutor' && status?.isProcessing && !status?.isReading && !status?.isWriting" class="ember-ring">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Default processing: thinking dots -->
|
|
||||||
<div v-else-if="status?.isProcessing && !status?.isReading && !status?.isWriting" class="thinking-dots">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reading icon (eye) -->
|
|
||||||
<svg v-else-if="status?.isReading" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Writing icon (pencil) -->
|
|
||||||
<svg v-else-if="status?.isWriting" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
|
|
||||||
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
|
|
||||||
<path d="M2 2l7.586 7.586"/>
|
|
||||||
<circle cx="11" cy="11" r="2"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Default: agent label -->
|
|
||||||
<span v-else class="bubble-label">{{ agent.uiConfig?.shortLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.agent-bubble {
|
|
||||||
pointer-events: auto;
|
|
||||||
width: 58px;
|
|
||||||
height: 58px;
|
|
||||||
border-radius: 18px;
|
|
||||||
color: white;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
overflow: visible;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
touch-action: none;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover glow ring */
|
|
||||||
.agent-bubble::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -3px;
|
|
||||||
border-radius: 21px;
|
|
||||||
background: inherit;
|
|
||||||
filter: blur(8px);
|
|
||||||
z-index: -1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble:hover {
|
|
||||||
transform: translateY(-3px) scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble:hover::before {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
transition-duration: 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== RECORDING STATE ====================== */
|
|
||||||
|
|
||||||
.agent-bubble.recording {
|
|
||||||
animation: rec-glow 1.5s ease-in-out infinite !important;
|
|
||||||
border-color: rgba(239, 68, 68, 0.7) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-bars {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 3px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-bars span {
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: #fff;
|
|
||||||
animation: audio-bar 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-bars span:nth-child(1) { height: 8px; animation-delay: 0s; }
|
|
||||||
.audio-bars span:nth-child(2) { height: 16px; animation-delay: 0.15s; }
|
|
||||||
.audio-bars span:nth-child(3) { height: 22px; animation-delay: 0.3s; }
|
|
||||||
.audio-bars span:nth-child(4) { height: 14px; animation-delay: 0.45s; }
|
|
||||||
.audio-bars span:nth-child(5) { height: 10px; animation-delay: 0.6s; }
|
|
||||||
|
|
||||||
@keyframes audio-bar {
|
|
||||||
0%, 100% { transform: scaleY(0.4); opacity: 0.5; }
|
|
||||||
50% { transform: scaleY(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rec-glow {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 0 rgba(239, 68, 68, 0.5),
|
|
||||||
0 4px 15px rgba(239, 68, 68, 0.3) !important;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 8px rgba(239, 68, 68, 0),
|
|
||||||
0 4px 25px rgba(239, 68, 68, 0.6) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== STATUS ANIMATIONS (DEFAULT) ====================== */
|
|
||||||
|
|
||||||
.agent-bubble.processing,
|
|
||||||
.agent-bubble.reading,
|
|
||||||
.agent-bubble.writing,
|
|
||||||
.agent-bubble.permission,
|
|
||||||
.agent-bubble.notification {
|
|
||||||
transition: background 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble.processing {
|
|
||||||
animation: ab-processing-pulse 2s ease-in-out infinite !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble.reading {
|
|
||||||
animation: ab-reading-scan 1.5s ease-in-out infinite !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble.writing {
|
|
||||||
animation: ab-writing-pulse 0.8s ease-in-out infinite !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble.permission {
|
|
||||||
animation: ab-permission-pulse 1s ease-in-out infinite !important;
|
|
||||||
transform: scale(1.1) !important;
|
|
||||||
z-index: 10000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble.notification {
|
|
||||||
animation: ab-notification-bounce 0.5s ease-in-out 4 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble.tool-flash::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -4px;
|
|
||||||
border-radius: 22px;
|
|
||||||
background: rgba(255, 255, 255, 0.4);
|
|
||||||
animation: ab-tool-flash 0.5s ease-out forwards !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thinking dots */
|
|
||||||
.thinking-dots {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots span {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: ab-thinking-dot 1.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
|
|
||||||
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
/* Status icons */
|
|
||||||
.bubble-status-icon {
|
|
||||||
animation: ab-icon-breathe 1s ease-in-out infinite;
|
|
||||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.permission-icon {
|
|
||||||
animation: ab-permission-shake 0.5s ease-in-out infinite;
|
|
||||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.8));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== EJECUTOR UNIQUE ANIMATIONS ====================== */
|
|
||||||
|
|
||||||
.agent-bubble[data-agent="ejecutor"].processing {
|
|
||||||
animation: ej-heartbeat 1.2s ease-in-out infinite !important;
|
|
||||||
border-color: rgba(255, 100, 50, 0.5) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble[data-agent="ejecutor"].reading {
|
|
||||||
animation: ej-infrared 1s linear infinite !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble[data-agent="ejecutor"].writing {
|
|
||||||
animation: ej-forge 0.6s ease-in-out infinite !important;
|
|
||||||
border-color: rgba(255, 200, 50, 0.6) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble[data-agent="ejecutor"].notification {
|
|
||||||
animation: ej-glitch 0.15s steps(2) 8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble[data-agent="ejecutor"].tool-flash::after {
|
|
||||||
background: rgba(239, 68, 68, 0.5) !important;
|
|
||||||
animation: ej-shockwave 0.6s ease-out forwards !important;
|
|
||||||
border: 1px solid rgba(255, 100, 50, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble[data-agent="ejecutor"].tool-flash::before {
|
|
||||||
content: '' !important;
|
|
||||||
position: absolute !important;
|
|
||||||
inset: -2px !important;
|
|
||||||
border-radius: 20px !important;
|
|
||||||
background: rgba(255, 150, 50, 0.3) !important;
|
|
||||||
filter: none !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
animation: ej-shockwave-inner 0.4s ease-out forwards !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
z-index: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ember ring */
|
|
||||||
.ember-ring {
|
|
||||||
position: relative;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ember-ring span {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ember-ring span:nth-child(1) {
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-right-color: rgba(255, 200, 50, 0.8);
|
|
||||||
animation: ej-ember-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ember-ring span:nth-child(2) {
|
|
||||||
border-bottom-color: rgba(255, 100, 50, 0.9);
|
|
||||||
border-left-color: rgba(255, 50, 50, 0.6);
|
|
||||||
animation: ej-ember-spin 1.2s linear infinite reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== EJECUTOR KEYFRAMES ====================== */
|
|
||||||
|
|
||||||
@keyframes ej-heartbeat {
|
|
||||||
0% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
|
||||||
14% { transform: scale(1.15) !important; box-shadow: 0 4px 35px rgba(239, 68, 68, 0.7) !important; }
|
|
||||||
28% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
|
||||||
42% { transform: scale(1.22) !important; box-shadow: 0 4px 45px rgba(255, 100, 50, 0.8) !important; }
|
|
||||||
70% { transform: scale(1) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2) !important; }
|
|
||||||
100% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ej-infrared {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
|
|
||||||
transform: rotate(0deg) !important;
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
box-shadow: 8px 0 20px rgba(239, 68, 68, 0.6), -8px 0 20px rgba(239, 68, 68, 0.1) !important;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.6), 0 -8px 20px rgba(239, 68, 68, 0.1) !important;
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
box-shadow: -8px 0 20px rgba(239, 68, 68, 0.6), 8px 0 20px rgba(239, 68, 68, 0.1) !important;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ej-forge {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1) !important;
|
|
||||||
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4) !important;
|
|
||||||
filter: brightness(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.06) !important;
|
|
||||||
box-shadow: 0 4px 30px rgba(255, 200, 50, 0.8), 0 0 15px rgba(255, 255, 255, 0.3) !important;
|
|
||||||
filter: brightness(1.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ej-glitch {
|
|
||||||
0% { transform: translate(0, 0) skewX(0deg) !important; }
|
|
||||||
25% { transform: translate(-3px, 1px) skewX(-4deg) !important; box-shadow: -3px 0 8px rgba(0, 255, 255, 0.4), 3px 0 8px rgba(255, 0, 50, 0.4) !important; }
|
|
||||||
50% { transform: translate(2px, -2px) skewX(3deg) !important; box-shadow: 3px 0 8px rgba(0, 255, 255, 0.4), -3px 0 8px rgba(255, 0, 50, 0.4) !important; }
|
|
||||||
75% { transform: translate(-1px, 2px) skewX(-2deg) !important; }
|
|
||||||
100%{ transform: translate(0, 0) skewX(0deg) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ej-shockwave {
|
|
||||||
0% { opacity: 1; transform: scale(1); }
|
|
||||||
100% { opacity: 0; transform: scale(1.6); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ej-shockwave-inner {
|
|
||||||
0% { opacity: 0.8; transform: scale(1); }
|
|
||||||
100% { opacity: 0; transform: scale(1.3); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ej-ember-spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== DEFAULT KEYFRAMES ====================== */
|
|
||||||
|
|
||||||
@keyframes ab-processing-pulse {
|
|
||||||
0%, 100% { box-shadow: 0 8px 24px rgba(245, 158, 11, 0.4) !important; }
|
|
||||||
50% { box-shadow: 0 8px 40px rgba(245, 158, 11, 0.8) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-reading-scan {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4) !important;
|
|
||||||
transform: rotate(0deg) !important;
|
|
||||||
}
|
|
||||||
25% { transform: rotate(-3deg) !important; }
|
|
||||||
75% { transform: rotate(3deg) !important; }
|
|
||||||
50% { box-shadow: 0 8px 40px rgba(6, 182, 212, 0.8) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-writing-pulse {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1) !important;
|
|
||||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4) !important;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.08) !important;
|
|
||||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.7) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-permission-pulse {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7) !important;
|
|
||||||
transform: scale(1.1) !important;
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0) !important;
|
|
||||||
transform: scale(1.05) !important;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0) !important;
|
|
||||||
transform: scale(1.1) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-permission-shake {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-2px); }
|
|
||||||
75% { transform: translateX(2px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-notification-bounce {
|
|
||||||
0%, 100% { transform: translateY(0) !important; }
|
|
||||||
50% { transform: translateY(-10px) !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-tool-flash {
|
|
||||||
0% { opacity: 1; transform: scale(1); }
|
|
||||||
100% { opacity: 0; transform: scale(1.4); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-thinking-dot {
|
|
||||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
|
|
||||||
40% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes ab-icon-breathe {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.7; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent touch selection on all children */
|
|
||||||
.agent-bubble * {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ====================== LABELS ====================== */
|
|
||||||
|
|
||||||
.bubble-label {
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 15px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.agent-bubble {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-bubble::before {
|
|
||||||
border-radius: 19px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-label {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { VoiceCapture } from '../../composables/useVoiceCapture'
|
|
||||||
|
|
||||||
interface SessionInfo {
|
|
||||||
id: string
|
|
||||||
startTime: string
|
|
||||||
messageCount: number
|
|
||||||
model: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
voice: VoiceCapture
|
|
||||||
sessions?: SessionInfo[]
|
|
||||||
activeSessionId?: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
'select-session': [sessionId: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function handleMicSelect(e: Event) {
|
|
||||||
const target = e.target as HTMLSelectElement
|
|
||||||
props.voice.selectMicrophone(target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSessionSelect(e: Event) {
|
|
||||||
const target = e.target as HTMLSelectElement
|
|
||||||
emit('select-session', target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSessionLabel(s: SessionInfo): string {
|
|
||||||
if (!s.startTime) return `${s.id.slice(0, 8)}... (${s.messageCount} msgs)`
|
|
||||||
const d = new Date(s.startTime)
|
|
||||||
const date = d.toLocaleDateString('es', { month: 'short', day: 'numeric' })
|
|
||||||
const time = d.toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
return `${date} ${time} · ${s.messageCount} msgs`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="input-settings">
|
|
||||||
<div class="is-header">
|
|
||||||
<span class="is-title">Voice Settings</span>
|
|
||||||
<button class="is-close" @click="emit('close')">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Session Selector -->
|
|
||||||
<div v-if="sessions && sessions.length > 0" class="is-section">
|
|
||||||
<label class="is-label">Session</label>
|
|
||||||
<select
|
|
||||||
class="is-select"
|
|
||||||
:value="activeSessionId || ''"
|
|
||||||
@change="handleSessionSelect"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="session in sessions"
|
|
||||||
:key="session.id"
|
|
||||||
:value="session.id"
|
|
||||||
>
|
|
||||||
{{ formatSessionLabel(session) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Whisper Status -->
|
|
||||||
<div class="is-section">
|
|
||||||
<label class="is-label">Mode</label>
|
|
||||||
<div class="is-mode-row">
|
|
||||||
<div
|
|
||||||
class="is-mode-btn active"
|
|
||||||
>
|
|
||||||
<span class="is-mode-icon">GPU</span>
|
|
||||||
<span class="is-mode-label">Whisper</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="is-status">
|
|
||||||
<span
|
|
||||||
class="is-status-dot"
|
|
||||||
:class="{
|
|
||||||
ready: voice.whisperStatus.value === 'ready',
|
|
||||||
loading: voice.whisperStatus.value === 'loading',
|
|
||||||
offline: voice.whisperStatus.value === 'offline'
|
|
||||||
}"
|
|
||||||
></span>
|
|
||||||
<span class="is-status-text">
|
|
||||||
{{ voice.whisperStatus.value === 'ready' ? 'Whisper ready' : voice.whisperStatus.value === 'loading' ? 'Starting...' : 'Offline' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Microphone Selection -->
|
|
||||||
<div class="is-section">
|
|
||||||
<label class="is-label">Microphone</label>
|
|
||||||
<select
|
|
||||||
class="is-select"
|
|
||||||
:value="voice.selectedDeviceId.value"
|
|
||||||
@change="handleMicSelect"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="device in voice.audioDevices.value"
|
|
||||||
:key="device.deviceId"
|
|
||||||
:value="device.deviceId"
|
|
||||||
>
|
|
||||||
{{ device.label || `Microphone ${voice.audioDevices.value.indexOf(device) + 1}` }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Debug: Last Audio Playback -->
|
|
||||||
<div class="is-section">
|
|
||||||
<label class="is-label">Debug</label>
|
|
||||||
<button
|
|
||||||
class="is-debug-btn"
|
|
||||||
:class="{ playing: voice.isPlayingAudio.value }"
|
|
||||||
:disabled="!voice.lastAudioUrl.value"
|
|
||||||
@click="voice.playLastAudio()"
|
|
||||||
>
|
|
||||||
<svg v-if="!voice.isPlayingAudio.value" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
||||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
||||||
<rect x="6" y="4" width="4" height="16" rx="1"/>
|
|
||||||
<rect x="14" y="4" width="4" height="16" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ voice.isPlayingAudio.value ? 'Stop' : 'Play last audio' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.input-settings {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
animation: settings-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-close {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-close:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-section {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-section:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 8px 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-btn:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-btn.active {
|
|
||||||
background: rgba(139, 92, 246, 0.15);
|
|
||||||
border-color: rgba(139, 92, 246, 0.3);
|
|
||||||
color: rgba(139, 92, 246, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-btn.loading {
|
|
||||||
border-color: rgba(251, 191, 36, 0.3);
|
|
||||||
color: rgba(251, 191, 36, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-icon {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-mode-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-status-dot.ready {
|
|
||||||
background: #22c55e;
|
|
||||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-status-dot.loading {
|
|
||||||
background: #fbbf24;
|
|
||||||
animation: status-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-status-dot.offline {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-status-text {
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-select {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.3)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 8px center;
|
|
||||||
padding-right: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-select:focus {
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-select option {
|
|
||||||
background: #1a1025;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-debug-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-debug-btn:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-debug-btn:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-debug-btn.playing {
|
|
||||||
background: rgba(59, 130, 246, 0.12);
|
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
|
||||||
color: rgba(59, 130, 246, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes settings-in {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes status-pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,142 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
thinkDelay?: number
|
|
||||||
}>(), {
|
|
||||||
thinkDelay: 1500
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
done: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const thinking = ref(true)
|
|
||||||
let timerId: number | null = null
|
|
||||||
|
|
||||||
const MOCK_RESPONSE = `He revisado el componente de autenticación y encontré el problema. El flujo actual de invalidación de tokens funciona así:
|
|
||||||
|
|
||||||
1. **Servidor** invalida el token en la base de datos al cerrar sesión
|
|
||||||
2. **Cliente** sigue usando el JWT almacenado en localStorage hasta que expira
|
|
||||||
|
|
||||||
La solución que implementé usa un canal WebSocket dedicado para notificaciones de sesión. Cuando un token se revoca, el servidor envía un evento \`session:revoked\` al cliente correspondiente, que automáticamente limpia el estado y redirige al login.
|
|
||||||
|
|
||||||
Los archivos modificados fueron:
|
|
||||||
- \`src/auth/tokenValidator.ts\` — verificación en tiempo real
|
|
||||||
- \`src/ws/sessionChannel.ts\` — canal de notificaciones
|
|
||||||
- \`src/stores/auth.ts\` — listener de revocación`
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
timerId = window.setTimeout(() => {
|
|
||||||
thinking.value = false
|
|
||||||
emit('done')
|
|
||||||
}, props.thinkDelay)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (timerId) clearTimeout(timerId)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="response-card">
|
|
||||||
<!-- Thinking state -->
|
|
||||||
<div v-if="thinking" class="thinking-state">
|
|
||||||
<div class="thinking-dots">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<span class="thinking-label">Pensando...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Response -->
|
|
||||||
<div v-else class="response-body fade-in">
|
|
||||||
<div class="response-badge">Agente</div>
|
|
||||||
<div class="response-text" v-html="MOCK_RESPONSE.replace(/\n/g, '<br>')"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.response-card {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-state {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots span {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: dot-bounce 1.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
|
|
||||||
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
.thinking-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-body {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: rgba(139, 92, 246, 0.9);
|
|
||||||
background: rgba(139, 92, 246, 0.12);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-text {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.response-text :deep(strong) {
|
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
animation: fade-in 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dot-bounce {
|
|
||||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
|
||||||
40% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import type { VoiceCapture } from '../../composables/useVoiceCapture'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
voice: VoiceCapture
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
done: [text: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function handleStop() {
|
|
||||||
props.voice.stopRecording()
|
|
||||||
// Brief delay for final Whisper result
|
|
||||||
const delay = props.voice.voiceMode.value === 'whisper' ? 800 : 200
|
|
||||||
setTimeout(() => {
|
|
||||||
emit('done', props.voice.transcript.value.trim())
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!props.voice.isRecording.value) {
|
|
||||||
props.voice.clearTranscript()
|
|
||||||
props.voice.startRecording()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (props.voice.isRecording.value) {
|
|
||||||
props.voice.stopRecording()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="transcript-card">
|
|
||||||
<div class="transcript-header">
|
|
||||||
<div class="transcript-header-left">
|
|
||||||
<span class="rec-dot"></span>
|
|
||||||
<span class="rec-label">Transcribiendo...</span>
|
|
||||||
<span class="mode-badge" :class="voice.voiceMode.value">
|
|
||||||
{{ voice.voiceMode.value === 'whisper' ? 'GPU' : 'Web' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button class="stop-btn" @click="handleStop" title="Stop recording">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
||||||
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
|
||||||
<div v-if="voice.error.value" class="transcript-error">
|
|
||||||
{{ voice.error.value }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transcript body -->
|
|
||||||
<div v-else class="transcript-body">
|
|
||||||
<span class="transcript-text">{{ voice.animatedTranscript.value }}</span>
|
|
||||||
<span v-if="voice.interimTranscript.value" class="interim-text">{{ voice.interimTranscript.value }}</span>
|
|
||||||
<span class="blink-cursor">|</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.transcript-card {
|
|
||||||
background: rgba(239, 68, 68, 0.08);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rec-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #ef4444;
|
|
||||||
box-shadow: 0 0 8px #ef4444;
|
|
||||||
animation: rec-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rec-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: rgba(239, 68, 68, 0.9);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-badge {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-badge.whisper {
|
|
||||||
background: rgba(139, 92, 246, 0.2);
|
|
||||||
color: rgba(139, 92, 246, 0.9);
|
|
||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-badge.webspeech {
|
|
||||||
background: rgba(59, 130, 246, 0.2);
|
|
||||||
color: rgba(59, 130, 246, 0.9);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #ef4444;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-btn:hover {
|
|
||||||
background: rgba(239, 68, 68, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-body {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-text {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interim-text {
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-error {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(239, 68, 68, 0.8);
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blink-cursor {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
animation: cursor-blink 0.8s step-end infinite;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rec-pulse {
|
|
||||||
0%, 100% { opacity: 1; box-shadow: 0 0 8px #ef4444; }
|
|
||||||
50% { opacity: 0.4; box-shadow: 0 0 16px #ef4444; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cursor-blink {
|
|
||||||
0%, 50% { opacity: 1; }
|
|
||||||
51%, 100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -73,8 +73,6 @@ function toggleSelectMode() {
|
|||||||
if (!selectMode.value) selectedUuids.value = new Set()
|
if (!selectMode.value) selectedUuids.value = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ selectMode, toggleSelectMode })
|
|
||||||
|
|
||||||
function toggleSelect(uuid: string) {
|
function toggleSelect(uuid: string) {
|
||||||
if (!selectMode.value) return
|
if (!selectMode.value) return
|
||||||
const s = new Set(selectedUuids.value)
|
const s = new Set(selectedUuids.value)
|
||||||
@@ -143,12 +141,21 @@ async function copySelected() {
|
|||||||
// ── Collapse sections ──
|
// ── Collapse sections ──
|
||||||
const collapsedSections = ref(new Set<string>())
|
const collapsedSections = ref(new Set<string>())
|
||||||
|
|
||||||
|
// Special user messages (interrupted, meta) are NOT section leaders —
|
||||||
|
// they belong to the previous normal user message's section.
|
||||||
|
function isSpecialUserMessage(msg: ConversationMessage): boolean {
|
||||||
|
if (msg.kind !== 'user') return false
|
||||||
|
if (msg.isMeta) return true
|
||||||
|
if (msg.content?.includes('[Request interrupted by user')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// For each message, find which user message "owns" it (section leader)
|
// For each message, find which user message "owns" it (section leader)
|
||||||
const sectionMap = computed(() => {
|
const sectionMap = computed(() => {
|
||||||
const map = new Map<string, string>() // messageUuid → ownerUserUuid
|
const map = new Map<string, string>() // messageUuid → ownerUserUuid
|
||||||
let currentUserUuid: string | null = null
|
let currentUserUuid: string | null = null
|
||||||
for (const msg of props.conversation.messages) {
|
for (const msg of props.conversation.messages) {
|
||||||
if (msg.kind === 'user') {
|
if (msg.kind === 'user' && !isSpecialUserMessage(msg)) {
|
||||||
currentUserUuid = msg.uuid
|
currentUserUuid = msg.uuid
|
||||||
} else if (currentUserUuid) {
|
} else if (currentUserUuid) {
|
||||||
map.set(msg.uuid, currentUserUuid)
|
map.set(msg.uuid, currentUserUuid)
|
||||||
@@ -157,12 +164,12 @@ const sectionMap = computed(() => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
// Count of non-user messages per section
|
// Count of non-leader messages per section
|
||||||
const sectionCounts = computed(() => {
|
const sectionCounts = computed(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
let currentUserUuid: string | null = null
|
let currentUserUuid: string | null = null
|
||||||
for (const msg of props.conversation.messages) {
|
for (const msg of props.conversation.messages) {
|
||||||
if (msg.kind === 'user') {
|
if (msg.kind === 'user' && !isSpecialUserMessage(msg)) {
|
||||||
currentUserUuid = msg.uuid
|
currentUserUuid = msg.uuid
|
||||||
if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0)
|
if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0)
|
||||||
} else if (currentUserUuid) {
|
} else if (currentUserUuid) {
|
||||||
@@ -180,11 +187,38 @@ function toggleCollapse(userUuid: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCollapsedChild(msg: { uuid: string; kind: string }): boolean {
|
function isCollapsedChild(msg: { uuid: string; kind: string }): boolean {
|
||||||
if (msg.kind === 'user') return false
|
// Special user messages collapse with their section like any other child
|
||||||
|
if (msg.kind === 'user' && !isSpecialUserMessage(msg as ConversationMessage)) return false
|
||||||
const owner = sectionMap.value.get(msg.uuid)
|
const owner = sectionMap.value.get(msg.uuid)
|
||||||
return !!owner && collapsedSections.value.has(owner)
|
return !!owner && collapsedSections.value.has(owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapse all user sections except the last one (only normal user messages)
|
||||||
|
const userUuids = computed(() =>
|
||||||
|
props.conversation.messages
|
||||||
|
.filter(m => m.kind === 'user' && !isSpecialUserMessage(m))
|
||||||
|
.map(m => m.uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
const allCollapsed = computed(() => {
|
||||||
|
const uuids = userUuids.value
|
||||||
|
if (uuids.length <= 1) return false
|
||||||
|
const toCollapse = uuids.slice(0, -1)
|
||||||
|
return toCollapse.length > 0 && toCollapse.every(u => collapsedSections.value.has(u))
|
||||||
|
})
|
||||||
|
|
||||||
|
function collapseAllExceptLast() {
|
||||||
|
const uuids = userUuids.value
|
||||||
|
if (uuids.length <= 1) return
|
||||||
|
if (allCollapsed.value) {
|
||||||
|
collapsedSections.value = new Set()
|
||||||
|
} else {
|
||||||
|
collapsedSections.value = new Set(uuids.slice(0, -1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ selectMode, toggleSelectMode, allCollapsed, collapseAllExceptLast })
|
||||||
|
|
||||||
// Track messages that just resolved from optimistic → real
|
// Track messages that just resolved from optimistic → real
|
||||||
// These skip the bounce animation and get a smooth transition instead
|
// These skip the bounce animation and get a smooth transition instead
|
||||||
const resolvedUuids = ref(new Set<string>())
|
const resolvedUuids = ref(new Set<string>())
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
/**
|
|
||||||
* useAgentTerminal
|
|
||||||
*
|
|
||||||
* Composable for managing a per-agent terminal session.
|
|
||||||
* Wraps useTerminalRenderer with agent-specific WebSocket connection,
|
|
||||||
* agent lifecycle (start/stop), and prompt sending.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, computed, type Ref } from 'vue'
|
|
||||||
import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer'
|
|
||||||
import { agentTerminalUrl, terminalApiUrl } from '../config/endpoints'
|
|
||||||
|
|
||||||
export type AgentTerminalState = 'off' | 'connecting' | 'ready' | 'crashed' | 'agent-starting'
|
|
||||||
|
|
||||||
export interface AgentTerminal {
|
|
||||||
// State
|
|
||||||
terminalState: Ref<AgentTerminalState>
|
|
||||||
connected: Ref<boolean>
|
|
||||||
agentRunning: Ref<boolean>
|
|
||||||
sessionId: Ref<string | null>
|
|
||||||
|
|
||||||
// Container ref - bind this to the xterm DOM element in the component
|
|
||||||
containerRef: Ref<HTMLElement | null>
|
|
||||||
|
|
||||||
// Terminal renderer (for mounting xterm)
|
|
||||||
renderer: TerminalRenderer
|
|
||||||
|
|
||||||
// Connection
|
|
||||||
connect: () => void
|
|
||||||
disconnect: () => void
|
|
||||||
|
|
||||||
// Agent lifecycle
|
|
||||||
startAgent: (force?: boolean) => Promise<void>
|
|
||||||
stopAgent: () => Promise<void>
|
|
||||||
checkStatus: () => Promise<void>
|
|
||||||
|
|
||||||
// Prompt
|
|
||||||
sendPrompt: (text: string) => void
|
|
||||||
|
|
||||||
// Direct PTY input (char-by-char, no agent auto-start)
|
|
||||||
sendInput: (text: string) => void
|
|
||||||
|
|
||||||
// Raw PTY input (single write, no \r appended)
|
|
||||||
sendRaw: (data: string) => void
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
dispose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentTerminal(agentId: string): AgentTerminal {
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
|
||||||
const connected = ref(false)
|
|
||||||
const connecting = ref(false)
|
|
||||||
const agentRunning = ref(false)
|
|
||||||
const agentStarting = ref(false)
|
|
||||||
const crashed = ref(false)
|
|
||||||
const sessionId = ref<string | null>(null)
|
|
||||||
|
|
||||||
let socket: WebSocket | null = null
|
|
||||||
let reconnectTimeout: number | null = null
|
|
||||||
let reconnectAttempts = 0
|
|
||||||
let pendingPrompt: string | null = null
|
|
||||||
const MAX_RECONNECT = 10
|
|
||||||
const RECONNECT_DELAY = 2000
|
|
||||||
|
|
||||||
const terminalState = computed<AgentTerminalState>(() => {
|
|
||||||
if (agentStarting.value) return 'agent-starting'
|
|
||||||
if (crashed.value) return 'crashed'
|
|
||||||
if (connected.value && agentRunning.value) return 'ready'
|
|
||||||
if (connecting.value) return 'connecting'
|
|
||||||
return 'off'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Terminal renderer
|
|
||||||
const renderer = useTerminalRenderer({
|
|
||||||
container: containerRef,
|
|
||||||
onData: (data) => {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'input', data }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onResize: (cols, rows) => {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onKeyEvent: (e) => {
|
|
||||||
// Ctrl+V: Paste
|
|
||||||
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
|
|
||||||
e.preventDefault()
|
|
||||||
navigator.clipboard.readText().then((text) => {
|
|
||||||
if (text && socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'input', data: text }))
|
|
||||||
}
|
|
||||||
}).catch(console.error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Ctrl+C: Copy selection
|
|
||||||
if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') {
|
|
||||||
const selection = renderer.getSelection()
|
|
||||||
if (selection) {
|
|
||||||
navigator.clipboard.writeText(selection).catch(console.error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── WebSocket connection ──
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
if (connecting.value || connected.value) return
|
|
||||||
connecting.value = true
|
|
||||||
crashed.value = false
|
|
||||||
|
|
||||||
const wsUrl = agentTerminalUrl(agentId)
|
|
||||||
console.log(`[AgentTerminal:${agentId}] Connecting to ${wsUrl}`)
|
|
||||||
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
if (connecting.value && !connected.value) {
|
|
||||||
connecting.value = false
|
|
||||||
socket?.close()
|
|
||||||
socket = null
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = new WebSocket(wsUrl)
|
|
||||||
socket.addEventListener('open', () => clearTimeout(timeout), { once: true })
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
connected.value = true
|
|
||||||
connecting.value = false
|
|
||||||
reconnectAttempts = 0
|
|
||||||
|
|
||||||
// Send initial resize
|
|
||||||
const term = renderer.terminal.value
|
|
||||||
if (term) {
|
|
||||||
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
handleMessage(msg)
|
|
||||||
} catch { /* ignore parse errors */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
connected.value = false
|
|
||||||
connecting.value = false
|
|
||||||
socket = null
|
|
||||||
|
|
||||||
if (reconnectAttempts < MAX_RECONNECT) {
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
connecting.value = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
connecting.value = false
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
cancelReconnect()
|
|
||||||
if (socket) {
|
|
||||||
socket.onclose = null
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
connected.value = false
|
|
||||||
connecting.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
|
||||||
reconnectAttempts++
|
|
||||||
const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5)
|
|
||||||
reconnectTimeout = window.setTimeout(() => {
|
|
||||||
if (!connected.value && !connecting.value) {
|
|
||||||
connect()
|
|
||||||
}
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelReconnect() {
|
|
||||||
if (reconnectTimeout) {
|
|
||||||
clearTimeout(reconnectTimeout)
|
|
||||||
reconnectTimeout = null
|
|
||||||
}
|
|
||||||
reconnectAttempts = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Message handling ──
|
|
||||||
|
|
||||||
function handleMessage(msg: any) {
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'connected':
|
|
||||||
sessionId.value = msg.sessionId
|
|
||||||
if (msg.hasHistory) {
|
|
||||||
// Session has existing output, request replay
|
|
||||||
setTimeout(() => requestReplay(), 50)
|
|
||||||
// Check if agent is actually running
|
|
||||||
checkStatus()
|
|
||||||
} else if (msg.isNew) {
|
|
||||||
// Brand new session, auto-start agent
|
|
||||||
autoStartAgent()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'replay':
|
|
||||||
renderer.handleReplay(msg.data || '')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'output':
|
|
||||||
renderer.write(msg.data)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'exit':
|
|
||||||
renderer.write(msg.data)
|
|
||||||
agentRunning.value = false
|
|
||||||
crashed.value = true
|
|
||||||
agentStarting.value = false
|
|
||||||
sessionId.value = null
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'error':
|
|
||||||
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'session-restart':
|
|
||||||
renderer.reset()
|
|
||||||
renderer.writeln('\x1b[33m[Session restarting...]\x1b[0m')
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'claude-status':
|
|
||||||
if (matchesAgent(msg.agent)) {
|
|
||||||
if (msg.status === 'sessionStart') {
|
|
||||||
agentRunning.value = true
|
|
||||||
agentStarting.value = false
|
|
||||||
crashed.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'buffer-cleared':
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesAgent(name: string): boolean {
|
|
||||||
if (!name) return agentId === 'main'
|
|
||||||
return name === agentId || name === 'main' && agentId === 'main'
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestReplay(tailOnly = true) {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'request-replay', tailOnly, chunks: 200 }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent lifecycle ──
|
|
||||||
|
|
||||||
async function autoStartAgent() {
|
|
||||||
agentStarting.value = true
|
|
||||||
try {
|
|
||||||
await startAgent()
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[AgentTerminal:${agentId}] Auto-start failed:`, e)
|
|
||||||
agentStarting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startAgent(force = false) {
|
|
||||||
agentStarting.value = true
|
|
||||||
crashed.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(terminalApiUrl('/start-agent'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ agentId, force })
|
|
||||||
})
|
|
||||||
if (!res.ok) { agentStarting.value = false; return }
|
|
||||||
const contentType = res.headers.get('content-type') || ''
|
|
||||||
if (!contentType.includes('application/json')) { agentStarting.value = false; return }
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.success) {
|
|
||||||
agentRunning.value = true
|
|
||||||
// agentStarting stays true until sessionStart is received
|
|
||||||
// Flush pending prompt
|
|
||||||
if (pendingPrompt) {
|
|
||||||
setTimeout(() => {
|
|
||||||
flushPendingPrompt()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
agentStarting.value = false
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[AgentTerminal:${agentId}] Start failed:`, e)
|
|
||||||
agentStarting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopAgent() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(terminalApiUrl('/stop-agent'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ agentId })
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
agentRunning.value = false
|
|
||||||
agentStarting.value = false
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[AgentTerminal:${agentId}] Stop failed:`, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(terminalApiUrl('/agent-sessions'))
|
|
||||||
if (!res.ok) return
|
|
||||||
const contentType = res.headers.get('content-type') || ''
|
|
||||||
if (!contentType.includes('application/json')) return
|
|
||||||
const data = await res.json()
|
|
||||||
const state = data[agentId]
|
|
||||||
if (state) {
|
|
||||||
agentRunning.value = state.isAgentRunning
|
|
||||||
if (!state.isAgentRunning && state.sessionExists) {
|
|
||||||
// Session exists but agent exited
|
|
||||||
crashed.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[AgentTerminal:${agentId}] Status check failed:`, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Prompt sending ──
|
|
||||||
|
|
||||||
function typeTextToSocket(text: string) {
|
|
||||||
const chars = (text + '\r').split('')
|
|
||||||
let i = 0
|
|
||||||
const typeChar = () => {
|
|
||||||
if (i < chars.length && socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'input', data: chars[i] }))
|
|
||||||
i++
|
|
||||||
setTimeout(typeChar, 15)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
typeChar()
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendPrompt(text: string) {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN && (agentRunning.value || agentStarting.value)) {
|
|
||||||
typeTextToSocket(text)
|
|
||||||
} else if (!connected.value) {
|
|
||||||
// Queue prompt and auto-connect + start
|
|
||||||
pendingPrompt = text
|
|
||||||
connect()
|
|
||||||
} else if (connected.value && !agentRunning.value) {
|
|
||||||
// Connected but agent not running, start it and queue
|
|
||||||
pendingPrompt = text
|
|
||||||
startAgent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendInput(text: string) {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
typeTextToSocket(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendRaw(data: string) {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'input', data }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPendingPrompt() {
|
|
||||||
if (pendingPrompt && socket?.readyState === WebSocket.OPEN) {
|
|
||||||
typeTextToSocket(pendingPrompt)
|
|
||||||
pendingPrompt = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cleanup ──
|
|
||||||
|
|
||||||
function dispose() {
|
|
||||||
disconnect()
|
|
||||||
renderer.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
terminalState,
|
|
||||||
connected,
|
|
||||||
agentRunning,
|
|
||||||
sessionId,
|
|
||||||
containerRef,
|
|
||||||
renderer,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
startAgent,
|
|
||||||
stopAgent,
|
|
||||||
checkStatus,
|
|
||||||
sendPrompt,
|
|
||||||
sendInput,
|
|
||||||
sendRaw,
|
|
||||||
dispose
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
import { ref, watch, type Ref } from 'vue'
|
|
||||||
import {
|
|
||||||
initWhisperSocket,
|
|
||||||
sendAudio,
|
|
||||||
onTranscription,
|
|
||||||
getWhisperStatus,
|
|
||||||
isConnected
|
|
||||||
} from '../services/whisperSocket'
|
|
||||||
export type WhisperStatus = 'offline' | 'loading' | 'ready'
|
|
||||||
|
|
||||||
export interface VoiceCapture {
|
|
||||||
// State
|
|
||||||
isRecording: Ref<boolean>
|
|
||||||
transcript: Ref<string>
|
|
||||||
interimTranscript: Ref<string>
|
|
||||||
animatedTranscript: Ref<string>
|
|
||||||
error: Ref<string>
|
|
||||||
voiceMode: Ref<'whisper'>
|
|
||||||
whisperStatus: Ref<WhisperStatus>
|
|
||||||
audioDevices: Ref<MediaDeviceInfo[]>
|
|
||||||
selectedDeviceId: Ref<string>
|
|
||||||
isAndroid: Ref<boolean>
|
|
||||||
lastAudioUrl: Ref<string>
|
|
||||||
isPlayingAudio: Ref<boolean>
|
|
||||||
// Actions
|
|
||||||
startRecording: () => void
|
|
||||||
stopRecording: () => void
|
|
||||||
loadAudioDevices: (skipPermission?: boolean) => Promise<void>
|
|
||||||
selectMicrophone: (deviceId: string) => void
|
|
||||||
playLastAudio: () => void
|
|
||||||
init: () => Promise<void>
|
|
||||||
cleanup: () => void
|
|
||||||
clearTranscript: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const GPU_TIMEOUT_MS = 30_000 // 30s timeout waiting for GPU
|
|
||||||
|
|
||||||
export function useVoiceCapture(options?: {
|
|
||||||
onNotification?: (message: string, type: 'info' | 'success' | 'error', duration?: number) => void
|
|
||||||
}): VoiceCapture {
|
|
||||||
const notify = options?.onNotification || (() => {})
|
|
||||||
|
|
||||||
// ====== State ======
|
|
||||||
const isRecording = ref(false)
|
|
||||||
const transcript = ref('')
|
|
||||||
const interimTranscript = ref('')
|
|
||||||
const animatedTranscript = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const voiceMode = ref<'whisper'>('whisper') // Always whisper, no web speech
|
|
||||||
const audioDevices = ref<MediaDeviceInfo[]>([])
|
|
||||||
const selectedDeviceId = ref('')
|
|
||||||
const isAndroid = ref(false)
|
|
||||||
const lastAudioUrl = ref('')
|
|
||||||
const isPlayingAudio = ref(false)
|
|
||||||
|
|
||||||
// ====== Internal ======
|
|
||||||
const sharedWhisperStatus = getWhisperStatus()
|
|
||||||
const whisperStatus = ref<WhisperStatus>(sharedWhisperStatus.value)
|
|
||||||
let mediaRecorder: MediaRecorder | null = null
|
|
||||||
let audioChunks: Blob[] = []
|
|
||||||
let chunkInterval: number | null = null
|
|
||||||
const CHUNK_INTERVAL_MS = 3000
|
|
||||||
let mediaStream: MediaStream | null = null
|
|
||||||
let supportedMimeType = 'audio/webm;codecs=opus'
|
|
||||||
let audioElement: HTMLAudioElement | null = null
|
|
||||||
let recordingStartTime = 0
|
|
||||||
let unsubTranscription: (() => void) | null = null
|
|
||||||
let gpuTimeout: number | null = null
|
|
||||||
|
|
||||||
// Typing animation
|
|
||||||
let typingTimeout: number | null = null
|
|
||||||
let lastAnimatedLength = 0
|
|
||||||
|
|
||||||
// Keep local status in sync with shared
|
|
||||||
watch(sharedWhisperStatus, (val) => {
|
|
||||||
whisperStatus.value = val
|
|
||||||
})
|
|
||||||
|
|
||||||
// ====== Mobile / Audio Format ======
|
|
||||||
|
|
||||||
function checkMobile() {
|
|
||||||
isAndroid.value = /Android/i.test(navigator.userAgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectAudioFormat(): string {
|
|
||||||
const formats = [
|
|
||||||
'audio/webm;codecs=opus', 'audio/webm',
|
|
||||||
'audio/mp4', 'audio/mp4;codecs=mp4a.40.2',
|
|
||||||
'audio/aac', 'audio/ogg;codecs=opus', 'audio/wav'
|
|
||||||
]
|
|
||||||
for (const f of formats) {
|
|
||||||
if (MediaRecorder.isTypeSupported(f)) {
|
|
||||||
console.log(`[VoiceCapture] Audio format: ${f}`)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Whisper transcription handler ======
|
|
||||||
|
|
||||||
function handleTranscription(msg: any) {
|
|
||||||
if (!isRecording.value) return
|
|
||||||
|
|
||||||
if (msg.success && msg.text) {
|
|
||||||
const fullText = msg.text.trim()
|
|
||||||
transcript.value = fullText + ' '
|
|
||||||
interimTranscript.value = ''
|
|
||||||
if (!msg.partial) {
|
|
||||||
console.log(`[VoiceCapture] WHISPER (${msg.model}/${msg.device}):`, fullText)
|
|
||||||
}
|
|
||||||
} else if (msg.error) {
|
|
||||||
error.value = msg.error
|
|
||||||
console.error('[VoiceCapture] Whisper error:', msg.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Recording ======
|
|
||||||
|
|
||||||
function startRecording() {
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
// Start capturing audio immediately, regardless of GPU status
|
|
||||||
startMediaRecorder()
|
|
||||||
|
|
||||||
// If GPU not ready yet, start timeout
|
|
||||||
if (!isConnected()) {
|
|
||||||
console.log('[VoiceCapture] Recording started, waiting for GPU...')
|
|
||||||
gpuTimeout = window.setTimeout(() => {
|
|
||||||
if (isRecording.value && !isConnected()) {
|
|
||||||
error.value = 'Whisper GPU timeout — server not available'
|
|
||||||
notify('Whisper GPU not available', 'error')
|
|
||||||
stopRecording()
|
|
||||||
}
|
|
||||||
}, GPU_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startMediaRecorder() {
|
|
||||||
try {
|
|
||||||
const audioConstraints: MediaTrackConstraints = {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true,
|
|
||||||
...(selectedDeviceId.value ? { deviceId: { exact: selectedDeviceId.value } } : {})
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
|
|
||||||
|
|
||||||
const recorderOptions: MediaRecorderOptions = {}
|
|
||||||
if (supportedMimeType) {
|
|
||||||
recorderOptions.mimeType = supportedMimeType
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder = new MediaRecorder(mediaStream, recorderOptions)
|
|
||||||
console.log(`[VoiceCapture] MediaRecorder using: ${mediaRecorder.mimeType}`)
|
|
||||||
|
|
||||||
audioChunks = []
|
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
|
||||||
if (event.data.size > 0) {
|
|
||||||
audioChunks.push(event.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.start(100)
|
|
||||||
isRecording.value = true
|
|
||||||
recordingStartTime = Date.now()
|
|
||||||
|
|
||||||
// Reload devices with labels now that we have permission
|
|
||||||
loadAudioDevices(true)
|
|
||||||
|
|
||||||
// Send chunks periodically — only when GPU is connected
|
|
||||||
chunkInterval = window.setInterval(() => {
|
|
||||||
if (audioChunks.length > 0 && isConnected()) {
|
|
||||||
// GPU came online — clear timeout if still pending
|
|
||||||
if (gpuTimeout) {
|
|
||||||
clearTimeout(gpuTimeout)
|
|
||||||
gpuTimeout = null
|
|
||||||
}
|
|
||||||
sendAudioChunk(false)
|
|
||||||
}
|
|
||||||
}, CHUNK_INTERVAL_MS)
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = `Microphone error: ${e.message}`
|
|
||||||
console.error('[VoiceCapture] Microphone error:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendAudioChunk(isFinal: boolean) {
|
|
||||||
if (audioChunks.length === 0) return
|
|
||||||
if (!isConnected()) {
|
|
||||||
console.log('[VoiceCapture] GPU not connected, holding audio')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = mediaRecorder?.mimeType || supportedMimeType || 'audio/webm'
|
|
||||||
const audioBlob = new Blob(audioChunks, { type: mimeType })
|
|
||||||
|
|
||||||
if (audioBlob.size < 5000) {
|
|
||||||
if (isFinal) audioChunks = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFinal) {
|
|
||||||
audioChunks = []
|
|
||||||
saveAudioForPlayback(audioBlob)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64 = (reader.result as string).split(',')[1]
|
|
||||||
sendAudio(base64, 'es', !isFinal)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(audioBlob)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording() {
|
|
||||||
if (gpuTimeout) {
|
|
||||||
clearTimeout(gpuTimeout)
|
|
||||||
gpuTimeout = null
|
|
||||||
}
|
|
||||||
if (chunkInterval) {
|
|
||||||
clearInterval(chunkInterval)
|
|
||||||
chunkInterval = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send final chunk (only if GPU is connected)
|
|
||||||
if (audioChunks.length > 0) {
|
|
||||||
sendAudioChunk(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
||||||
mediaRecorder.stop()
|
|
||||||
}
|
|
||||||
if (mediaStream) {
|
|
||||||
mediaStream.getTracks().forEach(track => track.stop())
|
|
||||||
mediaStream = null
|
|
||||||
}
|
|
||||||
|
|
||||||
isRecording.value = false
|
|
||||||
interimTranscript.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Audio Save & Playback ======
|
|
||||||
|
|
||||||
function currentMicName(): string {
|
|
||||||
if (!selectedDeviceId.value) return 'Default'
|
|
||||||
const device = audioDevices.value.find(d => d.deviceId === selectedDeviceId.value)
|
|
||||||
return device?.label || 'Microphone'
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAudioForPlayback(blob: Blob) {
|
|
||||||
if (lastAudioUrl.value) URL.revokeObjectURL(lastAudioUrl.value)
|
|
||||||
lastAudioUrl.value = URL.createObjectURL(blob)
|
|
||||||
saveRecordingToBackend(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRecordingToBackend(blob: Blob) {
|
|
||||||
try {
|
|
||||||
const duration_ms = Date.now() - recordingStartTime
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onloadend = async () => {
|
|
||||||
const base64 = (reader.result as string).split(',')[1]
|
|
||||||
const response = await fetch('/api/recordings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
audio: base64,
|
|
||||||
transcription: transcript.value.trim(),
|
|
||||||
microphone: currentMicName(),
|
|
||||||
duration_ms
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.success) {
|
|
||||||
console.log(`[VoiceCapture] Recording saved: ${data.filename}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(blob)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[VoiceCapture] Error saving recording:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function playLastAudio() {
|
|
||||||
if (!lastAudioUrl.value) return
|
|
||||||
if (isPlayingAudio.value && audioElement) {
|
|
||||||
audioElement.pause()
|
|
||||||
audioElement.currentTime = 0
|
|
||||||
isPlayingAudio.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
audioElement = new Audio(lastAudioUrl.value)
|
|
||||||
audioElement.onplay = () => { isPlayingAudio.value = true }
|
|
||||||
audioElement.onended = () => { isPlayingAudio.value = false }
|
|
||||||
audioElement.onpause = () => { isPlayingAudio.value = false }
|
|
||||||
audioElement.play().catch(() => { isPlayingAudio.value = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Microphone ======
|
|
||||||
|
|
||||||
async function loadAudioDevices(skipPermissionRequest = false) {
|
|
||||||
try {
|
|
||||||
if (!skipPermissionRequest) {
|
|
||||||
const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
tempStream.getTracks().forEach(track => track.stop())
|
|
||||||
}
|
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
|
||||||
audioDevices.value = devices.filter(d => d.kind === 'audioinput')
|
|
||||||
if (!selectedDeviceId.value && audioDevices.value.length > 0) {
|
|
||||||
selectedDeviceId.value = audioDevices.value[0]?.deviceId || ''
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[VoiceCapture] Failed to enumerate devices:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectMicrophone(deviceId: string) {
|
|
||||||
selectedDeviceId.value = deviceId
|
|
||||||
if (isRecording.value) {
|
|
||||||
stopRecording()
|
|
||||||
setTimeout(() => startRecording(), 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Typing Animation ======
|
|
||||||
|
|
||||||
function animateTyping(targetText: string) {
|
|
||||||
if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null }
|
|
||||||
if (targetText.length < animatedTranscript.value.length) {
|
|
||||||
animatedTranscript.value = targetText
|
|
||||||
lastAnimatedLength = targetText.length
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const startIndex = lastAnimatedLength
|
|
||||||
function typeNext(index: number) {
|
|
||||||
if (index <= targetText.length) {
|
|
||||||
animatedTranscript.value = targetText.substring(0, index)
|
|
||||||
lastAnimatedLength = index
|
|
||||||
if (index < targetText.length) {
|
|
||||||
typingTimeout = window.setTimeout(() => typeNext(index + 1), 15 + Math.random() * 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
typeNext(startIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(transcript, (v) => animateTyping(v))
|
|
||||||
|
|
||||||
// ====== Transcript ======
|
|
||||||
|
|
||||||
function clearTranscript() {
|
|
||||||
transcript.value = ''
|
|
||||||
interimTranscript.value = ''
|
|
||||||
animatedTranscript.value = ''
|
|
||||||
lastAnimatedLength = 0
|
|
||||||
if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====== Lifecycle ======
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
checkMobile()
|
|
||||||
supportedMimeType = detectAudioFormat()
|
|
||||||
await loadAudioDevices(true)
|
|
||||||
|
|
||||||
// Subscribe to shared whisper transcriptions
|
|
||||||
if (!unsubTranscription) {
|
|
||||||
unsubTranscription = onTranscription(handleTranscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize shared Whisper socket (singleton, safe to call multiple times)
|
|
||||||
initWhisperSocket()
|
|
||||||
console.log('[VoiceCapture] Initialized (Whisper-only, record-first)')
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
stopRecording()
|
|
||||||
if (unsubTranscription) { unsubTranscription(); unsubTranscription = null }
|
|
||||||
if (chunkInterval) clearInterval(chunkInterval)
|
|
||||||
if (typingTimeout) clearTimeout(typingTimeout)
|
|
||||||
if (gpuTimeout) clearTimeout(gpuTimeout)
|
|
||||||
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null }
|
|
||||||
if (audioElement) { audioElement.pause(); audioElement = null }
|
|
||||||
if (lastAudioUrl.value) { URL.revokeObjectURL(lastAudioUrl.value); lastAudioUrl.value = '' }
|
|
||||||
isPlayingAudio.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRecording, transcript, interimTranscript, animatedTranscript,
|
|
||||||
error, voiceMode, whisperStatus, audioDevices, selectedDeviceId,
|
|
||||||
isAndroid, lastAudioUrl, isPlayingAudio,
|
|
||||||
startRecording, stopRecording, loadAudioDevices, selectMicrophone,
|
|
||||||
playLastAudio, init, cleanup, clearTranscript
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
||||||
import { endpoints } from '../config/endpoints'
|
|
||||||
import { useTerminalRenderer } from '../composables/useTerminalRenderer'
|
|
||||||
|
|
||||||
const terminalContainer = ref<HTMLElement | null>(null)
|
|
||||||
const connected = ref(false)
|
|
||||||
const connecting = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const sessionId = ref<string | null>(null)
|
|
||||||
const isResumedSession = ref(false)
|
|
||||||
|
|
||||||
let socket: WebSocket | null = null
|
|
||||||
|
|
||||||
const WS_URL = endpoints.terminal
|
|
||||||
|
|
||||||
// Terminal theme for TerminalPage (different from FloatingTerminal)
|
|
||||||
const TERMINAL_PAGE_THEME = {
|
|
||||||
background: '#0f0f14',
|
|
||||||
foreground: '#e4e4e7',
|
|
||||||
cursor: '#6366f1',
|
|
||||||
cursorAccent: '#0f0f14',
|
|
||||||
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
|
||||||
black: '#16161d',
|
|
||||||
red: '#ef4444',
|
|
||||||
green: '#22c55e',
|
|
||||||
yellow: '#eab308',
|
|
||||||
blue: '#3b82f6',
|
|
||||||
magenta: '#a855f7',
|
|
||||||
cyan: '#06b6d4',
|
|
||||||
white: '#e4e4e7',
|
|
||||||
brightBlack: '#52525b',
|
|
||||||
brightRed: '#f87171',
|
|
||||||
brightGreen: '#4ade80',
|
|
||||||
brightYellow: '#facc15',
|
|
||||||
brightBlue: '#60a5fa',
|
|
||||||
brightMagenta: '#c084fc',
|
|
||||||
brightCyan: '#22d3ee',
|
|
||||||
brightWhite: '#ffffff'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the composable
|
|
||||||
const renderer = useTerminalRenderer({
|
|
||||||
container: terminalContainer,
|
|
||||||
theme: TERMINAL_PAGE_THEME,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
|
||||||
onData: (data) => {
|
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'input', data }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onResize: (cols, rows) => {
|
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
if (connecting.value) return
|
|
||||||
|
|
||||||
connecting.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = new WebSocket(WS_URL)
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
connected.value = true
|
|
||||||
connecting.value = false
|
|
||||||
renderer.focus()
|
|
||||||
|
|
||||||
const term = renderer.terminal.value
|
|
||||||
if (term) {
|
|
||||||
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
|
|
||||||
if (msg.type === 'connected') {
|
|
||||||
sessionId.value = msg.sessionId
|
|
||||||
isResumedSession.value = !msg.isNew
|
|
||||||
|
|
||||||
if (msg.hasHistory) {
|
|
||||||
socket?.send(JSON.stringify({ type: 'request-replay', tailOnly: true, chunks: 200 }))
|
|
||||||
} else if (!msg.isNew) {
|
|
||||||
renderer.writeln('\x1b[36m[Reconnected to existing session]\x1b[0m')
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'replay') {
|
|
||||||
renderer.handleReplay(msg.data || '')
|
|
||||||
} else if (msg.type === 'output') {
|
|
||||||
renderer.write(msg.data)
|
|
||||||
} else if (msg.type === 'exit') {
|
|
||||||
renderer.write(msg.data)
|
|
||||||
sessionId.value = null
|
|
||||||
} else if (msg.type === 'error') {
|
|
||||||
error.value = msg.message
|
|
||||||
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onclose = () => {
|
|
||||||
connected.value = false
|
|
||||||
connecting.value = false
|
|
||||||
if (sessionId.value) {
|
|
||||||
renderer.writeln('\x1b[33mDisconnected - session preserved on server\x1b[0m')
|
|
||||||
} else {
|
|
||||||
renderer.writeln('\x1b[33mConnection closed\x1b[0m')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onerror = () => {
|
|
||||||
error.value = 'WebSocket connection failed. Make sure terminal server is running.'
|
|
||||||
connecting.value = false
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e.message
|
|
||||||
connecting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
connected.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearTerminal() {
|
|
||||||
renderer.clear()
|
|
||||||
renderer.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyContent() {
|
|
||||||
const content = renderer.getBufferContent()
|
|
||||||
if (!content) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(content)
|
|
||||||
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
|
|
||||||
if (btn) {
|
|
||||||
btn.classList.add('copied')
|
|
||||||
setTimeout(() => btn.classList.remove('copied'), 1500)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to copy:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runClaudeCode() {
|
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
renderer.init()
|
|
||||||
connect()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
socket?.close()
|
|
||||||
renderer.dispose()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="terminal-page">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="terminal-toolbar">
|
|
||||||
<div class="toolbar-left">
|
|
||||||
<h2>Terminal</h2>
|
|
||||||
<span v-if="connected" class="status connected">
|
|
||||||
Connected
|
|
||||||
<span v-if="sessionId" class="session-badge">{{ sessionId }}</span>
|
|
||||||
</span>
|
|
||||||
<span v-else-if="connecting" class="status connecting">Connecting...</span>
|
|
||||||
<span v-else class="status disconnected">
|
|
||||||
Disconnected
|
|
||||||
<span v-if="sessionId" class="session-hint">(session active on server)</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-actions">
|
|
||||||
<button v-if="!connected" class="btn-primary" @click="connect" :disabled="connecting">
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
<button v-else class="btn-secondary" @click="disconnect">
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn-accent" @click="runClaudeCode" :disabled="!connected" title="Run Claude Code">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
||||||
<path d="M2 17l10 5 10-5"/>
|
|
||||||
<path d="M2 12l10 5 10-5"/>
|
|
||||||
</svg>
|
|
||||||
Claude
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn-icon btn-copy" @click="copyContent" title="Copy terminal content">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn-icon" @click="clearTerminal" title="Clear terminal">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M3 6h18"/>
|
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
<div v-if="error && !connected" class="error-banner">
|
|
||||||
{{ error }}
|
|
||||||
<p class="error-hint">Make sure the server is running with <code>bun start</code> in the server folder</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Terminal container -->
|
|
||||||
<div ref="terminalContainer" class="terminal-container"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.terminal-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0f0f14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-left h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connected { background: var(--success-bg); color: var(--success); }
|
|
||||||
.status.connecting { background: var(--warning-bg); color: var(--warning); }
|
|
||||||
.status.disconnected { background: var(--error-bg); color: var(--error); }
|
|
||||||
|
|
||||||
.session-badge {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-hint {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
|
||||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover { background: var(--bg-tertiary); }
|
|
||||||
|
|
||||||
.btn-accent {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.btn-accent:hover:not(:disabled) { opacity: 0.9; }
|
|
||||||
.btn-accent:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
||||||
.btn-icon.btn-copy.copied { color: var(--success); background: var(--success-bg); }
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--error-bg);
|
|
||||||
color: var(--error);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border-bottom: 1px solid var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-hint {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-hint code {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-container {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* xterm overrides */
|
|
||||||
.terminal-container :deep(.xterm) { height: 100%; padding: 0.5rem; }
|
|
||||||
.terminal-container :deep(.xterm-viewport) { overflow-y: auto !important; }
|
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) { width: 10px; }
|
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) { background: #16161d; }
|
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: #2a2a3a; border-radius: 5px; }
|
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) { background: #3a3a4a; }
|
|
||||||
</style>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* Terminal UI control handlers
|
|
||||||
* Controls the FloatingTerminal window (open, close, move, resize)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ToolConfig } from './index'
|
|
||||||
|
|
||||||
export interface TerminalControls {
|
|
||||||
open: (x?: number, y?: number) => void
|
|
||||||
close: () => void
|
|
||||||
toggle: () => void
|
|
||||||
move: (x: number, y: number) => void
|
|
||||||
resize: (width: number, height: number) => void
|
|
||||||
getState: () => { isOpen: boolean; position: { x: number; y: number }; size: { w: number; h: number } }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global reference to terminal controls (set by App.vue)
|
|
||||||
let terminalControls: TerminalControls | null = null
|
|
||||||
|
|
||||||
export function setTerminalControls(controls: TerminalControls) {
|
|
||||||
terminalControls = controls
|
|
||||||
;(window as any).__terminalControls = controls
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTerminalControls(): TerminalControls | null {
|
|
||||||
return terminalControls
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTerminalHandlers(): ToolConfig[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: 'terminal_open',
|
|
||||||
description: 'Abre la ventana flotante del terminal. Opcionalmente en una posicion especifica.',
|
|
||||||
category: 'terminal',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
x: { type: 'number', description: 'Posicion X en pixels (opcional)' },
|
|
||||||
y: { type: 'number', description: 'Posicion Y en pixels (opcional)' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handler: (args: { x?: number; y?: number }) => {
|
|
||||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
|
||||||
terminalControls.open(args.x, args.y)
|
|
||||||
const pos = args.x !== undefined && args.y !== undefined
|
|
||||||
? ` en posicion (${args.x}, ${args.y})`
|
|
||||||
: ''
|
|
||||||
return `Terminal abierta${pos}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'terminal_close',
|
|
||||||
description: 'Cierra la ventana flotante del terminal.',
|
|
||||||
category: 'terminal',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {}
|
|
||||||
},
|
|
||||||
handler: () => {
|
|
||||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
|
||||||
terminalControls.close()
|
|
||||||
return 'Terminal cerrada'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'terminal_toggle',
|
|
||||||
description: 'Alterna el estado de la ventana del terminal (abre si esta cerrada, cierra si esta abierta).',
|
|
||||||
category: 'terminal',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {}
|
|
||||||
},
|
|
||||||
handler: () => {
|
|
||||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
|
||||||
const wasOpen = terminalControls.getState().isOpen
|
|
||||||
terminalControls.toggle()
|
|
||||||
return wasOpen ? 'Terminal cerrada' : 'Terminal abierta'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'terminal_move',
|
|
||||||
description: 'Mueve la ventana del terminal a una posicion especifica en pixels.',
|
|
||||||
category: 'terminal',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
x: { type: 'number', description: 'Posicion X en pixels' },
|
|
||||||
y: { type: 'number', description: 'Posicion Y en pixels' }
|
|
||||||
},
|
|
||||||
required: ['x', 'y']
|
|
||||||
},
|
|
||||||
handler: (args: { x: number; y: number }) => {
|
|
||||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
|
||||||
terminalControls.move(args.x, args.y)
|
|
||||||
return `Terminal movida a (${args.x}, ${args.y})`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'terminal_resize',
|
|
||||||
description: 'Cambia el tamano de la ventana del terminal.',
|
|
||||||
category: 'terminal',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
width: { type: 'number', description: 'Ancho en pixels (min 400)' },
|
|
||||||
height: { type: 'number', description: 'Alto en pixels (min 250)' }
|
|
||||||
},
|
|
||||||
required: ['width', 'height']
|
|
||||||
},
|
|
||||||
handler: (args: { width: number; height: number }) => {
|
|
||||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
|
||||||
const w = Math.max(400, args.width)
|
|
||||||
const h = Math.max(250, args.height)
|
|
||||||
terminalControls.resize(w, h)
|
|
||||||
return `Terminal redimensionada a ${w}x${h}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
export type ClaudeStatus =
|
|
||||||
| 'idle'
|
|
||||||
| 'processing'
|
|
||||||
| 'toolUse'
|
|
||||||
| 'toolDone'
|
|
||||||
| 'reading'
|
|
||||||
| 'writing'
|
|
||||||
| 'sessionStart'
|
|
||||||
| 'subagentStart'
|
|
||||||
| 'subagentStop'
|
|
||||||
| 'notification'
|
|
||||||
| 'permissionRequest'
|
|
||||||
| 'thinking'
|
|
||||||
|
|
||||||
export interface AgentStatusState {
|
|
||||||
isProcessing: boolean
|
|
||||||
isReading: boolean
|
|
||||||
isWriting: boolean
|
|
||||||
awaitingPermission: boolean
|
|
||||||
showToolFlash: boolean
|
|
||||||
showNotification: boolean
|
|
||||||
currentTool: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UiConfig {
|
|
||||||
label: string
|
|
||||||
shortLabel: string
|
|
||||||
color: string
|
|
||||||
gradient: string
|
|
||||||
terminalBg: string
|
|
||||||
terminalBorder: string
|
|
||||||
enabled: boolean
|
|
||||||
command?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Agent {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
directory: string
|
|
||||||
uiConfig: UiConfig | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationEntry {
|
|
||||||
id: string
|
|
||||||
role: 'user' | 'agent'
|
|
||||||
content: string
|
|
||||||
timestamp: string
|
|
||||||
method: 'text' | 'voice'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranscriptSession {
|
|
||||||
id: string
|
|
||||||
startTime: string
|
|
||||||
messageCount: number
|
|
||||||
model: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranscriptMessage {
|
|
||||||
uuid: string
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
content: string
|
|
||||||
timestamp: string
|
|
||||||
isMeta: boolean
|
|
||||||
tokens?: { input: number; output: number }
|
|
||||||
toolCalls?: string[]
|
|
||||||
hasThinking: boolean
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user