feat: voice mic, pixel life layer, enhanced transcript-debug UX
VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble with voice display, AgentBadge terminal switcher, ChatContainer voice integration, FloatingTranscriptDebug ocean life enhancements, and terminal registry support. Remove traefik config.
This commit is contained in:
@@ -5,6 +5,7 @@ import Toolbar from './components/Toolbar.vue'
|
||||
import TorchButton from './components/TorchButton.vue'
|
||||
import FloatingTerminal from './components/FloatingTerminal.vue'
|
||||
import FloatingResponse from './components/FloatingResponse.vue'
|
||||
import { initWhisperSocket } from './services/whisperSocket'
|
||||
import FloatingVoice from './components/FloatingVoice.vue'
|
||||
import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue'
|
||||
import AgentBar from './components/AgentBar.vue'
|
||||
@@ -70,6 +71,8 @@ function clearDebugLogs() {
|
||||
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
|
||||
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
|
||||
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
|
||||
const transcriptDebugRef = ref<InstanceType<typeof FloatingTranscriptDebug> | null>(null)
|
||||
const mousePos = ref({ x: 0, y: 0 })
|
||||
const canvasStore = useCanvasStore()
|
||||
const projectCanvasStore = useProjectCanvasStore()
|
||||
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
|
||||
@@ -104,6 +107,21 @@ function hardRefresh() {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
function trackMouse(e: MouseEvent) {
|
||||
mousePos.value = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
if (transcriptDebugRef.value) {
|
||||
transcriptDebugRef.value.openAtCursor(mousePos.value.x, mousePos.value.y)
|
||||
} else {
|
||||
showTranscriptDebug.value = !showTranscriptDebug.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Voice FAB push-to-talk handlers
|
||||
function handleVoiceFabClick() {
|
||||
// If touch just ended, ignore click
|
||||
@@ -273,6 +291,9 @@ onMounted(async () => {
|
||||
connectApproval()
|
||||
fetchApprovalPending()
|
||||
|
||||
// Initialize Whisper WebSocket connection early
|
||||
initWhisperSocket()
|
||||
|
||||
// Fire torch connection early (don't await yet)
|
||||
const torchReady = initTorch()
|
||||
|
||||
@@ -342,6 +363,12 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Track mouse for Ctrl+E cursor-based opening
|
||||
document.addEventListener('mousemove', trackMouse)
|
||||
|
||||
// Global keyboard shortcut: Ctrl+E toggles Transcript Debug
|
||||
document.addEventListener('keydown', handleGlobalKeydown)
|
||||
|
||||
// Detect virtual keyboard on mobile
|
||||
if (window.visualViewport) {
|
||||
const initialHeight = window.visualViewport.height
|
||||
@@ -357,6 +384,8 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', trackMouse)
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
destroyTorch()
|
||||
disconnectApproval()
|
||||
if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout)
|
||||
@@ -577,7 +606,7 @@ watch(() => route.name, (newPage) => {
|
||||
<FloatingVoice ref="voiceRef" v-model="showVoice" />
|
||||
|
||||
<!-- Floating Transcript Debug -->
|
||||
<FloatingTranscriptDebug v-model="showTranscriptDebug" />
|
||||
<FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
|
||||
|
||||
<!-- Global Hooks Approval Modal -->
|
||||
<HooksApprovalModal />
|
||||
|
||||
@@ -85,13 +85,6 @@ const renderer = useTerminalRenderer({
|
||||
}
|
||||
},
|
||||
onKeyEvent: (e) => {
|
||||
// Ctrl+E: Toggle terminal
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
return false
|
||||
}
|
||||
|
||||
// Ctrl+V: Paste from clipboard
|
||||
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
|
||||
e.preventDefault()
|
||||
@@ -216,11 +209,8 @@ function toggleTerminal() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
}
|
||||
function handleKeydown(_e: KeyboardEvent) {
|
||||
// Reserved for future terminal shortcuts
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
||||
import { useVoiceInput } from '@/composables/useVoiceInput'
|
||||
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug'
|
||||
import type { AgentName } from '@/types/transcript-debug'
|
||||
|
||||
@@ -32,14 +33,31 @@ const {
|
||||
processing,
|
||||
ephemeral,
|
||||
terminalReady,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
init,
|
||||
switchAgent,
|
||||
selectSession,
|
||||
createNewSession,
|
||||
switchToTerminal,
|
||||
closeTerminal,
|
||||
disconnectRealtime,
|
||||
sendPrompt
|
||||
} = useTranscriptDebug()
|
||||
|
||||
const voice = useVoiceInput({ language: 'es-419' })
|
||||
const {
|
||||
isRecording: voiceRecording,
|
||||
transcript: voiceTranscript,
|
||||
interimTranscript: voiceInterim,
|
||||
voiceMode,
|
||||
whisperStatus,
|
||||
audioDevices,
|
||||
selectedDeviceId,
|
||||
lastAudioUrl,
|
||||
isPlayingAudio,
|
||||
} = voice
|
||||
|
||||
const agents: { id: AgentName; label: string }[] = [
|
||||
{ id: 'ejecutor', label: 'Ejecutor' },
|
||||
{ id: 'nucleo000', label: 'nucleo000' },
|
||||
@@ -62,6 +80,46 @@ const showChrome = computed(() =>
|
||||
isResizing.value || showSelector.value
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// OCEAN LIFE STATE
|
||||
// ============================================================================
|
||||
|
||||
const currentHour = ref(new Date().getHours())
|
||||
|
||||
const isNightTime = computed(() => currentHour.value >= 20 || currentHour.value < 6)
|
||||
const isNoonTime = computed(() => currentHour.value >= 11 && currentHour.value <= 13)
|
||||
const isDawnTime = computed(() => currentHour.value >= 5 && currentHour.value <= 7)
|
||||
const isFridayTime = computed(() => new Date().getDay() === 5)
|
||||
const isMidnightTime = computed(() => currentHour.value === 0)
|
||||
|
||||
const showComet = ref(false)
|
||||
const showWhale = ref(false)
|
||||
const showBottle = ref(false)
|
||||
const showLeviathan = ref(false)
|
||||
|
||||
const oceanSeeds = Array.from({ length: 20 }, () => Math.random())
|
||||
let oceanLifeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function tickOceanLife() {
|
||||
currentHour.value = new Date().getHours()
|
||||
if (!showComet.value && Math.random() < 0.04) {
|
||||
showComet.value = true
|
||||
setTimeout(() => { showComet.value = false }, 3000)
|
||||
}
|
||||
if (!showWhale.value && Math.random() < 0.07) {
|
||||
showWhale.value = true
|
||||
setTimeout(() => { showWhale.value = false }, 95000)
|
||||
}
|
||||
if (!showBottle.value && Math.random() < 0.025) {
|
||||
showBottle.value = true
|
||||
setTimeout(() => { showBottle.value = false }, 125000)
|
||||
}
|
||||
if (!showLeviathan.value && Math.random() < 0.008) {
|
||||
showLeviathan.value = true
|
||||
setTimeout(() => { showLeviathan.value = false }, 310000)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DRAG STATE
|
||||
// ============================================================================
|
||||
@@ -84,6 +142,24 @@ type SizeMode = 'pin' | 'medium' | 'large'
|
||||
const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null
|
||||
const sizeMode = ref<SizeMode>(savedSize && ['pin', 'medium', 'large'].includes(savedSize) ? savedSize : 'medium')
|
||||
|
||||
// Readability overlay opacity
|
||||
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
|
||||
const overlayOpacity = ref(savedOverlay !== null ? parseFloat(savedOverlay) : 0.55)
|
||||
|
||||
function setOverlayOpacity(val: number) {
|
||||
overlayOpacity.value = val
|
||||
localStorage.setItem('transcript-overlay-opacity', String(val))
|
||||
}
|
||||
|
||||
// Input textarea max lines
|
||||
const savedMaxLines = localStorage.getItem('transcript-input-max-lines')
|
||||
const inputMaxLines = ref(savedMaxLines !== null ? parseInt(savedMaxLines) : 6)
|
||||
|
||||
function setInputMaxLines(val: number) {
|
||||
inputMaxLines.value = val
|
||||
localStorage.setItem('transcript-input-max-lines', String(val))
|
||||
}
|
||||
|
||||
// Force mobile (bottom sheet) mode on desktop
|
||||
const forceMobile = ref(false)
|
||||
const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
|
||||
@@ -117,6 +193,18 @@ function cycleSizeMode() {
|
||||
const isMobile = ref(false)
|
||||
const sheetHeight = ref(55)
|
||||
|
||||
// Virtual keyboard detection
|
||||
const keyboardVisible = ref(false)
|
||||
const keyboardHeight = ref(0)
|
||||
|
||||
function onVisualViewportResize() {
|
||||
if (!window.visualViewport) return
|
||||
const vv = window.visualViewport
|
||||
const kbH = window.innerHeight - (vv.offsetTop + vv.height)
|
||||
keyboardHeight.value = Math.max(0, kbH)
|
||||
keyboardVisible.value = kbH > 100
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOBILE DETECTION
|
||||
// ============================================================================
|
||||
@@ -213,21 +301,34 @@ const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
|
||||
const windowStyle = computed((): Record<string, string> => {
|
||||
if (effectiveMobile.value) {
|
||||
const isFullScreen = sheetHeight.value >= 100
|
||||
const headerEl = document.querySelector('.app-header') as HTMLElement | null
|
||||
const headerH = headerEl ? headerEl.offsetHeight : 40
|
||||
const style: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
left: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
width: '100%',
|
||||
}
|
||||
if (isFullScreen) {
|
||||
// Full height but respect app header
|
||||
const headerEl = document.querySelector('.app-header') as HTMLElement | null
|
||||
const headerH = headerEl ? headerEl.offsetHeight : 40
|
||||
style.top = `${headerH}px`
|
||||
style.height = 'auto'
|
||||
|
||||
if (keyboardVisible.value) {
|
||||
// Body is position:fixed so no browser auto-scroll — simple offset works
|
||||
style.bottom = `${keyboardHeight.value}px`
|
||||
const available = window.innerHeight - keyboardHeight.value - headerH
|
||||
if (isFullScreen) {
|
||||
style.height = `${Math.max(0, available)}px`
|
||||
} else {
|
||||
const originalH = window.innerHeight * sheetHeight.value / 100
|
||||
style.height = `${Math.max(0, Math.min(originalH, available))}px`
|
||||
}
|
||||
} else {
|
||||
style.height = `${sheetHeight.value}dvh`
|
||||
// No keyboard
|
||||
style.bottom = '0'
|
||||
if (isFullScreen) {
|
||||
style.top = `${headerH}px`
|
||||
style.height = 'auto'
|
||||
} else {
|
||||
style.height = `${sheetHeight.value}dvh`
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
@@ -335,6 +436,7 @@ function handleSessionSelect(sessionId: string) {
|
||||
}
|
||||
|
||||
function handleSend(message: string) {
|
||||
voice.clearTranscript()
|
||||
sendPrompt(message)
|
||||
}
|
||||
|
||||
@@ -373,20 +475,29 @@ function handleZoomKey(e: KeyboardEvent) {
|
||||
// LIFECYCLE
|
||||
// ============================================================================
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
document.addEventListener('keydown', handleZoomKey)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', onVisualViewportResize)
|
||||
}
|
||||
oceanLifeTimer = setInterval(tickOceanLife, 20000)
|
||||
tickOceanLife()
|
||||
await voice.init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (oceanLifeTimer) clearInterval(oceanLifeTimer)
|
||||
disconnectRealtime()
|
||||
voice.cleanup()
|
||||
document.removeEventListener('keydown', handleZoomKey)
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
window.visualViewport?.removeEventListener('resize', onVisualViewportResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -420,8 +531,49 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Titlebar -->
|
||||
<div class="titlebar">
|
||||
<!-- Ocean Life Ecosystem -->
|
||||
<div class="ocean-life">
|
||||
<!-- Always visible -->
|
||||
<i class="cr fish-school" :style="{ animationDelay: `-${oceanSeeds[0] * 10}s` }"></i>
|
||||
<i class="cr fish-school-2" :style="{ animationDelay: `-${oceanSeeds[1] * 14}s` }"></i>
|
||||
<i class="cr dolphin" :style="{ animationDelay: `-${oceanSeeds[2] * 12}s` }"></i>
|
||||
<i class="cr jellyfish" :style="{ animationDelay: `-${oceanSeeds[3] * 25}s` }"></i>
|
||||
<i class="cr seahorse" :style="{ animationDelay: `-${oceanSeeds[4] * 35}s` }"></i>
|
||||
<i class="cr turtle" :style="{ animationDelay: `-${oceanSeeds[5] * 50}s` }"></i>
|
||||
<i class="cr pirate-ship" :style="{ animationDelay: `-${oceanSeeds[6] * 180}s` }"></i>
|
||||
<i class="cr bubble b1" :style="{ animationDelay: `-${oceanSeeds[7] * 6}s` }"></i>
|
||||
<i class="cr bubble b2" :style="{ animationDelay: `-${oceanSeeds[8] * 9}s` }"></i>
|
||||
<i class="cr bubble b3" :style="{ animationDelay: `-${oceanSeeds[9] * 11}s` }"></i>
|
||||
<!-- Night only -->
|
||||
<template v-if="isNightTime">
|
||||
<i class="cr satellite" :style="{ animationDelay: `-${oceanSeeds[10] * 15}s` }"></i>
|
||||
<i class="cr anglerfish" :style="{ animationDelay: `-${oceanSeeds[11] * 40}s` }"></i>
|
||||
<i class="cr biolum bl1" :style="{ animationDelay: `-${oceanSeeds[12] * 3.5}s` }"></i>
|
||||
<i class="cr biolum bl2" :style="{ animationDelay: `-${oceanSeeds[13] * 4.5}s` }"></i>
|
||||
<i class="cr biolum bl3" :style="{ animationDelay: `-${oceanSeeds[14] * 5.5}s` }"></i>
|
||||
</template>
|
||||
<!-- Rare random -->
|
||||
<i v-if="showComet" class="cr comet"></i>
|
||||
<i v-if="showWhale" class="cr whale" :style="{ animationDelay: `-${oceanSeeds[15] * 90}s` }"></i>
|
||||
<i v-if="showBottle" class="cr bottle" :style="{ animationDelay: `-${oceanSeeds[16] * 120}s` }"></i>
|
||||
<i v-if="showLeviathan" class="cr leviathan" :style="{ animationDelay: `-${oceanSeeds[17] * 300}s` }"></i>
|
||||
<!-- Time-of-day -->
|
||||
<i v-if="isNoonTime" class="cr sun-ray"></i>
|
||||
<i v-if="isDawnTime" class="cr dawn-glow"></i>
|
||||
<!-- Easter eggs -->
|
||||
<i v-if="isFridayTime" class="cr party-fish" :style="{ animationDelay: `-${oceanSeeds[18] * 8}s` }"></i>
|
||||
<i v-if="isMidnightTime" class="cr ghost-ship" :style="{ animationDelay: `-${oceanSeeds[19] * 240}s` }"></i>
|
||||
</div>
|
||||
<div class="left">
|
||||
<AgentBadge v-if="selectedAgent" :agent="selectedAgent" :connected="isRealtime" />
|
||||
<AgentBadge
|
||||
v-if="selectedAgent"
|
||||
:agent="selectedAgent"
|
||||
:connected="isRealtime"
|
||||
:terminals="openTerminals"
|
||||
:active-session-id="activeTerminalSessionId"
|
||||
@switch-terminal="switchToTerminal"
|
||||
@close-terminal="closeTerminal"
|
||||
/>
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button
|
||||
@@ -486,7 +638,7 @@ onBeforeUnmount(() => {
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<AquaticBackground />
|
||||
<div class="readability-overlay" />
|
||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||
<ChatContainer
|
||||
ref="chatRef"
|
||||
v-if="conversation"
|
||||
@@ -500,10 +652,27 @@ onBeforeUnmount(() => {
|
||||
:sessions="sessions"
|
||||
:selected-session-id="selectedSessionId"
|
||||
:sessions-loading="loading"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus"
|
||||
:audio-devices="audioDevices"
|
||||
:selected-device-id="selectedDeviceId"
|
||||
:is-recording="voiceRecording"
|
||||
:voice-transcript="voiceTranscript + voiceInterim"
|
||||
:last-audio-url="lastAudioUrl"
|
||||
:is-playing-audio="isPlayingAudio"
|
||||
:overlay-opacity="overlayOpacity"
|
||||
:input-max-lines="inputMaxLines"
|
||||
@send="handleSend"
|
||||
@switch-agent="handleAgentSwitch"
|
||||
@select-session="handleSessionSelect"
|
||||
@create-session="handleCreateSession"
|
||||
@start-recording="voice.startRecording()"
|
||||
@stop-recording="voice.stopRecording()"
|
||||
@set-voice-mode="voice.setMode($event)"
|
||||
@select-microphone="voice.selectMicrophone($event)"
|
||||
@play-last-audio="voice.playLastAudio()"
|
||||
@update:overlay-opacity="setOverlayOpacity"
|
||||
@update:input-max-lines="setInputMaxLines"
|
||||
/>
|
||||
<div v-else class="empty-state">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -733,9 +902,12 @@ onBeforeUnmount(() => {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
user-select: none;
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
animation: titlebar-ocean 8s linear infinite;
|
||||
}
|
||||
|
||||
.left {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -744,6 +916,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -831,7 +1005,6 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
@@ -1148,6 +1321,275 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
OCEAN LIFE ECOSYSTEM
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.ocean-life {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.cr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ── 1. Fish School (3 orange fish, swim left) ── */
|
||||
.fish-school {
|
||||
top: 16px;
|
||||
animation: swim-left 10s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #f97316, 1px 0 #f97316, -1px 0 #fb923c,
|
||||
5px 3px #f97316, 6px 3px #f97316, 4px 3px #fb923c,
|
||||
3px -2px #f97316, 4px -2px #f97316, 2px -2px #fb923c;
|
||||
}
|
||||
|
||||
/* ── 2. Fish School 2 (2 blue fish, swim right) ── */
|
||||
.fish-school-2 {
|
||||
top: 20px;
|
||||
animation: swim-right 14s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #818cf8, -1px 0 #818cf8, 1px 0 #a5b4fc,
|
||||
-4px 2px #818cf8, -5px 2px #818cf8, -3px 2px #a5b4fc;
|
||||
}
|
||||
|
||||
/* ── 3. Dolphin (grey-blue, arc jump) ── */
|
||||
.dolphin {
|
||||
top: 14px;
|
||||
animation: dolphin-arc 12s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #94a3b8, 3px 0 #cbd5e1, -1px 0 #64748b,
|
||||
0 1px #64748b, 1px 1px #64748b, 2px 1px #94a3b8,
|
||||
-1px 2px #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 4. Jellyfish (purple, bob) ── */
|
||||
.jellyfish {
|
||||
top: 18px;
|
||||
animation: swim-left 25s linear infinite, jelly-bob 3s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #c084fc,
|
||||
-1px 0 #a855f7, 0 0 #a855f7, 1px 0 #a855f7,
|
||||
0 1px #c084fc,
|
||||
-1px 2px #7c3aed, 1px 2px #7c3aed,
|
||||
0 3px #7c3aed;
|
||||
}
|
||||
|
||||
/* ── 5. Seahorse (yellow-orange, slow drift) ── */
|
||||
.seahorse {
|
||||
top: 15px;
|
||||
animation: swim-left 35s linear infinite, jelly-bob 4s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #fbbf24,
|
||||
0 0 #f59e0b, 1px 0 #f59e0b,
|
||||
0 1px #d97706,
|
||||
0 2px #d97706,
|
||||
1px 3px #b45309;
|
||||
}
|
||||
|
||||
/* ── 6. Turtle (green, slow) ── */
|
||||
.turtle {
|
||||
top: 14px;
|
||||
animation: swim-left 50s linear infinite;
|
||||
box-shadow:
|
||||
2px -1px #4ade80,
|
||||
-1px 0 #16a34a, 0 0 #16a34a, 1px 0 #22c55e, 2px 0 #22c55e, 3px 0 #16a34a,
|
||||
-1px 1px #4ade80, 3px 1px #4ade80;
|
||||
}
|
||||
|
||||
/* ── 7. Pirate Ship (dark silhouette, very slow) ── */
|
||||
.pirate-ship {
|
||||
top: 7px;
|
||||
animation: swim-right 180s linear infinite;
|
||||
box-shadow:
|
||||
3px -3px #334155, 3px -2px #334155, 3px -1px #475569,
|
||||
1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #1e293b, 4px 0 #1e293b, 5px 0 #1e293b,
|
||||
0 1px #1e293b, 1px 1px #1e293b, 2px 1px #1e293b, 3px 1px #1e293b, 4px 1px #1e293b, 5px 1px #1e293b, 6px 1px #1e293b,
|
||||
1px 2px #0f172a, 2px 2px #0f172a, 3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a;
|
||||
}
|
||||
|
||||
/* ── 8-10. Bubbles (rising white dots) ── */
|
||||
.bubble {
|
||||
animation: bubble-rise 6s linear infinite;
|
||||
box-shadow: 0 0 rgba(255,255,255,0.45);
|
||||
}
|
||||
.bubble.b1 { left: 25%; animation-duration: 6s; }
|
||||
.bubble.b2 { left: 55%; animation-duration: 9s; }
|
||||
.bubble.b3 { left: 80%; animation-duration: 11s; }
|
||||
|
||||
/* ── 11. Satellite (night, metallic + blue panels) ── */
|
||||
.satellite {
|
||||
top: 3px;
|
||||
animation: swim-left 15s linear infinite;
|
||||
box-shadow:
|
||||
-2px 0 #3b82f6, -1px 0 #3b82f6, 0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #3b82f6, 3px 0 #3b82f6;
|
||||
}
|
||||
|
||||
/* ── 12. Anglerfish (night, dark body + glowing lure) ── */
|
||||
.anglerfish {
|
||||
top: 22px;
|
||||
animation: swim-left 40s linear infinite;
|
||||
box-shadow:
|
||||
-2px -1px #fbbf24, -2px -2px rgba(251,191,36,0.4),
|
||||
0 0 #1e293b, 1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #334155,
|
||||
0 1px #0f172a, 1px 1px #0f172a, 2px 1px #1e293b;
|
||||
}
|
||||
|
||||
/* ── 13-15. Bioluminescence (night, pulsing cyan dots) ── */
|
||||
.biolum {
|
||||
box-shadow: 0 0 #22d3ee;
|
||||
animation: bio-pulse 3.5s ease-in-out infinite;
|
||||
}
|
||||
.biolum.bl1 { top: 19px; left: 20%; animation-duration: 3.5s; }
|
||||
.biolum.bl2 { top: 23px; left: 50%; animation-duration: 4.5s; }
|
||||
.biolum.bl3 { top: 17px; left: 75%; animation-duration: 5.5s; }
|
||||
|
||||
/* ── 16. Comet (rare, fast diagonal streak) ── */
|
||||
.comet {
|
||||
top: 2px;
|
||||
right: 0;
|
||||
animation: comet-streak 2.5s linear forwards;
|
||||
box-shadow:
|
||||
0 0 #fef3c7, -1px 0 #fef3c7,
|
||||
-2px 1px #fde68a, -3px 1px #fde68a,
|
||||
-4px 2px rgba(251,191,36,0.6),
|
||||
-5px 3px rgba(251,191,36,0.3),
|
||||
-6px 4px rgba(251,191,36,0.15);
|
||||
}
|
||||
|
||||
/* ── 17. Whale (rare, large blue silhouette) ── */
|
||||
.whale {
|
||||
top: 14px;
|
||||
animation: swim-left 90s linear infinite;
|
||||
box-shadow:
|
||||
4px -1px #2563eb, 5px -1px #2563eb,
|
||||
1px 0 #1d4ed8, 2px 0 #1d4ed8, 3px 0 #1d4ed8, 4px 0 #2563eb, 5px 0 #2563eb, 6px 0 #2563eb, 7px 0 #1d4ed8,
|
||||
0 1px #1e40af, 1px 1px #1e40af, 2px 1px #1d4ed8, 3px 1px #1d4ed8, 4px 1px #1d4ed8, 5px 1px #1d4ed8, 6px 1px #1d4ed8, 7px 1px #1e40af, 8px 1px #1e40af,
|
||||
2px 2px #1e40af, 3px 2px #1e40af, 4px 2px #1e40af, 5px 2px #1e40af, 6px 2px #1e40af,
|
||||
7px 3px #1e40af;
|
||||
}
|
||||
|
||||
/* ── 18. Bottle (rare, message in a bottle) ── */
|
||||
.bottle {
|
||||
top: 11px;
|
||||
animation: swim-left 120s linear infinite, jelly-bob 5s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #d97706,
|
||||
-1px 0 #92400e, 0 0 #92400e, 1px 0 #fef3c7,
|
||||
-1px 1px #92400e, 0 1px #92400e;
|
||||
}
|
||||
|
||||
/* ── 19. Leviathan (rare, enormous faint shadow) ── */
|
||||
.leviathan {
|
||||
top: 20px;
|
||||
animation: swim-left 300s linear infinite;
|
||||
opacity: 0.15;
|
||||
box-shadow:
|
||||
2px -1px #1e293b, 3px -1px #1e293b, 4px -1px #1e293b, 5px -1px #1e293b, 6px -1px #1e293b, 7px -1px #1e293b, 8px -1px #1e293b, 9px -1px #1e293b,
|
||||
0 0 #0f172a, 1px 0 #0f172a, 2px 0 #0f172a, 3px 0 #0f172a, 4px 0 #0f172a, 5px 0 #0f172a, 6px 0 #0f172a, 7px 0 #0f172a, 8px 0 #0f172a, 9px 0 #0f172a, 10px 0 #0f172a, 11px 0 #0f172a, 12px 0 #0f172a,
|
||||
1px 1px #0f172a, 2px 1px #0f172a, 3px 1px #0f172a, 4px 1px #0f172a, 5px 1px #0f172a, 6px 1px #0f172a, 7px 1px #0f172a, 8px 1px #0f172a, 9px 1px #0f172a, 10px 1px #0f172a, 11px 1px #0f172a,
|
||||
3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a, 6px 2px #0f172a, 7px 2px #0f172a, 8px 2px #0f172a, 9px 2px #0f172a;
|
||||
}
|
||||
|
||||
/* ── 20. Sun Ray (noon, golden beam) ── */
|
||||
.sun-ray {
|
||||
top: 0;
|
||||
left: 40%;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(251,191,36,0.12) 0%, rgba(251,191,36,0.04) 40%, transparent 100%);
|
||||
animation: sun-ray-pulse 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── 21. Dawn Glow (dawn, warm orange) ── */
|
||||
.dawn-glow {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, rgba(251,146,60,0.1) 0%, transparent 40%);
|
||||
animation: sun-ray-pulse 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── 22. Party Fish (Fridays, rainbow nyan) ── */
|
||||
.party-fish {
|
||||
top: 15px;
|
||||
animation: swim-right 8s linear infinite;
|
||||
box-shadow:
|
||||
-4px 0 #ef4444, -3px 0 #f97316, -2px 0 #eab308, -1px 0 #22c55e, 0 0 #3b82f6, 1px 0 #8b5cf6,
|
||||
-3px 1px #f97316, -2px 1px #eab308, -1px 1px #22c55e, 0 1px #3b82f6;
|
||||
}
|
||||
|
||||
/* ── 23. Ghost Ship (midnight, ethereal) ── */
|
||||
.ghost-ship {
|
||||
top: 7px;
|
||||
animation: swim-left 240s linear infinite;
|
||||
opacity: 0.3;
|
||||
box-shadow:
|
||||
3px -3px rgba(255,255,255,0.6), 3px -2px rgba(255,255,255,0.5), 3px -1px rgba(255,255,255,0.4),
|
||||
1px 0 rgba(255,255,255,0.3), 2px 0 rgba(255,255,255,0.3), 3px 0 rgba(255,255,255,0.3), 4px 0 rgba(255,255,255,0.3), 5px 0 rgba(255,255,255,0.3),
|
||||
0 1px rgba(255,255,255,0.2), 1px 1px rgba(255,255,255,0.2), 2px 1px rgba(255,255,255,0.2), 3px 1px rgba(255,255,255,0.2), 4px 1px rgba(255,255,255,0.2), 5px 1px rgba(255,255,255,0.2), 6px 1px rgba(255,255,255,0.2),
|
||||
1px 2px rgba(255,255,255,0.15), 2px 2px rgba(255,255,255,0.15), 3px 2px rgba(255,255,255,0.15), 4px 2px rgba(255,255,255,0.15), 5px 2px rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* ── Ocean Life Keyframes ── */
|
||||
|
||||
@keyframes swim-left {
|
||||
from { left: calc(100% + 15px); }
|
||||
to { left: -15px; }
|
||||
}
|
||||
|
||||
@keyframes swim-right {
|
||||
from { left: -15px; }
|
||||
to { left: calc(100% + 15px); }
|
||||
}
|
||||
|
||||
@keyframes dolphin-arc {
|
||||
0% { left: calc(100% + 15px); top: 14px; }
|
||||
35% { top: 14px; }
|
||||
50% { top: 4px; }
|
||||
65% { top: 14px; }
|
||||
100% { left: -15px; top: 14px; }
|
||||
}
|
||||
|
||||
@keyframes jelly-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes bubble-rise {
|
||||
0% { top: 28px; opacity: 0.5; }
|
||||
80% { opacity: 0.3; }
|
||||
100% { top: -2px; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bio-pulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes comet-streak {
|
||||
0% { top: 1px; left: 100%; opacity: 0.9; }
|
||||
100% { top: 18px; left: -20px; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sun-ray-pulse {
|
||||
0%, 100% { opacity: 0.05; }
|
||||
50% { opacity: 0.18; }
|
||||
}
|
||||
|
||||
@keyframes titlebar-ocean {
|
||||
from { background-position: 0 0, 0 0; }
|
||||
to { background-position: 120px 0, 0 0; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -1157,7 +1599,8 @@ onBeforeUnmount(() => {
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
max-width: 100%;
|
||||
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.aero-win.mobile .glass {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { TerminalSlot } from '@/types/transcript-debug'
|
||||
|
||||
defineProps<{
|
||||
agent: string
|
||||
connected: boolean
|
||||
terminals: TerminalSlot[]
|
||||
activeSessionId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switch-terminal': [sessionId: string]
|
||||
'close-terminal': [sessionId: string]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
@@ -21,6 +29,22 @@ function onClickOutside(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitch(sessionId: string) {
|
||||
emit('switch-terminal', sessionId)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleClose(e: Event, sessionId: string) {
|
||||
e.stopPropagation()
|
||||
emit('close-terminal', sessionId)
|
||||
}
|
||||
|
||||
function slotColor(t: TerminalSlot): string {
|
||||
if (!t.alive) return '#f87171' // PTY dead → red
|
||||
if (t.clients > 0) return '#4ade80' // alive + connected → green
|
||||
return '#fbbf24' // alive, no clients (parked) → orange
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onClickOutside))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
</script>
|
||||
@@ -28,6 +52,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
<template>
|
||||
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
|
||||
<span class="agent-label">{{ agent }}</span>
|
||||
<span v-if="terminals.length > 1" class="term-count">{{ terminals.length }}</span>
|
||||
<svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges">
|
||||
<rect x="2" y="4" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="0" y="2" width="2" height="2" fill="currentColor"/>
|
||||
@@ -35,7 +60,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
</svg>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="dropdown">
|
||||
<div class="dropdown-item todo">TODO</div>
|
||||
<div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
|
||||
<div
|
||||
v-for="t in terminals"
|
||||
:key="t.sessionId"
|
||||
class="dropdown-item terminal-item"
|
||||
:class="{ active: t.sessionId === activeSessionId }"
|
||||
@click.stop="handleSwitch(t.sessionId)"
|
||||
>
|
||||
<span class="state-dot" :style="{ background: slotColor(t) }" />
|
||||
<span class="terminal-label">{{ t.label }}</span>
|
||||
<button class="close-btn" @click="handleClose($event, t.sessionId)" title="Close terminal">
|
||||
<svg width="6" height="6" viewBox="0 0 6 6">
|
||||
<line x1="0" y1="0" x2="6" y2="6" stroke="currentColor" stroke-width="1.2"/>
|
||||
<line x1="6" y1="0" x2="0" y2="6" stroke="currentColor" stroke-width="1.2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -84,6 +125,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.term-count {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(165, 180, 252, 0.6);
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
padding: 0 3px;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.connected .term-count {
|
||||
color: rgba(134, 239, 172, 0.6);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgba(165, 180, 252, 0.5);
|
||||
transition: transform 0.2s ease;
|
||||
@@ -101,13 +159,16 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 120px;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: rgba(8, 8, 18, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
z-index: 100;
|
||||
padding: 4px 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@@ -116,12 +177,69 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dropdown-item.todo {
|
||||
.dropdown-item.empty {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item:hover {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item.active {
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.state-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item:hover .close-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
@@ -138,4 +256,17 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.dropdown::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,16 @@ const props = defineProps<{
|
||||
sessions?: { id: string; firstUserMessage?: string }[]
|
||||
selectedSessionId?: string | null
|
||||
sessionsLoading?: boolean
|
||||
voiceMode?: 'web' | 'whisper'
|
||||
whisperStatus?: 'offline' | 'loading' | 'ready'
|
||||
audioDevices?: MediaDeviceInfo[]
|
||||
selectedDeviceId?: string
|
||||
isRecording?: boolean
|
||||
voiceTranscript?: string
|
||||
lastAudioUrl?: string
|
||||
isPlayingAudio?: boolean
|
||||
overlayOpacity?: number
|
||||
inputMaxLines?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,6 +44,13 @@ const emit = defineEmits<{
|
||||
switchAgent: [agent: AgentName]
|
||||
selectSession: [sessionId: string]
|
||||
createSession: []
|
||||
startRecording: []
|
||||
stopRecording: []
|
||||
setVoiceMode: [mode: 'web' | 'whisper']
|
||||
selectMicrophone: [deviceId: string]
|
||||
playLastAudio: []
|
||||
'update:overlayOpacity': [value: number]
|
||||
'update:inputMaxLines': [value: number]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
@@ -286,6 +303,71 @@ function formatDuration(start: string, end: string): string {
|
||||
:terminal="terminal ?? null"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="voiceMode" class="selector-row">
|
||||
<label class="selector-label">Mic</label>
|
||||
<select
|
||||
class="session-select"
|
||||
:value="selectedDeviceId || ''"
|
||||
@change="emit('selectMicrophone', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="" disabled>Select mic...</option>
|
||||
<option v-for="d in audioDevices" :key="d.deviceId" :value="d.deviceId">
|
||||
{{ d.label || 'Microphone ' + d.deviceId.slice(0, 8) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="mode-toggle-btn"
|
||||
:class="{ gpu: voiceMode === 'whisper' }"
|
||||
@click="emit('setVoiceMode', voiceMode === 'whisper' ? 'web' : 'whisper')"
|
||||
:title="voiceMode === 'whisper' ? 'Switch to Web Speech' : 'Switch to Whisper GPU'"
|
||||
>
|
||||
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
|
||||
</button>
|
||||
<span
|
||||
class="whisper-dot"
|
||||
:class="whisperStatus"
|
||||
:title="'Whisper: ' + (whisperStatus || 'offline')"
|
||||
></span>
|
||||
<button
|
||||
v-if="lastAudioUrl"
|
||||
class="play-audio-btn"
|
||||
:class="{ playing: isPlayingAudio }"
|
||||
@click="emit('playLastAudio')"
|
||||
title="Play last recording"
|
||||
>
|
||||
<svg v-if="!isPlayingAudio" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16"/>
|
||||
<rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">Overlay</label>
|
||||
<input
|
||||
type="range"
|
||||
class="overlay-slider"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="Math.round((overlayOpacity ?? 0.55) * 100)"
|
||||
@input="emit('update:overlayOpacity', +($event.target as HTMLInputElement).value / 100)"
|
||||
/>
|
||||
<span class="overlay-value">{{ Math.round((overlayOpacity ?? 0.55) * 100) }}%</span>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">Lines</label>
|
||||
<input
|
||||
type="range"
|
||||
class="overlay-slider"
|
||||
min="1"
|
||||
max="12"
|
||||
:value="inputMaxLines ?? 6"
|
||||
@input="emit('update:inputMaxLines', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span class="overlay-value">{{ inputMaxLines ?? 6 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="scrollContainer" class="messages-scroll">
|
||||
<template
|
||||
@@ -367,7 +449,14 @@ function formatDuration(start: string, end: string): string {
|
||||
<UserInput
|
||||
:processing="props.processing"
|
||||
:terminal-ready="props.terminalReady"
|
||||
:voice-transcript="voiceTranscript"
|
||||
:is-recording="isRecording"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus"
|
||||
:max-lines="props.inputMaxLines"
|
||||
@send="emit('send', $event)"
|
||||
@start-recording="emit('startRecording')"
|
||||
@stop-recording="emit('stopRecording')"
|
||||
/>
|
||||
|
||||
<div class="status-bar">
|
||||
@@ -792,4 +881,124 @@ function formatDuration(start: string, end: string): string {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Mic settings row ── */
|
||||
.mode-toggle-btn {
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text-secondary, rgba(255,255,255,0.7));
|
||||
}
|
||||
|
||||
.mode-toggle-btn.gpu {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.whisper-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.whisper-dot.offline {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.whisper-dot.loading {
|
||||
background: #f59e0b;
|
||||
animation: pulse-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.whisper-dot.ready {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.play-audio-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.play-audio-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text-primary, rgba(255,255,255,0.8));
|
||||
}
|
||||
|
||||
.play-audio-btn.playing {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Overlay slider */
|
||||
.overlay-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #0ea5e9;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #0ea5e9;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-value {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import VoiceMicButton from './VoiceMicButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
processing?: boolean
|
||||
terminalReady?: boolean
|
||||
voiceTranscript?: string
|
||||
isRecording?: boolean
|
||||
voiceMode?: 'web' | 'whisper'
|
||||
whisperStatus?: 'offline' | 'loading' | 'ready'
|
||||
maxLines?: number
|
||||
}>()
|
||||
|
||||
const maxH = computed(() => {
|
||||
const lines = props.maxLines ?? 6
|
||||
// line-height is 1.5 at 13px = ~20px per line, plus padding
|
||||
return lines <= 1 ? '20px' : `${lines * 20}px`
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [message: string]
|
||||
startRecording: []
|
||||
stopRecording: []
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function adjustHeight() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
const notReady = computed(() => props.terminalReady === false)
|
||||
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
|
||||
@@ -20,6 +42,7 @@ function handleSend() {
|
||||
if (!msg || props.processing || notReady.value) return
|
||||
emit('send', msg)
|
||||
input.value = ''
|
||||
nextTick(adjustHeight)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -28,6 +51,16 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Fill textarea with voice transcript
|
||||
watch(() => props.voiceTranscript, (newText) => {
|
||||
if (newText && newText.trim()) {
|
||||
input.value = newText
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-grow textarea when content changes
|
||||
watch(input, () => nextTick(adjustHeight))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,13 +79,24 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
</div>
|
||||
<div class="input-container" :class="{ disabled: processing || notReady }">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="input"
|
||||
class="input-field"
|
||||
:style="{ maxHeight: maxH }"
|
||||
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
|
||||
rows="1"
|
||||
:disabled="processing || notReady"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<VoiceMicButton
|
||||
v-if="voiceMode"
|
||||
:is-recording="isRecording ?? false"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus ?? 'offline'"
|
||||
:disabled="processing || notReady"
|
||||
@start="emit('startRecording')"
|
||||
@stop="emit('stopRecording')"
|
||||
/>
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="isDisabled"
|
||||
@@ -139,7 +183,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
min-height: 20px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding: 0.15rem 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ParsedUserMessage } from '@/types/transcript-debug'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ParsedUserMessage
|
||||
@@ -14,6 +15,57 @@ const emit = defineEmits<{
|
||||
|
||||
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
|
||||
|
||||
// ── Command / special message detection ──
|
||||
type CommandInfo =
|
||||
| { type: 'caveat'; text: string }
|
||||
| { type: 'command'; name: string; message: string; args: string }
|
||||
| { type: 'stdout'; text: string }
|
||||
| { type: 'interrupted' }
|
||||
| { type: 'meta-action'; text: string }
|
||||
| null
|
||||
|
||||
const commandInfo = computed<CommandInfo>(() => {
|
||||
const c = props.message.content
|
||||
if (!c) return null
|
||||
|
||||
// [Request interrupted by user ...]
|
||||
if (c.includes('[Request interrupted by user')) {
|
||||
return { type: 'interrupted' }
|
||||
}
|
||||
|
||||
// Meta messages: "Continue from where you left off", etc.
|
||||
if (props.message.isMeta) {
|
||||
return { type: 'meta-action', text: c.trim() }
|
||||
}
|
||||
|
||||
// <local-command-caveat>...</local-command-caveat>
|
||||
const caveatMatch = c.match(/<local-command-caveat>([\s\S]*?)<\/local-command-caveat>/)
|
||||
if (caveatMatch) return { type: 'caveat', text: caveatMatch[1].trim() }
|
||||
|
||||
// <command-name>...</command-name>
|
||||
const cmdNameMatch = c.match(/<command-name>([\s\S]*?)<\/command-name>/)
|
||||
if (cmdNameMatch) {
|
||||
const msgMatch = c.match(/<command-message>([\s\S]*?)<\/command-message>/)
|
||||
const argsMatch = c.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
||||
return {
|
||||
type: 'command',
|
||||
name: cmdNameMatch[1].trim(),
|
||||
message: msgMatch?.[1]?.trim() || '',
|
||||
args: argsMatch?.[1]?.trim() || ''
|
||||
}
|
||||
}
|
||||
|
||||
// <local-command-stdout>...</local-command-stdout>
|
||||
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)
|
||||
if (stdoutMatch !== null && c.includes('<local-command-stdout>')) {
|
||||
return { type: 'stdout', text: stdoutMatch[1].trim() }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isCommand = computed(() => commandInfo.value !== null)
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
@@ -21,7 +73,65 @@ function formatTime(ts: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
|
||||
<!-- ═══════ COMMAND MESSAGE (compact) ═══════ -->
|
||||
<div v-if="isCommand" class="cmd-row">
|
||||
<!-- Caveat: system note about local commands -->
|
||||
<template v-if="commandInfo!.type === 'caveat'">
|
||||
<span class="cmd-icon cmd-icon-caveat">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-caveat-text">local command caveat</span>
|
||||
</template>
|
||||
|
||||
<!-- Command invocation -->
|
||||
<template v-else-if="commandInfo!.type === 'command'">
|
||||
<span class="cmd-icon">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</span>
|
||||
<code class="cmd-name">{{ (commandInfo as any).name }}</code>
|
||||
<span v-if="(commandInfo as any).args" class="cmd-args">{{ (commandInfo as any).args }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Stdout -->
|
||||
<template v-else-if="commandInfo!.type === 'stdout'">
|
||||
<span class="cmd-icon cmd-icon-out">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-if="(commandInfo as any).text" class="cmd-stdout">{{ (commandInfo as any).text }}</span>
|
||||
<span v-else class="cmd-empty">no output</span>
|
||||
</template>
|
||||
|
||||
<!-- Interrupted -->
|
||||
<template v-else-if="commandInfo!.type === 'interrupted'">
|
||||
<span class="cmd-icon cmd-icon-interrupted">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-interrupted-text">interrupted by user</span>
|
||||
</template>
|
||||
|
||||
<!-- Meta action (continue, etc.) -->
|
||||
<template v-else-if="commandInfo!.type === 'meta-action'">
|
||||
<span class="cmd-icon cmd-icon-meta">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-meta-text">{{ (commandInfo as any).text }}</span>
|
||||
</template>
|
||||
|
||||
<span class="cmd-time">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ NORMAL USER MESSAGE ═══════ -->
|
||||
<div v-else :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
|
||||
<div class="divider-line" />
|
||||
<div class="divider-content">
|
||||
<div class="divider-header">
|
||||
@@ -46,25 +156,142 @@ function formatTime(ts: string): string {
|
||||
</button>
|
||||
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="divider-text">{{ message.content }}</div>
|
||||
<div class="divider-text">
|
||||
<MarkdownContent :content="message.content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ══════════════════════════════════════
|
||||
Command row — compact inline display
|
||||
══════════════════════════════════════ */
|
||||
.cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
margin: 0.1rem 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.cmd-row:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cmd-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmd-icon-caveat { color: #f59e0b; }
|
||||
.cmd-icon-out { color: #64748b; }
|
||||
|
||||
.cmd-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cmd-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cmd-args {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cmd-stdout {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cmd-empty {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cmd-caveat-text {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-icon-interrupted { color: #ef4444; }
|
||||
.cmd-interrupted-text {
|
||||
color: rgba(239, 68, 68, 0.7);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cmd-icon-meta { color: #f59e0b; }
|
||||
.cmd-meta-text {
|
||||
color: rgba(251, 191, 36, 0.65);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-time {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
Normal user message
|
||||
══════════════════════════════════════ */
|
||||
.user-divider {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(129, 140, 248, 0.2) 10%, rgba(129, 140, 248, 0.2) 90%, transparent);
|
||||
margin-bottom: 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider-content {
|
||||
padding: 0 0.25rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: linear-gradient(170deg, rgba(148, 163, 184, 0.10) 0%, rgba(100, 116, 139, 0.15) 50%, rgba(71, 85, 105, 0.12) 100%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.divider-header {
|
||||
@@ -103,19 +330,34 @@ function formatTime(ts: string): string {
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Tighten markdown spacing inside user bubbles */
|
||||
.divider-text :deep(.md-content p) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content pre) {
|
||||
margin: 0.4em 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content code) {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Meta messages: dimmed */
|
||||
.user-divider.meta {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.user-divider.meta .divider-line {
|
||||
background: linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.15) 10%, rgba(251, 191, 36, 0.15) 90%, transparent);
|
||||
}
|
||||
|
||||
/* Collapse button */
|
||||
.collapse-btn {
|
||||
display: inline-flex;
|
||||
@@ -155,10 +397,6 @@ function formatTime(ts: string): string {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.user-divider.optimistic .divider-line {
|
||||
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1) 10%, rgba(99, 102, 241, 0.1) 90%, transparent);
|
||||
}
|
||||
|
||||
.sending-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
191
frontend/src/components/transcript-debug/VoiceMicButton.vue
Normal file
191
frontend/src/components/transcript-debug/VoiceMicButton.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isRecording: boolean
|
||||
voiceMode: 'web' | 'whisper'
|
||||
whisperStatus: 'offline' | 'loading' | 'ready'
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: []
|
||||
stop: []
|
||||
}>()
|
||||
|
||||
// ── Interaction state machine ──
|
||||
// idle → pointerdown → wait 250ms
|
||||
// if pointerup < 250ms → "click" → locked (recording until next press+release)
|
||||
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
|
||||
// locked → pointerdown → stopping
|
||||
// stopping → pointerup → stop recording → idle
|
||||
// ptt → pointerup → wait 500ms trail → stop recording → idle
|
||||
|
||||
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
|
||||
const mode = ref<Mode>('idle')
|
||||
|
||||
const HOLD_THRESHOLD = 250
|
||||
const TRAIL_BUFFER = 500
|
||||
|
||||
let holdTimer: number | null = null
|
||||
let trailTimer: number | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
||||
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
e.preventDefault()
|
||||
// Capture pointer so we get pointerup even if cursor leaves button
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
if (mode.value === 'idle') {
|
||||
// Start hold detection
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTimer = null
|
||||
// Still holding after 250ms → PTT mode
|
||||
mode.value = 'ptt'
|
||||
emit('start')
|
||||
}, HOLD_THRESHOLD)
|
||||
} else if (mode.value === 'locked') {
|
||||
// Second press while locked → prepare to stop on release
|
||||
mode.value = 'stopping'
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
e.preventDefault()
|
||||
|
||||
if (mode.value === 'idle' && holdTimer) {
|
||||
// Quick click (< 250ms) → lock mode
|
||||
clearTimers()
|
||||
mode.value = 'locked'
|
||||
emit('start')
|
||||
} else if (mode.value === 'ptt') {
|
||||
// Release from PTT → 500ms trailing buffer then stop
|
||||
clearTimers()
|
||||
trailTimer = window.setTimeout(() => {
|
||||
trailTimer = null
|
||||
mode.value = 'idle'
|
||||
emit('stop')
|
||||
}, TRAIL_BUFFER)
|
||||
} else if (mode.value === 'stopping') {
|
||||
// Release from second press → stop immediately
|
||||
clearTimers()
|
||||
mode.value = 'idle'
|
||||
emit('stop')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="mic-btn"
|
||||
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
|
||||
:disabled="disabled"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerup="onPointerUp"
|
||||
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
|
||||
>
|
||||
<!-- Mic icon -->
|
||||
<svg v-if="!isRecording" width="14" height="14" 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>
|
||||
<!-- Stop icon (square) when recording -->
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>
|
||||
<!-- Mode badge -->
|
||||
<span class="mode-badge" :class="voiceMode">
|
||||
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mic-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mic-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.1));
|
||||
color: var(--text-primary, #ccc);
|
||||
border-color: var(--text-muted, rgba(255,255,255,0.15));
|
||||
}
|
||||
|
||||
.mic-btn.recording {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: white;
|
||||
animation: rec-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mic-btn.recording.ptt {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
border-color: rgba(249, 115, 22, 0.5);
|
||||
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mic-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mode-badge.whisper {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.web {
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
@keyframes rec-pulse-ptt {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
JellyfishDrift,
|
||||
EventOverlay,
|
||||
EdgeFade,
|
||||
PixelLife,
|
||||
} from './layers'
|
||||
|
||||
const { start, stop } = useAquaticEvents()
|
||||
@@ -33,6 +34,7 @@ onBeforeUnmount(() => stop())
|
||||
<BubbleStream />
|
||||
<FishSchool />
|
||||
<JellyfishDrift />
|
||||
<PixelLife />
|
||||
<EventOverlay />
|
||||
<EdgeFade />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
|
||||
const pixelSeeds = Array.from({ length: 15 }, () => Math.random())
|
||||
|
||||
// All swimming creatures are rare — only stationary ones always visible
|
||||
const showSeahorse = ref(false)
|
||||
const showOctopus = ref(false)
|
||||
const showCrab = ref(false)
|
||||
const showEel = ref(false)
|
||||
const showPufferfish = ref(false)
|
||||
const showShrimp = ref(false)
|
||||
const showNautilus = ref(false)
|
||||
const showSwordfish = ref(false)
|
||||
const showStarfish = ref(false)
|
||||
const showAnchor = ref(false)
|
||||
const showSubmarine = ref(false)
|
||||
const showDiver = ref(false)
|
||||
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function tickPixelLife() {
|
||||
// Common creatures — low chance each tick
|
||||
showSeahorse.value = Math.random() < 0.06
|
||||
showOctopus.value = Math.random() < 0.05
|
||||
showCrab.value = Math.random() < 0.06
|
||||
showEel.value = Math.random() < 0.04
|
||||
showPufferfish.value = Math.random() < 0.05
|
||||
showShrimp.value = Math.random() < 0.06
|
||||
showNautilus.value = Math.random() < 0.04
|
||||
showSwordfish.value = Math.random() < 0.03
|
||||
showStarfish.value = Math.random() < 0.05
|
||||
|
||||
// Very rare creatures
|
||||
showAnchor.value = Math.random() < 0.01
|
||||
showSubmarine.value = Math.random() < 0.008
|
||||
showDiver.value = Math.random() < 0.015
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tickPixelLife()
|
||||
tickTimer = setInterval(tickPixelLife, 60000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pixel-life">
|
||||
<!-- Swimming creatures (all rare) -->
|
||||
<i v-if="showSeahorse" class="px seahorse" :style="{ animationDelay: `-${pixelSeeds[0] * 160}s` }"></i>
|
||||
<i v-if="showOctopus" class="px octopus" :style="{ animationDelay: `-${pixelSeeds[1] * 200}s` }"></i>
|
||||
<i v-if="showCrab" class="px crab" :style="{ animationDelay: `-${pixelSeeds[2] * 120}s` }"></i>
|
||||
<i v-if="showEel" class="px eel" :style="{ animationDelay: `-${pixelSeeds[3] * 70}s` }"></i>
|
||||
<i v-if="showPufferfish" class="px pufferfish" :style="{ animationDelay: `-${pixelSeeds[4] * 140}s` }"></i>
|
||||
<i v-if="showShrimp" class="px shrimp" :style="{ animationDelay: `-${pixelSeeds[5] * 50}s` }"></i>
|
||||
<i v-if="showNautilus" class="px nautilus" :style="{ animationDelay: `-${pixelSeeds[6] * 240}s` }"></i>
|
||||
<i v-if="showSwordfish" class="px swordfish" :style="{ animationDelay: `-${pixelSeeds[7] * 25}s` }"></i>
|
||||
<i v-if="showStarfish" class="px starfish-walk" :style="{ animationDelay: `-${pixelSeeds[8] * 360}s` }"></i>
|
||||
|
||||
<!-- Stationary creatures (always visible) -->
|
||||
<i class="px treasure"></i>
|
||||
<i class="px sea-anemone"></i>
|
||||
<i class="px clam"></i>
|
||||
|
||||
<!-- Very rare creatures -->
|
||||
<i v-if="showAnchor" class="px anchor"></i>
|
||||
<i v-if="showSubmarine" class="px submarine" :style="{ animationDelay: `-${pixelSeeds[11] * 320}s` }"></i>
|
||||
<i v-if="showDiver" class="px diver" :style="{ animationDelay: `-${pixelSeeds[12] * 180}s` }"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Pixel Life — box-shadow pixel art creatures for the main background
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.pixel-life {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.px {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
font-style: normal;
|
||||
transform: scale(3);
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Keyframes ── */
|
||||
|
||||
@keyframes px-swim-left {
|
||||
from { left: calc(100% + 20px); }
|
||||
to { left: -20px; }
|
||||
}
|
||||
|
||||
@keyframes px-swim-right {
|
||||
from { left: -20px; }
|
||||
to { left: calc(100% + 20px); }
|
||||
}
|
||||
|
||||
@keyframes px-bob {
|
||||
0%, 100% { transform: scale(3) translateY(0); }
|
||||
50% { transform: scale(3) translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes px-sine {
|
||||
0% { transform: scale(3) translateY(0); }
|
||||
25% { transform: scale(3) translateY(-6px); }
|
||||
50% { transform: scale(3) translateY(0); }
|
||||
75% { transform: scale(3) translateY(6px); }
|
||||
100% { transform: scale(3) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes px-sink {
|
||||
from { top: -10px; }
|
||||
to { top: calc(100% + 10px); }
|
||||
}
|
||||
|
||||
@keyframes px-puff {
|
||||
0%, 100% { transform: scale(3); }
|
||||
50% { transform: scale(4.5); }
|
||||
}
|
||||
|
||||
@keyframes px-pulse-glow {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes px-tentacle {
|
||||
0%, 100% { transform: scale(3) translateY(0) skewX(0deg); }
|
||||
25% { transform: scale(3) translateY(-2px) skewX(2deg); }
|
||||
75% { transform: scale(3) translateY(2px) skewX(-2deg); }
|
||||
}
|
||||
|
||||
@keyframes px-clam-open {
|
||||
0%, 100% { transform: scale(3) scaleY(1); }
|
||||
50% { transform: scale(3) scaleY(0.5); }
|
||||
}
|
||||
|
||||
/* ── 1. Seahorse — yellow-orange, gentle bob, swims left ── */
|
||||
.seahorse {
|
||||
top: 40%;
|
||||
animation:
|
||||
px-swim-left 160s linear infinite,
|
||||
px-bob 8s ease-in-out infinite;
|
||||
box-shadow:
|
||||
1px -2px #fbbf24, 2px -2px #fbbf24,
|
||||
0 -1px #f59e0b, 1px -1px #f59e0b, 2px -1px #f59e0b,
|
||||
0 0 #f59e0b, 1px 0 #d97706,
|
||||
0 1px #d97706, 1px 1px #d97706,
|
||||
0 2px #b45309, 1px 2px #b45309,
|
||||
-1px 3px #b45309, 0 3px #92400e,
|
||||
-1px 4px #92400e;
|
||||
}
|
||||
|
||||
/* ── 2. Octopus — purple-red, 4 trailing tentacles, swims right ── */
|
||||
.octopus {
|
||||
top: 55%;
|
||||
animation: px-swim-right 200s linear infinite;
|
||||
box-shadow:
|
||||
1px -1px #a855f7, 2px -1px #a855f7,
|
||||
0 0 #9333ea, 1px 0 #9333ea, 2px 0 #9333ea, 3px 0 #a855f7,
|
||||
0 1px #7c3aed, 1px 1px #7c3aed, 2px 1px #7c3aed, 3px 1px #7c3aed,
|
||||
-1px 2px #6d28d9, 0 2px #7c3aed, 2px 2px #7c3aed, 3px 2px #6d28d9,
|
||||
-1px 3px #5b21b6, 1px 3px #6d28d9, 3px 3px #5b21b6,
|
||||
-2px 4px #4c1d95, 0 4px #5b21b6, 2px 4px #5b21b6, 4px 4px #4c1d95;
|
||||
}
|
||||
|
||||
/* ── 3. Crab — red-orange, walking along bottom ── */
|
||||
.crab {
|
||||
top: 85%;
|
||||
animation: px-swim-right 120s linear infinite;
|
||||
box-shadow:
|
||||
-2px -1px #dc2626, 4px -1px #dc2626,
|
||||
-2px 0 #ef4444, -1px 0 #ef4444, 4px 0 #ef4444, 3px 0 #ef4444,
|
||||
0 0 #b91c1c, 1px 0 #dc2626, 2px 0 #dc2626,
|
||||
0 1px #991b1b, 1px 1px #b91c1c, 2px 1px #b91c1c,
|
||||
-1px 2px #7f1d1d, 0 2px #991b1b, 2px 2px #991b1b, 3px 2px #7f1d1d;
|
||||
}
|
||||
|
||||
/* ── 4. Eel — green-dark, sinuous, swims left ── */
|
||||
.eel {
|
||||
top: 45%;
|
||||
animation:
|
||||
px-swim-left 70s linear infinite,
|
||||
px-sine 4s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 #16a34a, 1px 0 #15803d, 2px 0 #166534, 3px 0 #14532d,
|
||||
4px 0 #166534, 5px 0 #15803d, 6px 0 #16a34a, 7px 0 #15803d,
|
||||
1px 1px #22c55e, 2px 1px #22c55e, 3px 1px #22c55e, 5px 1px #22c55e, 6px 1px #22c55e,
|
||||
0 -1px #fbbf24;
|
||||
}
|
||||
|
||||
/* ── 5. Pufferfish — yellow, inflates via scale pulse ── */
|
||||
.pufferfish {
|
||||
top: 35%;
|
||||
animation:
|
||||
px-swim-left 140s linear infinite,
|
||||
px-puff 10s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #fbbf24, 1px -1px #fbbf24,
|
||||
-1px 0 #f59e0b, 0 0 #eab308, 1px 0 #eab308, 2px 0 #f59e0b,
|
||||
-1px 1px #f59e0b, 0 1px #eab308, 1px 1px #eab308, 2px 1px #f59e0b,
|
||||
0 2px #d97706, 1px 2px #d97706,
|
||||
-2px 0 #ca8a04, 3px 0 #ca8a04, 0 -2px #ca8a04, 1px -2px #ca8a04,
|
||||
0 0 #1e293b;
|
||||
}
|
||||
|
||||
/* ── 6. Shrimp — pink-red, dart near bottom ── */
|
||||
.shrimp {
|
||||
top: 80%;
|
||||
animation: px-swim-right 50s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #fb7185, 1px 0 #f43f5e, 2px 0 #e11d48, 3px 0 #be123c,
|
||||
0 1px #fda4af, 1px 1px #fb7185, 2px 1px #f43f5e,
|
||||
-1px -1px rgba(251,113,133,0.6), -2px -2px rgba(251,113,133,0.3),
|
||||
4px 0 #9f1239, 5px -1px #881337;
|
||||
}
|
||||
|
||||
/* ── 7. Nautilus — cream-brown spiral shell, swims left ── */
|
||||
.nautilus {
|
||||
top: 50%;
|
||||
animation:
|
||||
px-swim-left 240s linear infinite,
|
||||
px-bob 12s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #d4a574, 1px -1px #c2956a,
|
||||
-1px 0 #d4a574, 0 0 #b08050, 1px 0 #a0714a, 2px 0 #c2956a,
|
||||
-1px 1px #c2956a, 0 1px #8b6040, 1px 1px #a0714a, 2px 1px #b08050,
|
||||
0 2px #c2956a, 1px 2px #d4a574,
|
||||
-2px 1px #fef3c7, -2px 2px #fde68a;
|
||||
}
|
||||
|
||||
/* ── 8. Swordfish — silver-blue, fast streak ── */
|
||||
.swordfish {
|
||||
top: 25%;
|
||||
animation: px-swim-left 25s linear infinite;
|
||||
box-shadow:
|
||||
-3px 0 #94a3b8, -2px 0 #94a3b8,
|
||||
-1px 0 #64748b, 0 0 #475569, 1px 0 #475569,
|
||||
2px 0 #334155, 3px 0 #334155, 4px 0 #475569, 5px 0 #64748b,
|
||||
1px 1px #94a3b8, 2px 1px #94a3b8, 3px 1px #94a3b8, 4px 1px #94a3b8,
|
||||
2px -1px #334155, 3px -1px #334155,
|
||||
6px -1px #475569, 6px 1px #475569;
|
||||
}
|
||||
|
||||
/* ── 9. Starfish — orange, very slow crawl along floor ── */
|
||||
.starfish-walk {
|
||||
top: 88%;
|
||||
animation: px-swim-right 360s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #f97316, 1px 0 #ea580c,
|
||||
0 -2px #fb923c, 1px -2px #fb923c,
|
||||
-2px 0 #fb923c, 3px 0 #fb923c,
|
||||
-1px 2px #fb923c, 2px 2px #fb923c,
|
||||
0 -1px #f97316, 1px -1px #f97316,
|
||||
-1px 0 #f97316, 2px 0 #f97316,
|
||||
0 1px #ea580c, 1px 1px #ea580c,
|
||||
-1px 1px #f97316, 2px 1px #f97316;
|
||||
}
|
||||
|
||||
/* ── 10. Anchor — grey iron, sinks slowly (very rare) ── */
|
||||
.anchor {
|
||||
left: 70%;
|
||||
animation: px-sink 100s linear forwards;
|
||||
box-shadow:
|
||||
0 -3px #6b7280, 1px -3px #6b7280,
|
||||
-1px -2px #6b7280, 2px -2px #6b7280,
|
||||
0 -2px #9ca3af, 1px -2px #9ca3af,
|
||||
0 -1px #4b5563, 1px -1px #4b5563,
|
||||
0 0 #4b5563, 1px 0 #4b5563,
|
||||
0 1px #4b5563, 1px 1px #4b5563,
|
||||
0 2px #374151, 1px 2px #374151,
|
||||
-2px 2px #6b7280, -1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #6b7280,
|
||||
-2px 3px #374151, 3px 3px #374151,
|
||||
-1px 3px #4b5563, 2px 3px #4b5563;
|
||||
}
|
||||
|
||||
/* ── 11. Treasure — brown chest with gold gleam (stationary) ── */
|
||||
.treasure {
|
||||
top: 90%;
|
||||
left: 25%;
|
||||
animation: px-pulse-glow 10s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #92400e, 1px -1px #92400e, 2px -1px #92400e, 3px -1px #92400e,
|
||||
-1px 0 #78350f, 0 0 #78350f, 1px 0 #92400e, 2px 0 #92400e, 3px 0 #78350f, 4px 0 #78350f,
|
||||
-1px 1px #78350f, 0 1px #78350f, 1px 1px #92400e, 2px 1px #92400e, 3px 1px #78350f, 4px 1px #78350f,
|
||||
1px 0 #fbbf24, 2px 0 #fbbf24,
|
||||
1px -1px #fde68a;
|
||||
}
|
||||
|
||||
/* ── 12. Submarine — grey with yellow light (very rare), swims right ── */
|
||||
.submarine {
|
||||
top: 30%;
|
||||
animation: px-swim-right 320s linear infinite;
|
||||
box-shadow:
|
||||
3px -2px #6b7280, 3px -1px #6b7280,
|
||||
0 0 #4b5563, 1px 0 #4b5563, 2px 0 #4b5563, 3px 0 #4b5563, 4px 0 #4b5563, 5px 0 #4b5563,
|
||||
-1px 1px #374151, 0 1px #374151, 1px 1px #374151, 2px 1px #374151, 3px 1px #374151, 4px 1px #374151, 5px 1px #374151, 6px 1px #374151,
|
||||
0 2px #4b5563, 1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #4b5563, 4px 2px #4b5563, 5px 2px #4b5563,
|
||||
1px 1px #fbbf24, 4px 1px #fbbf24,
|
||||
-2px 0 #9ca3af, -2px 1px #9ca3af, -2px 2px #9ca3af;
|
||||
}
|
||||
|
||||
/* ── 13. Diver — black suit, white mask, bubble trail (very rare) ── */
|
||||
.diver {
|
||||
top: 40%;
|
||||
animation: px-swim-left 180s linear infinite;
|
||||
box-shadow:
|
||||
0 -1px #f8fafc, 1px -1px #f8fafc,
|
||||
0 0 #1e293b, 1px 0 #1e293b,
|
||||
0 1px #1e293b, 1px 1px #1e293b,
|
||||
0 2px #0f172a, 1px 2px #0f172a,
|
||||
-1px 3px #1e293b, 2px 3px #1e293b,
|
||||
2px 0 #475569, 2px 1px #475569,
|
||||
-1px -2px rgba(255,255,255,0.5), -2px -3px rgba(255,255,255,0.3), -3px -4px rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* ── 14. Sea Anemone — pink-purple, tentacle wave (stationary) ── */
|
||||
.sea-anemone {
|
||||
top: 92%;
|
||||
left: 60%;
|
||||
animation: px-tentacle 12s ease-in-out infinite;
|
||||
box-shadow:
|
||||
-1px -2px #e879f9, 0 -2px #d946ef, 1px -2px #c026d3, 2px -2px #e879f9,
|
||||
-1px -1px #d946ef, 0 -1px #c026d3, 1px -1px #d946ef, 2px -1px #c026d3,
|
||||
0 0 #a21caf, 1px 0 #a21caf,
|
||||
0 1px #86198f, 1px 1px #86198f;
|
||||
}
|
||||
|
||||
/* ── 15. Clam — grey shell, pearl gleam inside (stationary) ── */
|
||||
.clam {
|
||||
top: 90%;
|
||||
left: 82%;
|
||||
animation: px-clam-open 16s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #9ca3af, 1px -1px #9ca3af, 2px -1px #9ca3af,
|
||||
-1px 0 #6b7280, 0 0 #6b7280, 1px 0 #6b7280, 2px 0 #6b7280, 3px 0 #6b7280,
|
||||
-1px 1px #4b5563, 0 1px #4b5563, 1px 1px #4b5563, 2px 1px #4b5563, 3px 1px #4b5563,
|
||||
0 2px #6b7280, 1px 2px #6b7280, 2px 2px #6b7280,
|
||||
1px 0 #fef3c7;
|
||||
}
|
||||
</style>
|
||||
@@ -7,3 +7,4 @@ export { default as FishSchool } from './FishSchool.vue'
|
||||
export { default as JellyfishDrift } from './JellyfishDrift.vue'
|
||||
export { default as EventOverlay } from './EventOverlay.vue'
|
||||
export { default as EdgeFade } from './EdgeFade.vue'
|
||||
export { default as PixelLife } from './PixelLife.vue'
|
||||
|
||||
@@ -14,4 +14,5 @@ export { default as PlanApproval } from './PlanApproval.vue'
|
||||
export { default as CodeBlock } from './CodeBlock.vue'
|
||||
export { default as AgentBadge } from './AgentBadge.vue'
|
||||
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
|
||||
export { default as VoiceMicButton } from './VoiceMicButton.vue'
|
||||
export { AquaticBackground } from './aquaticBackground'
|
||||
|
||||
@@ -19,9 +19,23 @@ import type {
|
||||
ProgressEntry,
|
||||
ContentBlock,
|
||||
ToolUseBlock,
|
||||
ToolResultBlock
|
||||
ToolResultBlock,
|
||||
TerminalSlot
|
||||
} from '@/types/transcript-debug'
|
||||
|
||||
// Server registry entry shape (returned by GET /terminal-registry)
|
||||
interface ServerRegistryEntry {
|
||||
ephemeralSessionId: string
|
||||
transcriptSessionId: string
|
||||
agent: string
|
||||
label: string
|
||||
command: string
|
||||
createdAt: string
|
||||
alive: boolean
|
||||
clients: number
|
||||
bufferSize: number
|
||||
}
|
||||
|
||||
export function useTranscriptDebug() {
|
||||
// ── Persistence ──
|
||||
const STORAGE_KEY = 'transcript-debug-selection'
|
||||
@@ -59,7 +73,7 @@ export function useTranscriptDebug() {
|
||||
const error = ref<string | null>(null)
|
||||
const isRealtime = ref(false)
|
||||
|
||||
// ── Ephemeral terminal ──
|
||||
// ── Terminal registry (server-backed) ──
|
||||
|
||||
const AGENT_CMD: Record<AgentName, string> = {
|
||||
ejecutor: 'ejecutor',
|
||||
@@ -67,29 +81,251 @@ export function useTranscriptDebug() {
|
||||
claude: 'claude'
|
||||
}
|
||||
|
||||
const ephemeral = shallowRef<EphemeralTerminal | null>(null)
|
||||
// Server registry data (source of truth for all clients)
|
||||
const serverRegistry = ref<ServerRegistryEntry[]>([])
|
||||
|
||||
// Local terminal instances (this client's active WS connections)
|
||||
const localTerminals = new Map<string, EphemeralTerminal>()
|
||||
|
||||
// Which terminal is currently active for THIS client
|
||||
const activeTerminalSessionId = ref<string | null>(null)
|
||||
|
||||
// Computed: current active terminal
|
||||
const ephemeral = computed<EphemeralTerminal | null>(() => {
|
||||
if (!activeTerminalSessionId.value) return null
|
||||
return localTerminals.get(activeTerminalSessionId.value) ?? null
|
||||
})
|
||||
|
||||
const terminalReady = computed(() => ephemeral.value?.state.value === 'running')
|
||||
|
||||
async function disposeTerminal() {
|
||||
if (ephemeral.value) {
|
||||
await ephemeral.value.dispose()
|
||||
ephemeral.value = null
|
||||
}
|
||||
}
|
||||
// Computed: terminal list for AgentBadge dropdown (from server registry)
|
||||
const openTerminals = computed<TerminalSlot[]>(() =>
|
||||
serverRegistry.value.map(entry => ({
|
||||
sessionId: entry.transcriptSessionId,
|
||||
ephemeralSessionId: entry.ephemeralSessionId,
|
||||
agent: entry.agent as AgentName,
|
||||
label: entry.label,
|
||||
command: entry.command,
|
||||
active: entry.transcriptSessionId === activeTerminalSessionId.value,
|
||||
alive: entry.alive,
|
||||
clients: entry.clients
|
||||
}))
|
||||
)
|
||||
|
||||
const awaitingNewSession = ref(false)
|
||||
|
||||
// ── Server registry HTTP helpers ──
|
||||
|
||||
async function fetchTerminalRegistry() {
|
||||
try {
|
||||
const res = await fetch(terminalApiUrl('/terminal-registry'))
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
serverRegistry.value = data.registry ?? []
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
async function registerTerminalOnServer(
|
||||
ephemeralSessionId: string,
|
||||
transcriptSessionId: string,
|
||||
agent: AgentName,
|
||||
label: string,
|
||||
command: string
|
||||
) {
|
||||
try {
|
||||
await fetch(terminalApiUrl('/register-terminal'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ephemeralSessionId,
|
||||
transcriptSessionId,
|
||||
agent,
|
||||
label,
|
||||
command,
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
// Server broadcasts change → other clients pick it up
|
||||
await fetchTerminalRegistry()
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
async function updateTerminalOnServer(
|
||||
ephemeralSessionId: string,
|
||||
updates: { transcriptSessionId?: string; label?: string }
|
||||
) {
|
||||
try {
|
||||
await fetch(terminalApiUrl('/update-terminal'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ephemeralSessionId, ...updates })
|
||||
})
|
||||
await fetchTerminalRegistry()
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
async function unregisterTerminalOnServer(ephemeralSessionId: string) {
|
||||
try {
|
||||
await fetch(terminalApiUrl('/unregister-terminal'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ephemeralSessionId })
|
||||
})
|
||||
await fetchTerminalRegistry()
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// ── Registry polling for multi-client sync ──
|
||||
|
||||
let registryPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startRegistryPolling() {
|
||||
if (registryPollTimer) return
|
||||
registryPollTimer = setInterval(fetchTerminalRegistry, 5000)
|
||||
}
|
||||
|
||||
function stopRegistryPolling() {
|
||||
if (registryPollTimer) {
|
||||
clearInterval(registryPollTimer)
|
||||
registryPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal lifecycle ──
|
||||
|
||||
function getSessionLabel(sessionId: string): string {
|
||||
const session = sessions.value.find(s => s.id === sessionId)
|
||||
if (session?.firstUserMessage) {
|
||||
const msg = session.firstUserMessage.trim()
|
||||
return msg.length > 30 ? msg.slice(0, 30) + '...' : msg
|
||||
}
|
||||
return sessionId.slice(0, 12) + '...'
|
||||
}
|
||||
|
||||
function startTerminal(sessionId?: string) {
|
||||
const key = sessionId || '__new__'
|
||||
const cmd = sessionId
|
||||
? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"`
|
||||
: AGENT_CMD[selectedAgent.value]
|
||||
|
||||
const term = useEphemeralTerminal(cmd)
|
||||
ephemeral.value = term
|
||||
localTerminals.set(key, term)
|
||||
activeTerminalSessionId.value = key
|
||||
|
||||
term.start()
|
||||
|
||||
// Register on server
|
||||
registerTerminalOnServer(
|
||||
term.ephemeralSessionId,
|
||||
key,
|
||||
selectedAgent.value,
|
||||
sessionId ? getSessionLabel(sessionId) : 'New session',
|
||||
cmd
|
||||
)
|
||||
}
|
||||
|
||||
function parkCurrentTerminal() {
|
||||
const currentId = activeTerminalSessionId.value
|
||||
if (!currentId) return
|
||||
|
||||
const term = localTerminals.get(currentId)
|
||||
if (term) {
|
||||
term.park()
|
||||
localTerminals.delete(currentId)
|
||||
}
|
||||
activeTerminalSessionId.value = null
|
||||
}
|
||||
|
||||
function connectToTerminal(transcriptSessionId: string) {
|
||||
// Find the server registry entry
|
||||
const entry = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === transcriptSessionId
|
||||
)
|
||||
if (!entry) return
|
||||
|
||||
// Create local EphemeralTerminal reusing the existing PTY session
|
||||
const term = useEphemeralTerminal(entry.command, entry.ephemeralSessionId)
|
||||
localTerminals.set(transcriptSessionId, term)
|
||||
activeTerminalSessionId.value = transcriptSessionId
|
||||
|
||||
term.start()
|
||||
}
|
||||
|
||||
async function closeTerminal(transcriptSessionId: string) {
|
||||
// Find the ephemeralSessionId from registry
|
||||
const entry = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === transcriptSessionId
|
||||
)
|
||||
const ephSid = entry?.ephemeralSessionId
|
||||
|
||||
// Dispose local terminal if we have one
|
||||
const term = localTerminals.get(transcriptSessionId)
|
||||
if (term) {
|
||||
await term.dispose()
|
||||
localTerminals.delete(transcriptSessionId)
|
||||
} else if (ephSid) {
|
||||
// No local terminal — kill the PTY directly on server
|
||||
try {
|
||||
await fetch(terminalApiUrl('/kill-session'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: ephSid })
|
||||
})
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Unregister if not auto-removed by kill
|
||||
if (ephSid) {
|
||||
await unregisterTerminalOnServer(ephSid)
|
||||
}
|
||||
|
||||
await fetchTerminalRegistry()
|
||||
|
||||
// If it was active, switch to another or clear
|
||||
if (activeTerminalSessionId.value === transcriptSessionId) {
|
||||
activeTerminalSessionId.value = null
|
||||
const remaining = openTerminals.value.filter(t => t.sessionId !== transcriptSessionId)
|
||||
if (remaining.length > 0) {
|
||||
await switchToTerminal(remaining[remaining.length - 1].sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToTerminal(transcriptSessionId: string) {
|
||||
if (transcriptSessionId === activeTerminalSessionId.value) return
|
||||
|
||||
// Park current
|
||||
parkCurrentTerminal()
|
||||
|
||||
// Find the entry — might be from another agent
|
||||
const entry = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === transcriptSessionId
|
||||
)
|
||||
if (entry && entry.agent !== selectedAgent.value) {
|
||||
// Switch agent context without disposing terminals
|
||||
selectedAgent.value = entry.agent as AgentName
|
||||
await fetchSessions()
|
||||
}
|
||||
|
||||
// Load the target session's transcript
|
||||
selectedSessionId.value = transcriptSessionId
|
||||
saveState()
|
||||
await fetchSessionContent(transcriptSessionId)
|
||||
|
||||
// Connect to the terminal
|
||||
connectToTerminal(transcriptSessionId)
|
||||
}
|
||||
|
||||
async function disposeAllLocalTerminals() {
|
||||
for (const [, term] of localTerminals) {
|
||||
await term.dispose()
|
||||
}
|
||||
localTerminals.clear()
|
||||
activeTerminalSessionId.value = null
|
||||
}
|
||||
|
||||
async function createNewSession() {
|
||||
await disposeTerminal()
|
||||
parkCurrentTerminal()
|
||||
|
||||
selectedSessionId.value = null
|
||||
rawContent.value = ''
|
||||
@@ -158,9 +394,30 @@ export function useTranscriptDebug() {
|
||||
// Refresh session list (new sessions or size changes)
|
||||
await fetchSessions()
|
||||
|
||||
// New session just appeared — lock onto it without restarting terminal
|
||||
// Also refresh terminal registry (other clients may have changed things)
|
||||
await fetchTerminalRegistry()
|
||||
|
||||
// New session just appeared — lock onto it, re-key from __new__
|
||||
if (awaitingNewSession.value) {
|
||||
awaitingNewSession.value = false
|
||||
|
||||
// Re-key the __new__ local terminal
|
||||
const newTerm = localTerminals.get('__new__')
|
||||
if (newTerm) {
|
||||
localTerminals.delete('__new__')
|
||||
localTerminals.set(changedSessionId, newTerm)
|
||||
|
||||
// Update server registry with real sessionId
|
||||
await updateTerminalOnServer(newTerm.ephemeralSessionId, {
|
||||
transcriptSessionId: changedSessionId,
|
||||
label: getSessionLabel(changedSessionId)
|
||||
})
|
||||
|
||||
if (activeTerminalSessionId.value === '__new__') {
|
||||
activeTerminalSessionId.value = changedSessionId
|
||||
}
|
||||
}
|
||||
|
||||
selectedSessionId.value = changedSessionId
|
||||
saveState()
|
||||
await fetchSessionContent(changedSessionId)
|
||||
@@ -174,6 +431,15 @@ export function useTranscriptDebug() {
|
||||
// No session selected yet — auto-select the newest
|
||||
await selectSession(sessions.value[0].id)
|
||||
}
|
||||
|
||||
// Update label for any terminal in the registry matching this session
|
||||
const entry = serverRegistry.value.find(e => e.transcriptSessionId === changedSessionId)
|
||||
if (entry) {
|
||||
const newLabel = getSessionLabel(changedSessionId)
|
||||
if (entry.label !== newLabel) {
|
||||
updateTerminalOnServer(entry.ephemeralSessionId, { label: newLabel })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRealtimeDone(changedSessionId: string) {
|
||||
@@ -199,17 +465,13 @@ export function useTranscriptDebug() {
|
||||
m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText)
|
||||
)
|
||||
if (found) {
|
||||
// Real message arrived, drop the optimistic one
|
||||
optimisticMessage.value = null
|
||||
} else {
|
||||
// Not yet in JSONL, keep showing it at the end
|
||||
parsed.messages.push(optimisticMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect turn completion from JSONL content:
|
||||
// If we're processing, the optimistic was replaced (real user entry exists),
|
||||
// and the last substantive message is an assistant response → turn is done
|
||||
// Detect turn completion from JSONL content
|
||||
if (processing.value && !optimisticMessage.value) {
|
||||
const lastSubstantive = [...parsed.messages]
|
||||
.reverse()
|
||||
@@ -225,14 +487,13 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
}
|
||||
|
||||
// Kill terminal on page close/refresh
|
||||
// Park local terminals on page close (don't kill — they persist for other clients)
|
||||
function onBeforeUnload() {
|
||||
if (ephemeral.value) {
|
||||
const sessionId = ephemeral.value.ephemeralSessionId
|
||||
navigator.sendBeacon(
|
||||
terminalApiUrl('/kill-session'),
|
||||
JSON.stringify({ sessionId })
|
||||
)
|
||||
for (const [, term] of localTerminals) {
|
||||
// Just park: close WS without killing server PTY
|
||||
try {
|
||||
term.park()
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,12 +503,19 @@ export function useTranscriptDebug() {
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
disposeTerminal()
|
||||
// Park local terminals (don't kill server PTYs — they're global)
|
||||
for (const [, term] of localTerminals) {
|
||||
try { term.park() } catch {}
|
||||
}
|
||||
localTerminals.clear()
|
||||
activeTerminalSessionId.value = null
|
||||
|
||||
stopRegistryPolling()
|
||||
disconnectRealtime()
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
})
|
||||
|
||||
// ── Send prompt (spawns independent claude process) ──
|
||||
// ── Send prompt ──
|
||||
|
||||
const sending = ref(false)
|
||||
const processing = ref(false)
|
||||
@@ -265,8 +533,6 @@ export function useTranscriptDebug() {
|
||||
ephemeral.value.sendInput(text)
|
||||
processing.value = true
|
||||
|
||||
// Optimistic: show user message immediately in chat
|
||||
// Stays until the real user entry appears in the JSONL
|
||||
optimisticMessage.value = {
|
||||
kind: 'user',
|
||||
uuid: `optimistic-${Date.now()}`,
|
||||
@@ -296,7 +562,10 @@ export function useTranscriptDebug() {
|
||||
|
||||
async function fetchSessionContent(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${selectedAgent.value}`)
|
||||
// Determine agent from registry if different from selected
|
||||
const entry = serverRegistry.value.find(e => e.transcriptSessionId === sessionId)
|
||||
const agent = entry?.agent || selectedAgent.value
|
||||
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
rawContent.value = await res.text()
|
||||
conversation.value = parseJsonl(rawContent.value, sessionId)
|
||||
@@ -309,6 +578,8 @@ export function useTranscriptDebug() {
|
||||
|
||||
async function init() {
|
||||
await fetchSessions()
|
||||
await fetchTerminalRegistry()
|
||||
startRegistryPolling()
|
||||
|
||||
let targetSession = selectedSessionId.value
|
||||
|
||||
@@ -326,7 +597,17 @@ export function useTranscriptDebug() {
|
||||
selectedSessionId.value = targetSession
|
||||
saveState()
|
||||
await fetchSessionContent(targetSession)
|
||||
startTerminal(targetSession)
|
||||
|
||||
// Check if there's already a terminal for this session in the registry
|
||||
const existing = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === targetSession
|
||||
)
|
||||
if (existing && existing.alive) {
|
||||
// Connect to existing terminal (may have been created by another client)
|
||||
connectToTerminal(targetSession)
|
||||
} else {
|
||||
startTerminal(targetSession)
|
||||
}
|
||||
} else {
|
||||
selectedSessionId.value = null
|
||||
saveState()
|
||||
@@ -339,25 +620,35 @@ export function useTranscriptDebug() {
|
||||
async function switchAgent(agent: AgentName) {
|
||||
if (agent === selectedAgent.value) return
|
||||
|
||||
await disposeTerminal()
|
||||
// Park local terminals (don't kill — they're global)
|
||||
parkCurrentTerminal()
|
||||
|
||||
selectedAgent.value = agent
|
||||
error.value = null
|
||||
loading.value = true
|
||||
transitioning.value = true
|
||||
|
||||
// Wait for fade-out
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
|
||||
rawContent.value = ''
|
||||
conversation.value = null
|
||||
|
||||
await fetchSessions()
|
||||
await fetchTerminalRegistry()
|
||||
|
||||
if (sessions.value.length > 0) {
|
||||
selectedSessionId.value = sessions.value[0].id
|
||||
await fetchSessionContent(sessions.value[0].id)
|
||||
startTerminal(sessions.value[0].id)
|
||||
|
||||
// Check if there's already a terminal for this session
|
||||
const existing = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === sessions.value[0].id && e.alive
|
||||
)
|
||||
if (existing) {
|
||||
connectToTerminal(sessions.value[0].id)
|
||||
} else {
|
||||
startTerminal(sessions.value[0].id)
|
||||
}
|
||||
} else {
|
||||
selectedSessionId.value = null
|
||||
}
|
||||
@@ -370,13 +661,12 @@ export function useTranscriptDebug() {
|
||||
async function selectSession(sessionId: string) {
|
||||
if (sessionId === selectedSessionId.value) return
|
||||
|
||||
await disposeTerminal()
|
||||
parkCurrentTerminal()
|
||||
|
||||
error.value = null
|
||||
loading.value = true
|
||||
transitioning.value = true
|
||||
|
||||
// Wait for fade-out
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
|
||||
selectedSessionId.value = sessionId
|
||||
@@ -386,7 +676,15 @@ export function useTranscriptDebug() {
|
||||
transitioning.value = false
|
||||
loading.value = false
|
||||
|
||||
startTerminal(sessionId)
|
||||
// Check if there's already a terminal for this session in the registry
|
||||
const existing = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === sessionId && e.alive
|
||||
)
|
||||
if (existing) {
|
||||
connectToTerminal(sessionId)
|
||||
} else {
|
||||
startTerminal(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSONL Parser ──
|
||||
@@ -427,19 +725,11 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
}
|
||||
|
||||
// Build conversation messages
|
||||
const messages: ConversationMessage[] = []
|
||||
|
||||
// Group assistant entries by message.id (streaming chunks)
|
||||
const assistantGroups = new Map<string, AssistantEntry[]>()
|
||||
|
||||
// Collect tool results from user entries
|
||||
const toolResultMap = new Map<string, ParsedToolResult>()
|
||||
|
||||
// Collect progress events by toolUseID
|
||||
const progressByTool = new Map<string, ParsedProgressEvent[]>()
|
||||
|
||||
// Helper to build a ParsedProgressEvent from a raw ProgressEntry
|
||||
function buildProgressEvent(pe: ProgressEntry): ParsedProgressEvent {
|
||||
return {
|
||||
uuid: pe.uuid,
|
||||
@@ -517,7 +807,6 @@ export function useTranscriptDebug() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Flush progress batch before next non-progress entry
|
||||
if (currentProgressBatch.length > 0) {
|
||||
messages.push({
|
||||
kind: 'progress',
|
||||
@@ -566,7 +855,6 @@ export function useTranscriptDebug() {
|
||||
const group = assistantGroups.get(msgId)
|
||||
if (!group) continue
|
||||
|
||||
// Only process first entry in group (we merge all chunks)
|
||||
if (group[0].uuid !== ae.uuid) continue
|
||||
|
||||
const textBlocks: string[] = []
|
||||
@@ -629,7 +917,6 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining progress
|
||||
if (currentProgressBatch.length > 0) {
|
||||
messages.push({
|
||||
kind: 'progress',
|
||||
@@ -675,11 +962,15 @@ export function useTranscriptDebug() {
|
||||
ephemeral,
|
||||
terminalReady,
|
||||
awaitingNewSession,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
init,
|
||||
fetchSessions,
|
||||
switchAgent,
|
||||
selectSession,
|
||||
createNewSession,
|
||||
switchToTerminal,
|
||||
closeTerminal,
|
||||
connectRealtime,
|
||||
disconnectRealtime,
|
||||
sendPrompt
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* useEphemeralTerminal
|
||||
*
|
||||
* Lightweight composable for ephemeral terminal sessions.
|
||||
* Creates a temporary PTY via WebSocket, runs a command, and
|
||||
* cleans up completely when disposed (no persistent session).
|
||||
* Composable for terminal sessions that can be parked (WS closed,
|
||||
* renderer disposed) and later reconnected to the same server PTY
|
||||
* via buffer replay.
|
||||
*
|
||||
* Used by ResumeTerminalButton to open `<agent> --resume <sessionId>`.
|
||||
*/
|
||||
@@ -32,16 +32,21 @@ export interface EphemeralTerminal {
|
||||
|
||||
/** Full cleanup (stop + dispose renderer) */
|
||||
dispose: () => Promise<void>
|
||||
|
||||
/** Park: close WS + dispose renderer, but keep server PTY alive for reconnection */
|
||||
park: () => void
|
||||
}
|
||||
|
||||
export function useEphemeralTerminal(
|
||||
command: string
|
||||
command: string,
|
||||
existingSessionId?: string
|
||||
): EphemeralTerminal {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const state = ref<EphemeralState>('off')
|
||||
|
||||
const ephemeralSessionId = `resume-${Date.now()}`
|
||||
const ephemeralSessionId = existingSessionId || `resume-${Date.now()}`
|
||||
const isReconnect = !!existingSessionId
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
|
||||
@@ -104,13 +109,21 @@ export function useEphemeralTerminal(
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
state.value = 'shell-ready'
|
||||
// Wait for shell prompt to render, then send the command
|
||||
setTimeout(() => {
|
||||
if (isReconnect) {
|
||||
// Reconnecting to existing PTY — request buffer replay
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: command + '\r' }))
|
||||
socket.send(JSON.stringify({ type: 'request-replay', tailOnly: false }))
|
||||
}
|
||||
state.value = 'running'
|
||||
}, 500)
|
||||
} else {
|
||||
// First time — wait for shell prompt, then send command
|
||||
setTimeout(() => {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: command + '\r' }))
|
||||
}
|
||||
state.value = 'running'
|
||||
}, 500)
|
||||
}
|
||||
break
|
||||
case 'replay':
|
||||
renderer.handleReplay(msg.data || '')
|
||||
@@ -188,6 +201,18 @@ export function useEphemeralTerminal(
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
function park() {
|
||||
// Close WS without killing server PTY — it stays alive for reconnection
|
||||
if (socket) {
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
connected.value = false
|
||||
renderer.dispose()
|
||||
state.value = 'off'
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
connected,
|
||||
@@ -197,6 +222,7 @@ export function useEphemeralTerminal(
|
||||
start,
|
||||
sendInput,
|
||||
stop,
|
||||
dispose
|
||||
dispose,
|
||||
park
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
||||
import { useVoiceInput } from '@/composables/useVoiceInput'
|
||||
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug'
|
||||
import type { AgentName } from '@/types/transcript-debug'
|
||||
|
||||
@@ -27,6 +28,19 @@ const {
|
||||
sendPrompt
|
||||
} = useTranscriptDebug()
|
||||
|
||||
const voice = useVoiceInput({ language: 'es-419' })
|
||||
const {
|
||||
isRecording: voiceRecording,
|
||||
transcript: voiceTranscript,
|
||||
interimTranscript: voiceInterim,
|
||||
voiceMode,
|
||||
whisperStatus,
|
||||
audioDevices,
|
||||
selectedDeviceId,
|
||||
lastAudioUrl,
|
||||
isPlayingAudio,
|
||||
} = voice
|
||||
|
||||
const agents: { id: AgentName; label: string }[] = [
|
||||
{ id: 'ejecutor', label: 'Ejecutor' },
|
||||
{ id: 'nucleo000', label: 'nucleo000' },
|
||||
@@ -34,6 +48,7 @@ const agents: { id: AgentName; label: string }[] = [
|
||||
]
|
||||
|
||||
function handleSend(message: string) {
|
||||
voice.clearTranscript()
|
||||
sendPrompt(message)
|
||||
}
|
||||
|
||||
@@ -41,12 +56,14 @@ function handleCreateSession() {
|
||||
createNewSession()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
init()
|
||||
await voice.init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectRealtime()
|
||||
voice.cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -119,8 +136,21 @@ onUnmounted(() => {
|
||||
:terminal-ready="terminalReady"
|
||||
:terminal="ephemeral"
|
||||
:selected-agent="selectedAgent"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus"
|
||||
:audio-devices="audioDevices"
|
||||
:selected-device-id="selectedDeviceId"
|
||||
:is-recording="voiceRecording"
|
||||
:voice-transcript="voiceTranscript + voiceInterim"
|
||||
:last-audio-url="lastAudioUrl"
|
||||
:is-playing-audio="isPlayingAudio"
|
||||
@send="handleSend"
|
||||
@create-session="handleCreateSession"
|
||||
@start-recording="voice.startRecording()"
|
||||
@stop-recording="voice.stopRecording()"
|
||||
@set-voice-mode="voice.setMode($event)"
|
||||
@select-microphone="voice.selectMicrophone($event)"
|
||||
@play-last-audio="voice.playLastAudio()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
@@ -28,10 +26,13 @@ html, body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
||||
@@ -216,6 +216,19 @@ export interface ParsedSystemMessage {
|
||||
subtype?: string
|
||||
}
|
||||
|
||||
// ── Terminal slot (persistent terminal registry) ──
|
||||
|
||||
export interface TerminalSlot {
|
||||
sessionId: string // Claude transcript session ID (transcriptSessionId)
|
||||
ephemeralSessionId: string // PTY session ID on server (for reconnection)
|
||||
agent: AgentName
|
||||
label: string // firstUserMessage (truncated) or "New session"
|
||||
command: string // The command used to start
|
||||
active: boolean // Is this the currently displayed terminal (for this client)?
|
||||
alive: boolean // PTY exists on server
|
||||
clients: number // Number of connected WS clients
|
||||
}
|
||||
|
||||
// ── Raw entry union ──
|
||||
|
||||
export type TranscriptEntry =
|
||||
|
||||
@@ -33,6 +33,47 @@ const sessions = new Map<string, TerminalSession>()
|
||||
// Map WebSocket to sessionId
|
||||
const wsToSession = new Map<any, string>()
|
||||
|
||||
// ── Global terminal registry ──
|
||||
// Tracks metadata about transcript-debug terminals so all clients can see/connect to them
|
||||
|
||||
interface TerminalRegistryEntry {
|
||||
ephemeralSessionId: string // PTY session ID on this server
|
||||
transcriptSessionId: string // Claude transcript session being resumed (or '__new__')
|
||||
agent: string // ejecutor | nucleo000 | claude
|
||||
label: string // First user message or short description
|
||||
command: string // Full command that was run
|
||||
createdAt: string // ISO timestamp
|
||||
}
|
||||
|
||||
const terminalRegistry = new Map<string, TerminalRegistryEntry>() // keyed by ephemeralSessionId
|
||||
|
||||
function getRegistrySnapshot() {
|
||||
return Array.from(terminalRegistry.values()).map(entry => {
|
||||
const ptySession = sessions.get(entry.ephemeralSessionId)
|
||||
return {
|
||||
...entry,
|
||||
alive: !!ptySession,
|
||||
clients: ptySession?.clients.size ?? 0,
|
||||
bufferSize: ptySession?.outputBuffer.length ?? 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function broadcastRegistryChange() {
|
||||
const message = JSON.stringify({
|
||||
type: 'terminal-registry-change',
|
||||
registry: getRegistrySnapshot(),
|
||||
timestamp: Date.now()
|
||||
})
|
||||
let clientCount = 0
|
||||
for (const [, session] of sessions) {
|
||||
for (const ws of session.clients) {
|
||||
try { ws.send(message); clientCount++ } catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
console.log(`[Terminal] Registry broadcast → ${clientCount} clients (${terminalRegistry.size} entries)`)
|
||||
}
|
||||
|
||||
function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession {
|
||||
let session = sessions.get(sessionId)
|
||||
|
||||
@@ -83,6 +124,12 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
}
|
||||
sessions.delete(sessionId)
|
||||
|
||||
// Auto-remove from terminal registry
|
||||
if (terminalRegistry.has(sessionId)) {
|
||||
terminalRegistry.delete(sessionId)
|
||||
broadcastRegistryChange()
|
||||
}
|
||||
|
||||
// Mark agent as not running if this is an agent session
|
||||
if (sessionId.startsWith('agent-')) {
|
||||
const agentId = sessionId.replace('agent-', '')
|
||||
@@ -122,6 +169,13 @@ export function killSession(sessionId: string): boolean {
|
||||
}
|
||||
|
||||
sessions.delete(sessionId)
|
||||
|
||||
// Auto-remove from terminal registry
|
||||
if (terminalRegistry.has(sessionId)) {
|
||||
terminalRegistry.delete(sessionId)
|
||||
broadcastRegistryChange()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -301,6 +355,67 @@ export function startTerminalServer() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal Registry endpoints ──
|
||||
|
||||
// List all registered terminals (global, for all clients)
|
||||
if (url.pathname === '/terminal-registry' && req.method === 'GET') {
|
||||
return Response.json({ registry: getRegistrySnapshot() }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Register a new terminal
|
||||
if (url.pathname === '/register-terminal' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as TerminalRegistryEntry
|
||||
if (!body.ephemeralSessionId) {
|
||||
return Response.json({ error: 'ephemeralSessionId required' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
terminalRegistry.set(body.ephemeralSessionId, {
|
||||
ephemeralSessionId: body.ephemeralSessionId,
|
||||
transcriptSessionId: body.transcriptSessionId || '',
|
||||
agent: body.agent || '',
|
||||
label: body.label || '',
|
||||
command: body.command || '',
|
||||
createdAt: body.createdAt || new Date().toISOString()
|
||||
})
|
||||
console.log(`[Terminal] Registered terminal: ${body.ephemeralSessionId} → ${body.transcriptSessionId} (${body.agent})`)
|
||||
broadcastRegistryChange()
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Update a registered terminal (e.g. re-key transcriptSessionId, update label)
|
||||
if (url.pathname === '/update-terminal' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as Partial<TerminalRegistryEntry> & { ephemeralSessionId: string }
|
||||
const entry = terminalRegistry.get(body.ephemeralSessionId)
|
||||
if (!entry) {
|
||||
return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
if (body.transcriptSessionId !== undefined) entry.transcriptSessionId = body.transcriptSessionId
|
||||
if (body.label !== undefined) entry.label = body.label
|
||||
if (body.agent !== undefined) entry.agent = body.agent
|
||||
if (body.command !== undefined) entry.command = body.command
|
||||
broadcastRegistryChange()
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister a terminal (does NOT kill the PTY — use /kill-session for that)
|
||||
if (url.pathname === '/unregister-terminal' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as { ephemeralSessionId: string }
|
||||
const deleted = terminalRegistry.delete(body.ephemeralSessionId)
|
||||
if (deleted) broadcastRegistryChange()
|
||||
return Response.json({ success: true, deleted }, { headers: corsHeaders })
|
||||
} catch (e: any) {
|
||||
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a WebSocket upgrade request
|
||||
const upgradeHeader = req.headers.get('upgrade')
|
||||
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
## ============================================
|
||||
## Agent UI - Traefik Dynamic Configuration
|
||||
## ============================================
|
||||
##
|
||||
## INSTALACIÓN:
|
||||
## 1. Copia este archivo a tu directorio de config dinámica de Traefik
|
||||
## Ejemplo: /etc/traefik/dynamic/agent-ui.yml
|
||||
##
|
||||
## 2. Asegúrate de tener el file provider habilitado en traefik.yml:
|
||||
## providers:
|
||||
## file:
|
||||
## directory: /etc/traefik/dynamic
|
||||
## watch: true
|
||||
##
|
||||
## 3. Asegúrate de tener el certResolver configurado (letsencrypt)
|
||||
##
|
||||
## ARQUITECTURA:
|
||||
## ┌─────────────────────────────────────────────────────┐
|
||||
## │ https://z590.nucleoriofrio.com │
|
||||
## │ / → Frontend (4100) │
|
||||
## │ /api/* → API Server (4101) │
|
||||
## │ /ws/terminal→ Terminal WebSocket (4103) │
|
||||
## │ /ws/status → Claude Status WebSocket (4103) │
|
||||
## │ /ws/mcp → WebMCP WebSocket (4102) │
|
||||
## │ /ws/whisper → Whisper WebSocket (4104) │
|
||||
## └─────────────────────────────────────────────────────┘
|
||||
##
|
||||
|
||||
http:
|
||||
## ============================================
|
||||
## MIDDLEWARES
|
||||
## ============================================
|
||||
middlewares:
|
||||
## ----------------------------------------
|
||||
## IP Whitelist (DESCOMENTEAR PARA ACTIVAR)
|
||||
## ----------------------------------------
|
||||
## Para activar: descomenta este bloque y agrega
|
||||
## "agentui-ipwhitelist" a la lista de middlewares
|
||||
## de cada router.
|
||||
##
|
||||
# agentui-ipwhitelist:
|
||||
# ipWhiteList:
|
||||
# sourceRange:
|
||||
# - "192.168.87.0/24" # Red local del servidor
|
||||
# - "192.168.1.0/24" # Otra red local
|
||||
# - "10.0.0.0/8" # Redes internas
|
||||
# - "YOUR_PUBLIC_IP/32" # Tu IP pública específica
|
||||
# # Si hay proxies intermedios, usa ipStrategy:
|
||||
# # ipStrategy:
|
||||
# # depth: 1
|
||||
|
||||
## ----------------------------------------
|
||||
## Strip Prefix para WebSockets
|
||||
## ----------------------------------------
|
||||
## Traefik recibe /ws/terminal pero el backend espera /
|
||||
strip-ws-terminal:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/ws/terminal"
|
||||
|
||||
strip-ws-status:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/ws/status"
|
||||
|
||||
strip-ws-mcp:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/ws/mcp"
|
||||
|
||||
strip-ws-whisper:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/ws/whisper"
|
||||
|
||||
## ----------------------------------------
|
||||
## Headers de seguridad
|
||||
## ----------------------------------------
|
||||
agentui-headers:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Proto: "https"
|
||||
# Headers de seguridad opcionales:
|
||||
# stsSeconds: 31536000
|
||||
# stsIncludeSubdomains: true
|
||||
# contentTypeNosniff: true
|
||||
# browserXssFilter: true
|
||||
|
||||
## ============================================
|
||||
## ROUTERS
|
||||
## ============================================
|
||||
routers:
|
||||
## ----------------------------------------
|
||||
## Frontend (/) - Vue App
|
||||
## ----------------------------------------
|
||||
## Sirve la aplicación Vue, assets, PWA manifest, etc.
|
||||
## Excluye /api y /ws para que los otros routers los manejen.
|
||||
##
|
||||
agentui-frontend:
|
||||
rule: "Host(`z590.nucleoriofrio.com`) && !PathPrefix(`/api`) && !PathPrefix(`/ws`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: agentui-frontend
|
||||
middlewares:
|
||||
- agentui-headers
|
||||
# - agentui-ipwhitelist # Descomentar para activar
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
priority: 1
|
||||
|
||||
## ----------------------------------------
|
||||
## API Backend (/api/*) - REST API
|
||||
## ----------------------------------------
|
||||
## Maneja todas las llamadas REST:
|
||||
## - /api/health - Health check
|
||||
## - /api/themes/* - Sistema de temas
|
||||
## - /api/components/* - Componentes Vue guardados
|
||||
## - /api/database/* - Explorador de base de datos
|
||||
## - /api/claude-status - Status de Claude (desde hooks)
|
||||
## - /api/whisper/* - Control de Whisper
|
||||
## - etc.
|
||||
##
|
||||
agentui-api:
|
||||
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/api`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: agentui-api
|
||||
middlewares:
|
||||
- agentui-headers
|
||||
# - agentui-ipwhitelist
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
priority: 10
|
||||
|
||||
## ----------------------------------------
|
||||
## Terminal WebSocket (/ws/terminal)
|
||||
## ----------------------------------------
|
||||
## Conexión WebSocket para xterm.js
|
||||
## Permite ejecutar comandos en el servidor.
|
||||
##
|
||||
agentui-ws-terminal:
|
||||
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/terminal`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: agentui-terminal
|
||||
middlewares:
|
||||
- strip-ws-terminal
|
||||
# - agentui-ipwhitelist
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
priority: 20
|
||||
|
||||
## ----------------------------------------
|
||||
## Claude Status WebSocket (/ws/status)
|
||||
## ----------------------------------------
|
||||
## Recibe actualizaciones de estado de Claude/Nucleo
|
||||
## para las animaciones del FAB.
|
||||
## Usa el mismo backend que terminal (4103).
|
||||
##
|
||||
agentui-ws-status:
|
||||
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/status`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: agentui-terminal
|
||||
middlewares:
|
||||
- strip-ws-status
|
||||
# - agentui-ipwhitelist
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
priority: 20
|
||||
|
||||
## ----------------------------------------
|
||||
## WebMCP WebSocket (/ws/mcp)
|
||||
## ----------------------------------------
|
||||
## Bridge entre Claude Code MCP y el browser.
|
||||
## Permite que Claude ejecute herramientas en la UI:
|
||||
## - render_vue_component
|
||||
## - navigate_to
|
||||
## - set_theme_variable
|
||||
## - etc.
|
||||
##
|
||||
agentui-ws-mcp:
|
||||
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/mcp`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: agentui-mcp
|
||||
middlewares:
|
||||
- strip-ws-mcp
|
||||
# - agentui-ipwhitelist
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
priority: 20
|
||||
|
||||
## ----------------------------------------
|
||||
## Whisper WebSocket (/ws/whisper)
|
||||
## ----------------------------------------
|
||||
## Transcripción de voz con Whisper.
|
||||
## Opcional - solo si usas la función de voz.
|
||||
##
|
||||
agentui-ws-whisper:
|
||||
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/whisper`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: agentui-whisper
|
||||
middlewares:
|
||||
- strip-ws-whisper
|
||||
# - agentui-ipwhitelist
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
priority: 20
|
||||
|
||||
## ============================================
|
||||
## SERVICES (Backends)
|
||||
## ============================================
|
||||
## Cambia 192.168.87.135 por la IP de tu servidor
|
||||
## si es diferente.
|
||||
##
|
||||
services:
|
||||
## Frontend - Vite dev server o build estático
|
||||
agentui-frontend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.87.135:4100"
|
||||
|
||||
## API Server - Bun HTTP
|
||||
agentui-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.87.135:4101"
|
||||
|
||||
## Terminal WebSocket Server
|
||||
## También maneja Claude status broadcast
|
||||
agentui-terminal:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.87.135:4103"
|
||||
|
||||
## WebMCP WebSocket Server
|
||||
agentui-mcp:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.87.135:4102"
|
||||
|
||||
## Whisper WebSocket Server (opcional)
|
||||
agentui-whisper:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.87.135:4104"
|
||||
Reference in New Issue
Block a user