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"/>
|
||||
</svg>
|
||||
</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">
|
||||
<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"/>
|
||||
@@ -963,6 +976,22 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
defineExpose({ selectMode, toggleSelectMode })
|
||||
|
||||
function toggleSelect(uuid: string) {
|
||||
if (!selectMode.value) return
|
||||
const s = new Set(selectedUuids.value)
|
||||
@@ -143,12 +141,21 @@ async function copySelected() {
|
||||
// ── Collapse sections ──
|
||||
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)
|
||||
const sectionMap = computed(() => {
|
||||
const map = new Map<string, string>() // messageUuid → ownerUserUuid
|
||||
let currentUserUuid: string | null = null
|
||||
for (const msg of props.conversation.messages) {
|
||||
if (msg.kind === 'user') {
|
||||
if (msg.kind === 'user' && !isSpecialUserMessage(msg)) {
|
||||
currentUserUuid = msg.uuid
|
||||
} else if (currentUserUuid) {
|
||||
map.set(msg.uuid, currentUserUuid)
|
||||
@@ -157,12 +164,12 @@ const sectionMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// Count of non-user messages per section
|
||||
// Count of non-leader messages per section
|
||||
const sectionCounts = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
let currentUserUuid: string | null = null
|
||||
for (const msg of props.conversation.messages) {
|
||||
if (msg.kind === 'user') {
|
||||
if (msg.kind === 'user' && !isSpecialUserMessage(msg)) {
|
||||
currentUserUuid = msg.uuid
|
||||
if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0)
|
||||
} else if (currentUserUuid) {
|
||||
@@ -180,11 +187,38 @@ function toggleCollapse(userUuid: string) {
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
// These skip the bounce animation and get a smooth transition instead
|
||||
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