feat: Add torch system for multi-browser MCP control
- Add TorchButton component to header (replaces dropdowns) - Add torch store for managing torch state - Add torch service for requesting/releasing torch - Add torch event handlers in WebMCP service - Remove ComponentsDropdown and ToolsDropdown from header The torch system allows controlling which browser receives MCP tool calls when multiple browsers are connected. Requires WebMCP library update to fully function.
This commit is contained in:
@@ -3,9 +3,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import StatusBar from './components/StatusBar.vue'
|
import StatusBar from './components/StatusBar.vue'
|
||||||
import Toolbar from './components/Toolbar.vue'
|
import Toolbar from './components/Toolbar.vue'
|
||||||
import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
import TorchButton from './components/TorchButton.vue'
|
||||||
import ToolsDropdown from './components/ToolsDropdown.vue'
|
|
||||||
// ConnectionDropdown removed - replaced with debug console
|
|
||||||
import FloatingTerminal from './components/FloatingTerminal.vue'
|
import FloatingTerminal from './components/FloatingTerminal.vue'
|
||||||
import FloatingResponse from './components/FloatingResponse.vue'
|
import FloatingResponse from './components/FloatingResponse.vue'
|
||||||
import FloatingVoice from './components/FloatingVoice.vue'
|
import FloatingVoice from './components/FloatingVoice.vue'
|
||||||
@@ -366,14 +364,13 @@ watch(() => route.name, (newPage) => {
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="logo">Agent UI</h1>
|
<h1 class="logo">Agent UI</h1>
|
||||||
|
<TorchButton />
|
||||||
<button class="debug-btn" :class="{ active: showDebugConsole }" @click="showDebugConsole = !showDebugConsole" title="Debug Console">
|
<button class="debug-btn" :class="{ active: showDebugConsole }" @click="showDebugConsole = !showDebugConsole" title="Debug Console">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span v-if="debugLogs.length" class="log-count">{{ debugLogs.length }}</span>
|
<span v-if="debugLogs.length" class="log-count">{{ debugLogs.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<ComponentsDropdown />
|
|
||||||
<ToolsDropdown />
|
|
||||||
<PwaInstallBanner />
|
<PwaInstallBanner />
|
||||||
</div>
|
</div>
|
||||||
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
|
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
|
||||||
|
|||||||
117
frontend/src/components/TorchButton.vue
Normal file
117
frontend/src/components/TorchButton.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTorchStore } from '../stores/torch'
|
||||||
|
import { requestTorch, releaseTorch } from '../services/torch'
|
||||||
|
|
||||||
|
const torchStore = useTorchStore()
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
if (torchStore.hasTorch) {
|
||||||
|
await releaseTorch()
|
||||||
|
} else {
|
||||||
|
await requestTorch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="torch-btn"
|
||||||
|
:class="{
|
||||||
|
'has-torch': torchStore.hasTorch,
|
||||||
|
'torch-held': torchStore.torchHolderId && !torchStore.hasTorch,
|
||||||
|
'requesting': torchStore.isRequesting
|
||||||
|
}"
|
||||||
|
@click="handleClick"
|
||||||
|
:title="torchStore.hasTorch ? 'You have the torch - click to release' : torchStore.torchHolderId ? 'Torch held by another client - click to request' : 'Click to request the torch'"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Torch/flame icon -->
|
||||||
|
<path d="M12 2c1 3 2.5 3.5 3.5 4.5A5 5 0 0 1 17 10a5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 1.5-3.5C9.5 5.5 11 5 12 2z"/>
|
||||||
|
<path d="M12 15v7"/>
|
||||||
|
<path d="M10 22h4"/>
|
||||||
|
</svg>
|
||||||
|
<span v-if="torchStore.isRequesting" class="requesting-indicator"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.torch-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torch-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Has the torch - golden glow */
|
||||||
|
.torch-btn.has-torch {
|
||||||
|
background: linear-gradient(135deg, #ff9500 0%, #ff6b00 100%);
|
||||||
|
border-color: #ff9500;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 12px rgba(255, 149, 0, 0.5);
|
||||||
|
animation: torch-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torch-btn.has-torch:hover {
|
||||||
|
background: linear-gradient(135deg, #ffaa33 0%, #ff8533 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Torch held by another */
|
||||||
|
.torch-btn.torch-held {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: #ff9500;
|
||||||
|
color: #ff9500;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torch-btn.torch-held:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Requesting state */
|
||||||
|
.torch-btn.requesting {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requesting-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes torch-glow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 12px rgba(255, 149, 0, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(255, 149, 0, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend/src/services/torch.ts
Normal file
103
frontend/src/services/torch.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useTorchStore } from '../stores/torch'
|
||||||
|
import { getWebMCP } from './webmcp'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the torch from the server
|
||||||
|
*/
|
||||||
|
export async function requestTorch(): Promise<boolean> {
|
||||||
|
const torchStore = useTorchStore()
|
||||||
|
const webmcp = getWebMCP()
|
||||||
|
|
||||||
|
if (!webmcp || !webmcp.isConnected) {
|
||||||
|
console.error('[Torch] WebMCP not connected')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
torchStore.setRequesting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send request to server via WebSocket
|
||||||
|
webmcp.socket?.send(JSON.stringify({
|
||||||
|
type: 'requestTorch'
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Torch] Error requesting torch:', e)
|
||||||
|
torchStore.setRequesting(false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the torch
|
||||||
|
*/
|
||||||
|
export async function releaseTorch(): Promise<boolean> {
|
||||||
|
const torchStore = useTorchStore()
|
||||||
|
const webmcp = getWebMCP()
|
||||||
|
|
||||||
|
if (!webmcp || !webmcp.isConnected) {
|
||||||
|
console.error('[Torch] WebMCP not connected')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!torchStore.hasTorch) {
|
||||||
|
console.warn('[Torch] Cannot release - do not have torch')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
webmcp.socket?.send(JSON.stringify({
|
||||||
|
type: 'releaseTorch'
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Torch] Error releasing torch:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize torch event handlers
|
||||||
|
* Call this after WebMCP is connected
|
||||||
|
*/
|
||||||
|
export function initTorchHandlers() {
|
||||||
|
const torchStore = useTorchStore()
|
||||||
|
const webmcp = getWebMCP()
|
||||||
|
|
||||||
|
if (!webmcp) {
|
||||||
|
console.error('[Torch] WebMCP not initialized')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for torch state updates
|
||||||
|
webmcp.on('torchUpdate', (data: { holderId: string | null; clients: any[] }) => {
|
||||||
|
console.log('[Torch] State updated:', data)
|
||||||
|
torchStore.setClients(data.clients)
|
||||||
|
torchStore.setRequesting(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for client ID assignment
|
||||||
|
webmcp.on('clientId', (data: { id: string }) => {
|
||||||
|
console.log('[Torch] Client ID assigned:', data.id)
|
||||||
|
torchStore.setClientId(data.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for torch granted
|
||||||
|
webmcp.on('torchGranted', () => {
|
||||||
|
console.log('[Torch] Torch granted!')
|
||||||
|
torchStore.setRequesting(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for torch denied
|
||||||
|
webmcp.on('torchDenied', (data: { reason: string }) => {
|
||||||
|
console.warn('[Torch] Torch denied:', data.reason)
|
||||||
|
torchStore.setRequesting(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for torch released
|
||||||
|
webmcp.on('torchReleased', () => {
|
||||||
|
console.log('[Torch] Torch released')
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[Torch] Handlers initialized')
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCanvasStore } from '../stores/canvas'
|
import { useCanvasStore } from '../stores/canvas'
|
||||||
|
import { useTorchStore } from '../stores/torch'
|
||||||
import { endpoints, isSecure, wsProtocol, hostname } from '../config/endpoints'
|
import { endpoints, isSecure, wsProtocol, hostname } from '../config/endpoints'
|
||||||
|
|
||||||
// WebMCP HTTP API base for direct token requests
|
// WebMCP HTTP API base for direct token requests
|
||||||
@@ -117,6 +118,43 @@ function setupEventHandlers() {
|
|||||||
updateConnectionInfo()
|
updateConnectionInfo()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Torch events
|
||||||
|
const torchStore = useTorchStore()
|
||||||
|
|
||||||
|
eventUnsubscribers.push(
|
||||||
|
webmcpInstance.on('clientId', (data: { id: string }) => {
|
||||||
|
console.log('[WebMCP] Client ID assigned:', data.id)
|
||||||
|
torchStore.setClientId(data.id)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
eventUnsubscribers.push(
|
||||||
|
webmcpInstance.on('torchUpdate', (data: { holderId: string | null; clients: any[] }) => {
|
||||||
|
console.log('[WebMCP] Torch state updated:', data.holderId)
|
||||||
|
torchStore.setClients(data.clients)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
eventUnsubscribers.push(
|
||||||
|
webmcpInstance.on('torchGranted', () => {
|
||||||
|
console.log('[WebMCP] Torch granted!')
|
||||||
|
torchStore.setRequesting(false)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
eventUnsubscribers.push(
|
||||||
|
webmcpInstance.on('torchDenied', (data: { reason: string }) => {
|
||||||
|
console.warn('[WebMCP] Torch denied:', data.reason)
|
||||||
|
torchStore.setRequesting(false)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
eventUnsubscribers.push(
|
||||||
|
webmcpInstance.on('torchReleased', () => {
|
||||||
|
console.log('[WebMCP] Torch released')
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateConnectionInfo() {
|
function updateConnectionInfo() {
|
||||||
|
|||||||
72
frontend/src/stores/torch.ts
Normal file
72
frontend/src/stores/torch.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export interface TorchClient {
|
||||||
|
id: string
|
||||||
|
userAgent: string
|
||||||
|
hostname: string
|
||||||
|
url: string
|
||||||
|
connectedAt: string
|
||||||
|
hasTorch: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTorchStore = defineStore('torch', () => {
|
||||||
|
// State
|
||||||
|
const clientId = ref<string | null>(null)
|
||||||
|
const hasTorch = ref(false)
|
||||||
|
const torchHolderId = ref<string | null>(null)
|
||||||
|
const clients = ref<TorchClient[]>([])
|
||||||
|
const isRequesting = ref(false)
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const isConnected = computed(() => clientId.value !== null)
|
||||||
|
const torchHolder = computed(() => clients.value.find(c => c.hasTorch))
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
function setClientId(id: string) {
|
||||||
|
clientId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTorchState(holder: string | null) {
|
||||||
|
torchHolderId.value = holder
|
||||||
|
hasTorch.value = holder === clientId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setClients(newClients: TorchClient[]) {
|
||||||
|
clients.value = newClients
|
||||||
|
// Update torch state based on clients
|
||||||
|
const holder = newClients.find(c => c.hasTorch)
|
||||||
|
torchHolderId.value = holder?.id || null
|
||||||
|
hasTorch.value = holder?.id === clientId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRequesting(value: boolean) {
|
||||||
|
isRequesting.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
clientId.value = null
|
||||||
|
hasTorch.value = false
|
||||||
|
torchHolderId.value = null
|
||||||
|
clients.value = []
|
||||||
|
isRequesting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
clientId,
|
||||||
|
hasTorch,
|
||||||
|
torchHolderId,
|
||||||
|
clients,
|
||||||
|
isRequesting,
|
||||||
|
// Computed
|
||||||
|
isConnected,
|
||||||
|
torchHolder,
|
||||||
|
// Actions
|
||||||
|
setClientId,
|
||||||
|
setTorchState,
|
||||||
|
setClients,
|
||||||
|
setRequesting,
|
||||||
|
reset
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user