feat(vela-ui): add voice session shell
Add a minimal UI shell that connects to the gateway WebSocket and exposes developer-visible session state. Align the architecture, protocol, setup, integration, and backlog docs with the current UI increment.
This commit is contained in:
@@ -5,5 +5,6 @@ This workspace contains the Vela browser UI as a minimal SvelteKit app.
|
||||
Current status:
|
||||
|
||||
- SvelteKit app boots in the Yarn workspace
|
||||
- root page shows the initial Vela UI starter screen
|
||||
- PWA features and voice interaction flows remain future increments
|
||||
- root page shows a minimal voice-session shell with connect/disconnect controls
|
||||
- the shell can connect to the gateway `/ws` endpoint and display developer-visible session status
|
||||
- microphone capture, transcript rendering, and audio playback remain future increments
|
||||
|
||||
@@ -2,43 +2,262 @@
|
||||
<title>Vela UI</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Minimal starter UI for the Vela voice assistant."
|
||||
content="Minimal voice-session shell for the Vela voice assistant."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
CLIENT_EVENT_TYPES,
|
||||
PROTOCOL_PACKAGE_NAME,
|
||||
SERVER_EVENT_TYPES
|
||||
SERVER_EVENT_TYPES,
|
||||
SESSION_STATES,
|
||||
isMessageEnvelope,
|
||||
isServerEventType
|
||||
} from '@vela/protocol';
|
||||
|
||||
const appStatus = 'Bootstrapped';
|
||||
const nextFocus = `Build the voice session shell on top of ${PROTOCOL_PACKAGE_NAME}.`;
|
||||
const DEFAULT_GATEWAY_PORT = '3001';
|
||||
const FALLBACK_GATEWAY_URL = `ws://localhost:${DEFAULT_GATEWAY_PORT}/ws`;
|
||||
const configuredGatewayUrl = import.meta.env.VITE_VELA_GATEWAY_WS_URL;
|
||||
|
||||
function resolveGatewayWebSocketUrl() {
|
||||
if (configuredGatewayUrl) {
|
||||
return configuredGatewayUrl;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return FALLBACK_GATEWAY_URL;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const isLocalhost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||||
|
||||
if (isLocalhost && window.location.port !== DEFAULT_GATEWAY_PORT) {
|
||||
return `${protocol}//${window.location.hostname}:${DEFAULT_GATEWAY_PORT}/ws`;
|
||||
}
|
||||
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function formatCloseReason(event) {
|
||||
const reason = event.reason ? ` (${event.reason})` : '';
|
||||
return `code ${event.code}, clean ${event.wasClean ? 'yes' : 'no'}${reason}`;
|
||||
}
|
||||
|
||||
let gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
|
||||
let connectionState = 'not connected';
|
||||
let connectionDetail = 'Socket is idle.';
|
||||
let gatewaySessionState = 'not received';
|
||||
let sessionId = 'not assigned';
|
||||
let lastServerEvent = 'none';
|
||||
let lastError = 'none';
|
||||
let lastClose = 'not closed';
|
||||
let socket = null;
|
||||
let connectionAttempts = 0;
|
||||
|
||||
function clearSocketHandlers(targetSocket) {
|
||||
targetSocket.onopen = null;
|
||||
targetSocket.onmessage = null;
|
||||
targetSocket.onerror = null;
|
||||
targetSocket.onclose = null;
|
||||
}
|
||||
|
||||
function resetSessionStatus() {
|
||||
gatewaySessionState = 'not received';
|
||||
sessionId = 'not assigned';
|
||||
lastServerEvent = 'none';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket && (connectionState === 'connecting' || connectionState === 'connected')) {
|
||||
return;
|
||||
}
|
||||
|
||||
gatewayWebSocketUrl = resolveGatewayWebSocketUrl();
|
||||
resetSessionStatus();
|
||||
lastError = 'none';
|
||||
lastClose = 'not closed';
|
||||
connectionState = 'connecting';
|
||||
connectionDetail = 'Opening WebSocket connection to gateway.';
|
||||
connectionAttempts += 1;
|
||||
|
||||
const nextSocket = new WebSocket(gatewayWebSocketUrl);
|
||||
socket = nextSocket;
|
||||
|
||||
nextSocket.onopen = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState = 'connected';
|
||||
connectionDetail = 'Gateway WebSocket is open.';
|
||||
};
|
||||
|
||||
nextSocket.onmessage = ({ data }) => {
|
||||
if (socket !== nextSocket || typeof data !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Received non-JSON message from gateway.';
|
||||
lastError = 'invalid server message: JSON parse failed';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMessageEnvelope(message) || !isServerEventType(message.type)) {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Received unsupported message from gateway.';
|
||||
lastError = 'invalid server message: envelope or event type mismatch';
|
||||
return;
|
||||
}
|
||||
|
||||
lastServerEvent = message.type;
|
||||
|
||||
if (message.type === 'session.ready') {
|
||||
sessionId = message.payload.sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'session.state') {
|
||||
gatewaySessionState = message.payload.value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Gateway reported a protocol error.';
|
||||
lastError = `${message.payload.code}: ${message.payload.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
nextSocket.onerror = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectionState = 'error';
|
||||
connectionDetail = 'Browser reported a WebSocket error.';
|
||||
lastError = 'browser websocket error';
|
||||
};
|
||||
|
||||
nextSocket.onclose = (event) => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastClose = formatCloseReason(event);
|
||||
connectionState = connectionState === 'error' ? 'error' : 'disconnected';
|
||||
connectionDetail = connectionState === 'error'
|
||||
? 'Socket closed after an error.'
|
||||
: 'Gateway WebSocket is closed.';
|
||||
clearSocketHandlers(nextSocket);
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (!socket) {
|
||||
connectionState = 'disconnected';
|
||||
connectionDetail = 'No active socket to close.';
|
||||
return;
|
||||
}
|
||||
|
||||
connectionDetail = 'Closing WebSocket connection.';
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close(1000, 'client disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSocket = socket;
|
||||
clearSocketHandlers(activeSocket);
|
||||
socket = null;
|
||||
|
||||
if (activeSocket.readyState === WebSocket.OPEN || activeSocket.readyState === WebSocket.CONNECTING) {
|
||||
activeSocket.close(1000, 'page dispose');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<section class="card">
|
||||
<p class="eyebrow">Vela UI</p>
|
||||
<h1>Minimal SvelteKit starter</h1>
|
||||
<h1>Voice session shell</h1>
|
||||
<p>
|
||||
This workspace now runs as the browser shell for Vela. The voice controls, transcript, and
|
||||
streaming session UI will be added in later increments.
|
||||
This minimal browser shell can connect to the gateway WebSocket and expose developer-visible
|
||||
session status. Microphone capture, transcript rendering, and audio playback remain future
|
||||
increments.
|
||||
</p>
|
||||
|
||||
<p class="contract-note">
|
||||
Shared protocol package loaded with {CLIENT_EVENT_TYPES.length} client event types and
|
||||
{SERVER_EVENT_TYPES.length} server event types.
|
||||
{SERVER_EVENT_TYPES.length} server event types across {SESSION_STATES.length} gateway session
|
||||
states.
|
||||
</p>
|
||||
|
||||
<div class="controls">
|
||||
<button on:click={connect} disabled={connectionState === 'connecting' || connectionState === 'connected'}>
|
||||
Connect
|
||||
</button>
|
||||
<button on:click={disconnect} disabled={!socket && connectionState !== 'connected' && connectionState !== 'connecting'}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>{appStatus}</strong>
|
||||
<span>UI connection state</span>
|
||||
<strong>{connectionState}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Next</span>
|
||||
<strong>{nextFocus}</strong>
|
||||
<span>Connection detail</span>
|
||||
<strong>{connectionDetail}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gateway WebSocket URL</span>
|
||||
<strong>{gatewayWebSocketUrl}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Session ID</span>
|
||||
<strong>{sessionId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gateway session state</span>
|
||||
<strong>{gatewaySessionState}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last server event</span>
|
||||
<strong>{lastServerEvent}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last error</span>
|
||||
<strong>{lastError}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Last close</span>
|
||||
<strong>{lastClose}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Connection attempts</span>
|
||||
<strong>{connectionAttempts}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Protocol package</span>
|
||||
<strong>{PROTOCOL_PACKAGE_NAME}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -91,9 +310,32 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.8rem 1.1rem;
|
||||
border: 1px solid #2b4a6b;
|
||||
border-radius: 0.75rem;
|
||||
background: #12233a;
|
||||
color: #e6eef8;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user