feat(liveatc): redesign UI with classroom styling, add pilot suggestions & telemetry debugger

- Align liveatc page with classroom design system (learn-theme, btn/panel/glass classes)
- Add pilot suggestion buttons showing all phase interactions (disabled when unavailable)
- Short click sends directly, long press opens editable text field
- Add ATC-initiated interaction buttons (tower: lineup, cleared takeoff)
- Add telemetry debugger panel with 5 presets + manual sliders + engine state view
- Fix when-condition bug in ground phase (remove 'vars.' prefix)
- Fix off_schema crash: graceful "say again" fallback instead of throwing
- Expose getInteractionSuggestions() and processAtcInitiated() from engine
- Extend demo flight with push_direction, wind, arrival vars for full flow testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-15 21:10:42 +01:00
parent 81e8ce9fdf
commit 785d9e26b2
3 changed files with 544 additions and 257 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { reactive, computed } from 'vue'
import type { EngineState, FlightPlan, FlightVars, TelemetryState, Transmission,
TransmissionDebug, RouteRequest, RouteCandidate, RouteResponse, Phase } from './types'
TransmissionDebug, RouteRequest, RouteCandidate, RouteResponse, Phase, Interaction } from './types'
import { getPhase } from './phases'
import { renderTemplate } from './templateRenderer'
import { evaluateTelemetry } from './telemetryWatcher'
@@ -47,6 +47,16 @@ export interface AtcEngineOptions {
apiFetch?: typeof $fetch
}
/** Info about an interaction for the UI — includes availability status */
export interface InteractionSuggestion {
id: string
type: Interaction['type']
intent: string
example: string | undefined
available: boolean
whenKey?: string
}
export function useAtcEngine(options: AtcEngineOptions = {}) {
const fetchFn = options.apiFetch ?? $fetch
const state = reactive<EngineState>(makeDefaultState())
@@ -92,14 +102,16 @@ export function useAtcEngine(options: AtcEngineOptions = {}) {
}
}
function isWhenSatisfied(when: string | undefined): boolean {
if (!when) return true
return !!state.vars[when]
}
function getCandidates(): RouteCandidate[] {
const phase = currentPhase.value
if (!phase) return []
return phase.interactions
.filter((i) => {
if (!i.when) return true
return !!state.vars[i.when]
})
.filter((i) => isWhenSatisfied(i.when))
.map((i) => ({
id: i.id,
intent: i.pilotIntent,
@@ -107,6 +119,20 @@ export function useAtcEngine(options: AtcEngineOptions = {}) {
}))
}
/** Get ALL interactions of current phase with availability info for UI buttons */
function getInteractionSuggestions(): InteractionSuggestion[] {
const phase = currentPhase.value
if (!phase) return []
return phase.interactions.map((i) => ({
id: i.id,
type: i.type,
intent: i.pilotIntent,
example: i.pilotExample ? renderTemplate(i.pilotExample, state.vars) : undefined,
available: isWhenSatisfied(i.when),
whenKey: i.when,
}))
}
function checkReadback(
text: string,
required: string[],
@@ -150,6 +176,45 @@ export function useAtcEngine(options: AtcEngineOptions = {}) {
return null
}
/**
* Process an atc_initiates interaction directly (no LLM routing needed).
* Returns the ATC response text.
*/
function processAtcInitiated(interactionId: string): string | null {
const phase = currentPhase.value
if (!phase) return null
const interaction = phase.interactions.find(i => i.id === interactionId)
if (!interaction) return null
let variablesUpdated: Record<string, any> = {}
if (interaction.updates) {
variablesUpdated = applyUpdates(interaction.updates)
}
const atcText = renderTemplate(interaction.atcResponse, state.vars)
state.currentInteraction = interaction.id
if (interaction.readback) {
state.waitingFor = 'readback'
} else {
state.waitingFor = 'pilot'
}
logTransmission('atc', atcText, {
engineAction: {
templateUsed: interaction.atcResponse,
variablesUpdated,
},
})
if (interaction.handoff) {
const handoffMsg = handleHandoff(interaction.handoff, state.vars)
if (handoffMsg) return `${atcText}\n${handoffMsg}`
}
return atcText
}
// --- Public API ---
function initFlight(plan: FlightPlan): void {
@@ -193,8 +258,37 @@ export function useAtcEngine(options: AtcEngineOptions = {}) {
const phase = currentPhase.value
if (!phase) throw new Error(`Phase not found: ${state.currentPhase}`)
// Handle off_schema gracefully
if (res.chosen === 'off_schema') {
const sayAgain = `${state.vars.callsign}, say again.`
logTransmission('atc', sayAgain, {
llmResponse: {
chosenInteraction: 'off_schema',
confidence: res.confidence,
reason: res.reason,
tokensUsed: res.tokensUsed,
durationMs: res.durationMs,
model: res.model,
},
})
return sayAgain
}
const interaction = phase.interactions.find(i => i.id === res.chosen)
if (!interaction) throw new Error(`Interaction not found: ${res.chosen}`)
if (!interaction) {
const sayAgain = `${state.vars.callsign}, say again.`
logTransmission('atc', sayAgain, {
llmResponse: {
chosenInteraction: res.chosen,
confidence: res.confidence,
reason: `Interaction not found: ${res.chosen}`,
tokensUsed: res.tokensUsed,
durationMs: res.durationMs,
model: res.model,
},
})
return sayAgain
}
// 4b. Fetch taxi route if needed (before rendering template)
if (
@@ -307,6 +401,8 @@ export function useAtcEngine(options: AtcEngineOptions = {}) {
currentPhase,
initFlight,
handlePilotInput,
processAtcInitiated,
getInteractionSuggestions,
updateTelemetry,
declareEmergency,
reset,

View File

@@ -23,7 +23,7 @@ export const groundPhase: Phase = {
{
id: 'request_taxi',
type: 'pilot_initiates',
when: 'vars.pushback_approved',
when: 'pushback_approved',
pilotIntent: 'Pilot requests taxi clearance to the runway',
pilotExample: '{callsign}, request taxi',
atcResponse: '{callsign}, taxi to holding point {runway} via {taxi_route}.',
@@ -37,7 +37,7 @@ export const groundPhase: Phase = {
{
id: 'report_holding_short',
type: 'pilot_initiates',
when: 'vars.taxi_clearance_received',
when: 'taxi_clearance_received',
pilotIntent: 'Pilot reports holding short of the runway',
pilotExample: '{callsign}, holding short runway {runway}',
atcResponse: '{callsign}, contact tower on {tower_freq}.',