mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-10 19:36:04 +08:00
Enable flow-aware decision routing
This commit is contained in:
@@ -2,17 +2,33 @@
|
||||
<div class="min-h-screen bg-[#050910] text-white">
|
||||
<div class="mx-auto w-full max-w-[420px] px-4 pb-24 pt-6 sm:px-6">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center justify-between pb-6">
|
||||
<header class="flex flex-col gap-4 pb-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.35em] text-cyan-400/80">OpenSquawk</p>
|
||||
<h1 class="text-2xl font-semibold">Pilot Monitoring</h1>
|
||||
<p class="mt-1 text-sm text-white/70">Alpha Build • Decision Tree • VATSIM</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat" class="mb-1">
|
||||
{{ currentState?.id || 'INIT' }}
|
||||
</v-chip>
|
||||
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:items-end">
|
||||
<div class="text-right">
|
||||
<v-chip size="small" :color="currentState?.phase === 'Interrupt' ? 'red' : 'cyan'" variant="flat" class="mb-1">
|
||||
{{ currentState?.id || 'INIT' }}
|
||||
</v-chip>
|
||||
<div class="text-xs text-white/50">{{ currentState?.phase || 'Setup' }}</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="selectedFlowSlug"
|
||||
:items="flowOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="Flow"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="cyan"
|
||||
class="min-w-[200px]"
|
||||
:disabled="flowOptions.length <= 1"
|
||||
prepend-inner-icon="mdi-sitemap"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -696,6 +712,14 @@
|
||||
</div>
|
||||
<p class="text-sm text-white font-mono">{{ entry.message }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<v-chip
|
||||
v-if="entry.flow"
|
||||
size="x-small"
|
||||
color="purple"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ entry.flow }}
|
||||
</v-chip>
|
||||
<v-chip size="x-small" color="cyan" variant="outlined">{{ entry.frequency || 'N/A' }}</v-chip>
|
||||
<span class="text-xs text-white/40">{{ entry.state }}</span>
|
||||
</div>
|
||||
@@ -953,9 +977,12 @@ const {
|
||||
flags,
|
||||
flightContext,
|
||||
currentStep,
|
||||
availableFlows,
|
||||
activeFlow,
|
||||
initializeFlight,
|
||||
updateFrequencyVariables,
|
||||
fetchRuntimeTree,
|
||||
setActiveFlow,
|
||||
isReady: engineReady,
|
||||
processPilotTransmission,
|
||||
buildLLMContext,
|
||||
@@ -1023,6 +1050,41 @@ const clearLog = () => {
|
||||
clearLastTransmission()
|
||||
}
|
||||
|
||||
const selectedFlowSlug = ref('')
|
||||
const flowOptions = computed(() =>
|
||||
availableFlows.value.map((flow) => ({
|
||||
title: flow.name,
|
||||
value: flow.slug,
|
||||
subtitle: flow.description,
|
||||
}))
|
||||
)
|
||||
|
||||
watch(
|
||||
activeFlow,
|
||||
(slug) => {
|
||||
selectedFlowSlug.value = slug || ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(selectedFlowSlug, (slug, previous) => {
|
||||
if (!slug || slug === activeFlow.value || slug === previous) {
|
||||
return
|
||||
}
|
||||
handleFlowChange(slug)
|
||||
})
|
||||
|
||||
function handleFlowChange(slug: string) {
|
||||
if (!slug || slug === activeFlow.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setActiveFlow(slug)
|
||||
} catch (error) {
|
||||
console.error('Failed to activate flow', error)
|
||||
}
|
||||
}
|
||||
|
||||
// UI State
|
||||
const currentScreen = ref<'login' | 'flightselect' | 'monitor'>('login')
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -17,9 +17,10 @@ interface PTTRequest {
|
||||
context: {
|
||||
state_id: string;
|
||||
state: any;
|
||||
candidates: Array<{ id: string; state: any }>;
|
||||
candidates: Array<{ id: string; state: any; flow?: string }>;
|
||||
variables: Record<string, any>;
|
||||
flags: Record<string, any>;
|
||||
flow_slug?: string;
|
||||
};
|
||||
moduleId: string;
|
||||
lessonId: string;
|
||||
@@ -35,6 +36,8 @@ interface PTTResponse {
|
||||
controller_say_tpl?: string;
|
||||
off_schema?: boolean;
|
||||
radio_check?: boolean;
|
||||
activate_flow?: string;
|
||||
resume_previous?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (url.pathname.startsWith('/api/service/')) {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/decision-flows/runtime') {
|
||||
return
|
||||
}
|
||||
if (event.node.req.method === 'OPTIONS') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import OpenAI from 'openai'
|
||||
import {spellIcaoDigits, toIcaoPhonetic} from '../../shared/utils/radioSpeech'
|
||||
import type {LLMDecision, LLMDecisionInput} from '../../shared/types/llm'
|
||||
import type { DecisionNodeCondition, DecisionNodeTrigger, RuntimeDecisionState, RuntimeDecisionSystem } from '../../shared/types/decision'
|
||||
import { buildRuntimeDecisionSystem } from '../services/decisionFlowService'
|
||||
import {getServerRuntimeConfig} from './runtimeConfig'
|
||||
|
||||
let openaiClient: OpenAI | null = null
|
||||
@@ -200,6 +202,319 @@ function fallbackNextState(input: LLMDecisionInput): string {
|
||||
return input.candidates[0]?.id || input.state_id || 'GEN_NO_REPLY'
|
||||
}
|
||||
|
||||
interface IndexedStateEntry {
|
||||
flow: string
|
||||
state: RuntimeDecisionState
|
||||
}
|
||||
|
||||
interface DecisionCandidate {
|
||||
id: string
|
||||
flow: string
|
||||
state: RuntimeDecisionState
|
||||
}
|
||||
|
||||
interface PreparedCandidateResult {
|
||||
filteredCandidates: DecisionCandidate[]
|
||||
candidateFlowMap: Map<string, string>
|
||||
activeFlowSlug: string
|
||||
}
|
||||
|
||||
const RUNTIME_CACHE_TTL_MS = 5_000
|
||||
let runtimeSystemCache: { system: RuntimeDecisionSystem; index: Map<string, IndexedStateEntry>; timestamp: number } | null = null
|
||||
|
||||
function buildRuntimeIndex(system: RuntimeDecisionSystem): Map<string, IndexedStateEntry> {
|
||||
const index = new Map<string, IndexedStateEntry>()
|
||||
for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
|
||||
const states = tree?.states || {}
|
||||
for (const [stateId, state] of Object.entries(states)) {
|
||||
index.set(stateId, { flow: flowSlug, state })
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
async function getRuntimeSystemIndex(): Promise<{ system: RuntimeDecisionSystem; index: Map<string, IndexedStateEntry> }> {
|
||||
const now = Date.now()
|
||||
if (!runtimeSystemCache || now - runtimeSystemCache.timestamp > RUNTIME_CACHE_TTL_MS) {
|
||||
const system = await buildRuntimeDecisionSystem()
|
||||
runtimeSystemCache = {
|
||||
system,
|
||||
index: buildRuntimeIndex(system),
|
||||
timestamp: now,
|
||||
}
|
||||
}
|
||||
return { system: runtimeSystemCache.system, index: runtimeSystemCache.index }
|
||||
}
|
||||
|
||||
function evaluateRegexPattern(pattern: string | undefined, flags: string | undefined, value: string): boolean {
|
||||
const source = pattern?.trim()
|
||||
if (!source) {
|
||||
return false
|
||||
}
|
||||
const normalizedFlags = flags && flags.trim().length ? flags : 'i'
|
||||
try {
|
||||
const regex = new RegExp(source, normalizedFlags)
|
||||
return regex.test(value)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeTriggers(triggers: DecisionNodeTrigger[] | undefined, utterance: string) {
|
||||
if (!Array.isArray(triggers) || triggers.length === 0) {
|
||||
return { matchesRegex: false, matchesNone: true }
|
||||
}
|
||||
|
||||
let matchesRegex = false
|
||||
let hasNone = false
|
||||
for (const trigger of triggers) {
|
||||
if (!trigger) continue
|
||||
if (trigger.type === 'regex') {
|
||||
if (evaluateRegexPattern(trigger.pattern, trigger.patternFlags, utterance)) {
|
||||
matchesRegex = true
|
||||
}
|
||||
} else if (trigger.type === 'none') {
|
||||
hasNone = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchesRegex && !hasNone) {
|
||||
hasNone = true
|
||||
}
|
||||
|
||||
return { matchesRegex, matchesNone: hasNone }
|
||||
}
|
||||
|
||||
function normalizeComparable(value: any): any {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.length) return ''
|
||||
const numeric = Number(trimmed)
|
||||
if (!Number.isNaN(numeric)) return numeric
|
||||
if (trimmed.toLowerCase() === 'true') return true
|
||||
if (trimmed.toLowerCase() === 'false') return false
|
||||
return trimmed
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseComparable(raw: any): any {
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed.length) return ''
|
||||
const numeric = Number(trimmed)
|
||||
if (!Number.isNaN(numeric)) return numeric
|
||||
if (trimmed.toLowerCase() === 'true') return true
|
||||
if (trimmed.toLowerCase() === 'false') return false
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith('\'') && trimmed.endsWith('\''))
|
||||
) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function compareValuesSafe(left: any, operator: string | undefined, right: any): boolean {
|
||||
const normalizedLeft = normalizeComparable(left)
|
||||
const normalizedRight = normalizeComparable(parseComparable(right))
|
||||
switch (operator) {
|
||||
case '>':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft > normalizedRight
|
||||
: false
|
||||
case '>=':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft >= normalizedRight
|
||||
: false
|
||||
case '<':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft < normalizedRight
|
||||
: false
|
||||
case '<=':
|
||||
return typeof normalizedLeft === 'number' && typeof normalizedRight === 'number'
|
||||
? normalizedLeft <= normalizedRight
|
||||
: false
|
||||
case '!==':
|
||||
case '!=':
|
||||
return normalizedLeft !== normalizedRight
|
||||
case '===':
|
||||
case '==':
|
||||
default:
|
||||
return normalizedLeft === normalizedRight
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContextPath(
|
||||
path: string | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> }
|
||||
) {
|
||||
if (!path || typeof path !== 'string') return undefined
|
||||
const segments = path.split('.').map(segment => segment.trim()).filter(Boolean)
|
||||
if (!segments.length) return undefined
|
||||
|
||||
let current: any
|
||||
const [first, ...rest] = segments
|
||||
if (first === 'variables' || first === 'flags') {
|
||||
current = (context as any)[first]
|
||||
} else {
|
||||
current = context.variables
|
||||
rest.unshift(first)
|
||||
}
|
||||
|
||||
for (const segment of rest) {
|
||||
if (current == null) return undefined
|
||||
current = current[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function evaluateConditionEntry(
|
||||
condition: DecisionNodeCondition | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> },
|
||||
utterance: string
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
switch (condition.type) {
|
||||
case 'regex':
|
||||
return evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
case 'regex_not':
|
||||
return !evaluateRegexPattern(condition.pattern, condition.patternFlags, utterance)
|
||||
case 'variable_value':
|
||||
default: {
|
||||
const left = resolveContextPath(condition.variable, context)
|
||||
const operator = condition.operator || '=='
|
||||
return compareValuesSafe(left, operator, condition.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateConditionList(
|
||||
conditions: DecisionNodeCondition[] | undefined,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> },
|
||||
utterance: string
|
||||
): boolean {
|
||||
if (!Array.isArray(conditions) || conditions.length === 0) {
|
||||
return true
|
||||
}
|
||||
const ordered = [...conditions].sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0))
|
||||
for (const condition of ordered) {
|
||||
if (!evaluateConditionEntry(condition, context, utterance)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function filterDecisionCandidates(
|
||||
candidates: DecisionCandidate[],
|
||||
utterance: string,
|
||||
context: { variables: Record<string, any>; flags: Record<string, any> }
|
||||
): DecisionCandidate[] {
|
||||
if (!candidates.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const regexMatches: DecisionCandidate[] = []
|
||||
const noneMatches: DecisionCandidate[] = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { matchesRegex, matchesNone } = analyzeTriggers(candidate.state?.triggers, utterance)
|
||||
if (matchesRegex) {
|
||||
regexMatches.push(candidate)
|
||||
} else if (matchesNone) {
|
||||
noneMatches.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
const pool = regexMatches.length > 0 ? regexMatches : noneMatches
|
||||
if (!pool.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pool.filter(candidate =>
|
||||
evaluateConditionList(candidate.state?.conditions, context, utterance)
|
||||
)
|
||||
}
|
||||
|
||||
async function prepareDecisionCandidates(
|
||||
input: LLMDecisionInput,
|
||||
utterance: string
|
||||
): Promise<PreparedCandidateResult> {
|
||||
const { system, index } = await getRuntimeSystemIndex()
|
||||
|
||||
let activeFlowSlug = input.flow_slug && system.flows[input.flow_slug]
|
||||
? input.flow_slug
|
||||
: undefined
|
||||
|
||||
if (!activeFlowSlug) {
|
||||
const entry = index.get(input.state_id)
|
||||
if (entry) {
|
||||
activeFlowSlug = entry.flow
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeFlowSlug) {
|
||||
activeFlowSlug = system.main || Object.keys(system.flows)[0] || ''
|
||||
}
|
||||
|
||||
const uniqueCandidates = new Map<string, DecisionCandidate>()
|
||||
|
||||
const addCandidate = (candidate: DecisionCandidate | null | undefined) => {
|
||||
if (!candidate || !candidate.id || !candidate.state) return
|
||||
if (uniqueCandidates.has(candidate.id)) return
|
||||
uniqueCandidates.set(candidate.id, candidate)
|
||||
}
|
||||
|
||||
for (const raw of input.candidates || []) {
|
||||
if (!raw?.id) continue
|
||||
const indexed = index.get(raw.id)
|
||||
const flow = raw.flow || indexed?.flow || activeFlowSlug
|
||||
const state = indexed?.state ? { ...indexed.state } : raw.state
|
||||
if (!state) continue
|
||||
addCandidate({ id: raw.id, flow: flow || activeFlowSlug, state })
|
||||
}
|
||||
|
||||
for (const raw of input.candidates || []) {
|
||||
if (!raw?.id || !raw.state) continue
|
||||
if (!uniqueCandidates.has(raw.id)) {
|
||||
addCandidate({ id: raw.id, flow: raw.flow || activeFlowSlug, state: raw.state })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
|
||||
const startStateId = tree.start_state
|
||||
if (!startStateId) continue
|
||||
const indexed = index.get(startStateId)
|
||||
if (!indexed) continue
|
||||
addCandidate({ id: startStateId, flow: flowSlug, state: { ...indexed.state } })
|
||||
}
|
||||
|
||||
const candidates = Array.from(uniqueCandidates.values())
|
||||
const context = { variables: input.variables || {}, flags: input.flags || {} }
|
||||
const filteredCandidates = filterDecisionCandidates(candidates, utterance, context)
|
||||
|
||||
const candidateFlowMap = new Map<string, string>()
|
||||
for (const candidate of filteredCandidates) {
|
||||
if (candidate.flow) {
|
||||
candidateFlowMap.set(candidate.id, candidate.flow)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filteredCandidates,
|
||||
candidateFlowMap,
|
||||
activeFlowSlug,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadbackValue(key: string, input: LLMDecisionInput): string | null {
|
||||
const rawValue = input.variables?.[key]
|
||||
if (rawValue !== undefined && rawValue !== null) {
|
||||
@@ -329,8 +644,29 @@ export async function routeDecision(input: LLMDecisionInput): Promise<LLMDecisio
|
||||
const pilotUtterance = (input.pilot_utterance || '').trim()
|
||||
const pilotText = pilotUtterance.toLowerCase()
|
||||
const trace: LLMDecisionTrace = {calls: []}
|
||||
let candidateFlowMap = new Map<string, string>()
|
||||
let activeFlowSlug = input.flow_slug || ''
|
||||
|
||||
const prepared = await prepareDecisionCandidates(input, pilotUtterance)
|
||||
candidateFlowMap = prepared.candidateFlowMap
|
||||
if (prepared.activeFlowSlug) {
|
||||
activeFlowSlug = prepared.activeFlowSlug
|
||||
input.flow_slug = prepared.activeFlowSlug
|
||||
}
|
||||
input.candidates = prepared.filteredCandidates.map(candidate => ({
|
||||
id: candidate.id,
|
||||
state: candidate.state,
|
||||
flow: candidate.flow,
|
||||
}))
|
||||
|
||||
const finalize = (decision: LLMDecision): LLMDecisionResult => {
|
||||
const targetState = decision.next_state
|
||||
if (targetState) {
|
||||
const targetFlow = candidateFlowMap.get(targetState)
|
||||
if (targetFlow && targetFlow !== activeFlowSlug) {
|
||||
decision.activate_flow = targetFlow
|
||||
}
|
||||
}
|
||||
if (!trace.calls.length && !trace.fallback) {
|
||||
return {decision}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,35 @@ export interface DecisionNodeAutoTrigger {
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
export type DecisionNodeTriggerType = 'auto_time' | 'auto_variable' | 'regex' | 'none'
|
||||
|
||||
export interface DecisionNodeTrigger {
|
||||
id: string
|
||||
type: DecisionNodeTriggerType
|
||||
order?: number
|
||||
delaySeconds?: number
|
||||
variable?: string
|
||||
operator?: DecisionComparisonOperator
|
||||
value?: number | string | boolean
|
||||
pattern?: string
|
||||
patternFlags?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type DecisionNodeConditionType = 'variable_value' | 'regex' | 'regex_not'
|
||||
|
||||
export interface DecisionNodeCondition {
|
||||
id: string
|
||||
type: DecisionNodeConditionType
|
||||
order?: number
|
||||
variable?: string
|
||||
operator?: DecisionComparisonOperator
|
||||
value?: number | string | boolean
|
||||
pattern?: string
|
||||
patternFlags?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface DecisionTransitionMetadata {
|
||||
color?: string
|
||||
icon?: string
|
||||
@@ -114,6 +143,8 @@ export interface DecisionNodeModel {
|
||||
trigger?: string
|
||||
frequency?: string
|
||||
frequencyName?: string
|
||||
triggers?: DecisionNodeTrigger[]
|
||||
conditions?: DecisionNodeCondition[]
|
||||
transitions: DecisionNodeTransition[]
|
||||
layout?: DecisionNodeLayout
|
||||
metadata?: DecisionNodeMetadata
|
||||
@@ -198,10 +229,13 @@ export interface RuntimeDecisionState {
|
||||
frequency?: string
|
||||
frequencyName?: string
|
||||
auto_transitions?: RuntimeDecisionAutoTransition[]
|
||||
triggers?: DecisionNodeTrigger[]
|
||||
conditions?: DecisionNodeCondition[]
|
||||
metadata?: DecisionNodeMetadata
|
||||
}
|
||||
|
||||
export interface RuntimeDecisionTree {
|
||||
slug: string
|
||||
schema_version: string
|
||||
name: string
|
||||
description?: string
|
||||
@@ -216,6 +250,12 @@ export interface RuntimeDecisionTree {
|
||||
states: Record<string, RuntimeDecisionState>
|
||||
}
|
||||
|
||||
export interface RuntimeDecisionSystem {
|
||||
main: string
|
||||
order: string[]
|
||||
flows: Record<string, RuntimeDecisionTree>
|
||||
}
|
||||
|
||||
export interface DecisionFlowSummary {
|
||||
id: string
|
||||
slug: string
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export interface LLMDecisionInput {
|
||||
state_id: string
|
||||
state: any
|
||||
candidates: Array<{ id: string; state: any }>
|
||||
candidates: Array<{ id: string; state: any; flow?: string }>
|
||||
variables: Record<string, any>
|
||||
flags: Record<string, any>
|
||||
pilot_utterance: string
|
||||
flow_slug?: string
|
||||
}
|
||||
|
||||
export interface LLMDecision {
|
||||
@@ -14,4 +15,6 @@ export interface LLMDecision {
|
||||
controller_say_tpl?: string
|
||||
off_schema?: boolean
|
||||
radio_check?: boolean
|
||||
activate_flow?: string
|
||||
resume_previous?: boolean
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// communicationsEngine composable
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { ref, computed, readonly, reactive } from 'vue'
|
||||
import type {
|
||||
RuntimeDecisionTree,
|
||||
RuntimeDecisionSystem,
|
||||
RuntimeDecisionState,
|
||||
RuntimeDecisionAutoTransition,
|
||||
DecisionNodeAutoTrigger,
|
||||
@@ -93,6 +94,19 @@ export interface EngineLog {
|
||||
state: string
|
||||
radioCheck?: boolean
|
||||
offSchema?: boolean
|
||||
flow?: string
|
||||
}
|
||||
|
||||
interface FlowSnapshot {
|
||||
tree: RuntimeDecisionTree
|
||||
variables: Record<string, any>
|
||||
flags: EngineFlags
|
||||
telemetry: TelemetryState
|
||||
currentStateId: string
|
||||
communicationLog: EngineLog[]
|
||||
autoHistory: Map<string, Set<string>>
|
||||
flightContext: FlightContext
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type TelemetryState = {
|
||||
@@ -111,6 +125,33 @@ export function normalizeATCText(text: string, context: Record<string, any>): st
|
||||
return normalizeRadioPhrase(rendered)
|
||||
}
|
||||
|
||||
function createDefaultFlightContext(): FlightContext {
|
||||
return {
|
||||
callsign: '',
|
||||
aircraft: 'A320',
|
||||
dep: 'EDDF',
|
||||
dest: 'EDDM',
|
||||
stand: 'A12',
|
||||
runway: '25R',
|
||||
squawk: '1234',
|
||||
atis_code: 'K',
|
||||
sid: 'ANEKI7S',
|
||||
transition: 'ANEKI',
|
||||
flight_level: 'FL360',
|
||||
atis_freq: '118.025',
|
||||
ground_freq: '121.700',
|
||||
tower_freq: '118.700',
|
||||
departure_freq: '125.350',
|
||||
approach_freq: '120.800',
|
||||
handoff_freq: '121.800',
|
||||
qnh_hpa: 1015,
|
||||
taxi_route: 'A, V',
|
||||
remarks: 'standard',
|
||||
time_now: undefined,
|
||||
phase: 'clearance',
|
||||
}
|
||||
}
|
||||
|
||||
function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
||||
return tpl.replace(/\{([\w.]+)\}/g, (_m, key) => {
|
||||
const parts = key.split('.')
|
||||
@@ -121,9 +162,15 @@ function renderTpl(tpl: string, ctx: Record<string, any>): string {
|
||||
}
|
||||
|
||||
export default function useCommunicationsEngine() {
|
||||
const runtimeSystem = ref<RuntimeDecisionSystem | null>(null)
|
||||
const flowOrder = ref<string[]>([])
|
||||
const activeFlowSlug = ref<string>('')
|
||||
|
||||
const tree = ref<RuntimeDecisionTree | null>(null)
|
||||
const ready = ref(false)
|
||||
|
||||
const flowSnapshots = reactive<Record<string, FlowSnapshot>>({})
|
||||
|
||||
const states = computed<Record<string, RuntimeDecisionState>>(() => tree.value?.states ?? {})
|
||||
|
||||
const variables = ref<Record<string, any>>({})
|
||||
@@ -148,31 +195,8 @@ export default function useCommunicationsEngine() {
|
||||
heading_deg: 0,
|
||||
})
|
||||
|
||||
const autoExecutionHistory = new Map<string, Set<string>>()
|
||||
|
||||
// Flight context used for pm_alt.vue integration
|
||||
const flightContext = ref<FlightContext>({
|
||||
callsign: '',
|
||||
aircraft: 'A320',
|
||||
dep: 'EDDF',
|
||||
dest: 'EDDM',
|
||||
stand: 'A12',
|
||||
runway: '25R',
|
||||
squawk: '1234',
|
||||
atis_code: 'K',
|
||||
sid: 'ANEKI7S',
|
||||
transition: 'ANEKI',
|
||||
flight_level: 'FL360',
|
||||
atis_freq: '118.025',
|
||||
ground_freq: '121.700',
|
||||
tower_freq: '118.700',
|
||||
departure_freq: '125.350',
|
||||
approach_freq: '120.800',
|
||||
handoff_freq: '121.800',
|
||||
qnh_hpa: 1015,
|
||||
taxi_route: 'A, V',
|
||||
phase: 'clearance'
|
||||
})
|
||||
const flightContext = ref<FlightContext>(createDefaultFlightContext())
|
||||
|
||||
const currentState = computed<RuntimeDecisionState & { id: string } | null>(() => {
|
||||
const stateMap = states.value
|
||||
@@ -183,6 +207,140 @@ export default function useCommunicationsEngine() {
|
||||
return base ? { ...base, id } : null
|
||||
})
|
||||
|
||||
function ensureSnapshot(slug: string): FlowSnapshot {
|
||||
const snapshot = flowSnapshots[slug]
|
||||
if (!snapshot) {
|
||||
throw new Error(`Flow snapshot not loaded: ${slug}`)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function getActiveSnapshot(): FlowSnapshot | null {
|
||||
if (!activeFlowSlug.value) return null
|
||||
return flowSnapshots[activeFlowSlug.value] || null
|
||||
}
|
||||
|
||||
function assignActiveVariables(next: Record<string, any>) {
|
||||
variables.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].variables = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignActiveFlags(next: EngineFlags) {
|
||||
flags.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].flags = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignActiveTelemetry(next: TelemetryState) {
|
||||
telemetry.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].telemetry = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignCommunicationLog(next: EngineLog[]) {
|
||||
communicationLog.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].communicationLog = next
|
||||
}
|
||||
}
|
||||
|
||||
function assignFlightContext(next: FlightContext) {
|
||||
flightContext.value = next
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].flightContext = next
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveStateId(stateId: string) {
|
||||
currentStateId.value = stateId
|
||||
if (activeFlowSlug.value && flowSnapshots[activeFlowSlug.value]) {
|
||||
flowSnapshots[activeFlowSlug.value].currentStateId = stateId
|
||||
}
|
||||
}
|
||||
|
||||
function createSnapshotFromTree(treeData: RuntimeDecisionTree): FlowSnapshot {
|
||||
const variables = { ...treeData.variables }
|
||||
const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {}
|
||||
const stack = Array.isArray((baseFlags as any).stack) ? [...(baseFlags as any).stack] : []
|
||||
const flags: EngineFlags = {
|
||||
in_air: Boolean((baseFlags as any).in_air),
|
||||
emergency_active: Boolean((baseFlags as any).emergency_active),
|
||||
current_unit: typeof (baseFlags as any).current_unit === 'string'
|
||||
? (baseFlags as any).current_unit
|
||||
: 'DEL',
|
||||
stack,
|
||||
off_schema_count: Number((baseFlags as any).off_schema_count) || 0,
|
||||
radio_checks_done: Number((baseFlags as any).radio_checks_done) || 0,
|
||||
...(baseFlags as EngineFlags),
|
||||
}
|
||||
if (!Array.isArray(flags.stack)) {
|
||||
flags.stack = []
|
||||
}
|
||||
|
||||
const telemetry: TelemetryState = {
|
||||
altitude_ft: Number((baseFlags as any).altitude_ft) || 0,
|
||||
speed_kts: Number((baseFlags as any).speed_kts) || 0,
|
||||
groundspeed_kts: Number((baseFlags as any).groundspeed_kts) || 0,
|
||||
vertical_speed_fpm: Number((baseFlags as any).vertical_speed_fpm) || 0,
|
||||
latitude_deg: Number((baseFlags as any).latitude_deg) || 0,
|
||||
longitude_deg: Number((baseFlags as any).longitude_deg) || 0,
|
||||
heading_deg: Number((baseFlags as any).heading_deg) || 0,
|
||||
}
|
||||
|
||||
const log: EngineLog[] = []
|
||||
const snapshotContext = createDefaultFlightContext()
|
||||
snapshotContext.phase = 'clearance'
|
||||
|
||||
const autoHistory = new Map<string, Set<string>>()
|
||||
if (treeData.start_state) {
|
||||
autoHistory.set(treeData.start_state, new Set())
|
||||
}
|
||||
|
||||
return {
|
||||
tree: treeData,
|
||||
variables,
|
||||
flags,
|
||||
telemetry,
|
||||
currentStateId: treeData.start_state,
|
||||
communicationLog: log,
|
||||
autoHistory,
|
||||
flightContext: snapshotContext,
|
||||
ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
function persistActiveSnapshot() {
|
||||
if (!activeFlowSlug.value) return
|
||||
const snapshot = flowSnapshots[activeFlowSlug.value]
|
||||
if (!snapshot) return
|
||||
snapshot.variables = variables.value
|
||||
snapshot.flags = flags.value
|
||||
snapshot.telemetry = telemetry.value
|
||||
snapshot.currentStateId = currentStateId.value
|
||||
snapshot.communicationLog = communicationLog.value
|
||||
snapshot.flightContext = flightContext.value
|
||||
snapshot.ready = ready.value
|
||||
}
|
||||
|
||||
function activateFlow(slug: string) {
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
if (activeFlowSlug.value && activeFlowSlug.value !== slug) {
|
||||
persistActiveSnapshot()
|
||||
}
|
||||
activeFlowSlug.value = slug
|
||||
tree.value = snapshot.tree
|
||||
assignActiveVariables(snapshot.variables)
|
||||
assignActiveFlags(snapshot.flags)
|
||||
assignActiveTelemetry(snapshot.telemetry)
|
||||
assignCommunicationLog(snapshot.communicationLog)
|
||||
assignFlightContext(snapshot.flightContext)
|
||||
setActiveStateId(snapshot.currentStateId)
|
||||
ready.value = snapshot.ready
|
||||
}
|
||||
const nextCandidates = computed<string[]>(() => {
|
||||
const s = currentState.value
|
||||
if (!s) return []
|
||||
@@ -210,69 +368,137 @@ export default function useCommunicationsEngine() {
|
||||
return tree.value
|
||||
}
|
||||
|
||||
function resetAutoHistory(stateId: string) {
|
||||
autoExecutionHistory.set(stateId, new Set())
|
||||
function resetAutoHistory(stateId: string, slug = activeFlowSlug.value) {
|
||||
if (!slug) return
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
snapshot.autoHistory.set(stateId, new Set())
|
||||
}
|
||||
|
||||
function markAutoExecuted(stateId: string, transitionId: string) {
|
||||
if (!autoExecutionHistory.has(stateId)) {
|
||||
autoExecutionHistory.set(stateId, new Set())
|
||||
function markAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value) {
|
||||
if (!slug) return
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
if (!snapshot.autoHistory.has(stateId)) {
|
||||
snapshot.autoHistory.set(stateId, new Set())
|
||||
}
|
||||
autoExecutionHistory.get(stateId)!.add(transitionId)
|
||||
snapshot.autoHistory.get(stateId)!.add(transitionId)
|
||||
}
|
||||
|
||||
function hasAutoExecuted(stateId: string, transitionId: string): boolean {
|
||||
const set = autoExecutionHistory.get(stateId)
|
||||
function hasAutoExecuted(stateId: string, transitionId: string, slug = activeFlowSlug.value): boolean {
|
||||
if (!slug) return false
|
||||
const snapshot = ensureSnapshot(slug)
|
||||
const set = snapshot.autoHistory.get(stateId)
|
||||
return set ? set.has(transitionId) : false
|
||||
}
|
||||
|
||||
function resetEngineFromTree(treeData: RuntimeDecisionTree) {
|
||||
tree.value = treeData
|
||||
variables.value = { ...treeData.variables }
|
||||
const baseFlags = (treeData.flags && typeof treeData.flags === 'object') ? { ...treeData.flags } : {}
|
||||
const stack = Array.isArray(baseFlags.stack) ? [...baseFlags.stack] : []
|
||||
flags.value = {
|
||||
in_air: Boolean(baseFlags.in_air),
|
||||
emergency_active: Boolean(baseFlags.emergency_active),
|
||||
current_unit: typeof baseFlags.current_unit === 'string' ? baseFlags.current_unit : 'DEL',
|
||||
stack,
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0,
|
||||
...baseFlags,
|
||||
const system: RuntimeDecisionSystem = {
|
||||
main: treeData.slug,
|
||||
order: [treeData.slug],
|
||||
flows: { [treeData.slug]: treeData },
|
||||
}
|
||||
if (!Array.isArray(flags.value.stack)) {
|
||||
flags.value.stack = []
|
||||
resetEngineFromSystem(system, { activeSlug: treeData.slug })
|
||||
}
|
||||
|
||||
function resetEngineFromSystem(system: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
|
||||
runtimeSystem.value = system
|
||||
const order = Array.isArray(system.order) && system.order.length
|
||||
? [...system.order]
|
||||
: Object.keys(system.flows)
|
||||
flowOrder.value = order
|
||||
|
||||
for (const key of Object.keys(flowSnapshots)) {
|
||||
delete flowSnapshots[key]
|
||||
}
|
||||
currentStateId.value = treeData.start_state
|
||||
communicationLog.value = []
|
||||
telemetry.value = {
|
||||
altitude_ft: Number(baseFlags.altitude_ft) || 0,
|
||||
speed_kts: Number(baseFlags.speed_kts) || 0,
|
||||
groundspeed_kts: Number(baseFlags.groundspeed_kts) || 0,
|
||||
vertical_speed_fpm: Number(baseFlags.vertical_speed_fpm) || 0,
|
||||
latitude_deg: Number(baseFlags.latitude_deg) || 0,
|
||||
longitude_deg: Number(baseFlags.longitude_deg) || 0,
|
||||
heading_deg: Number(baseFlags.heading_deg) || 0,
|
||||
|
||||
for (const slug of order) {
|
||||
const treeData = system.flows[slug]
|
||||
if (!treeData) continue
|
||||
flowSnapshots[slug] = createSnapshotFromTree(treeData)
|
||||
}
|
||||
|
||||
const preferred = options.activeSlug && system.flows[options.activeSlug]
|
||||
? options.activeSlug
|
||||
: system.main && system.flows[system.main]
|
||||
? system.main
|
||||
: order[0]
|
||||
|
||||
if (preferred) {
|
||||
activateFlow(preferred)
|
||||
ready.value = true
|
||||
const snapshot = ensureSnapshot(preferred)
|
||||
resetAutoHistory(snapshot.currentStateId, preferred)
|
||||
evaluateAutoTransitions()
|
||||
} else {
|
||||
activeFlowSlug.value = ''
|
||||
tree.value = null
|
||||
ready.value = false
|
||||
assignActiveVariables({})
|
||||
assignActiveFlags({
|
||||
in_air: false,
|
||||
emergency_active: false,
|
||||
current_unit: 'DEL',
|
||||
stack: [],
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0,
|
||||
})
|
||||
assignActiveTelemetry({
|
||||
altitude_ft: 0,
|
||||
speed_kts: 0,
|
||||
groundspeed_kts: 0,
|
||||
vertical_speed_fpm: 0,
|
||||
latitude_deg: 0,
|
||||
longitude_deg: 0,
|
||||
heading_deg: 0,
|
||||
})
|
||||
assignCommunicationLog([])
|
||||
assignFlightContext(createDefaultFlightContext())
|
||||
setActiveStateId('')
|
||||
}
|
||||
autoExecutionHistory.clear()
|
||||
resetAutoHistory(currentStateId.value)
|
||||
flightContext.value.phase = 'clearance'
|
||||
ready.value = true
|
||||
evaluateAutoTransitions()
|
||||
}
|
||||
|
||||
function loadRuntimeTree(data: RuntimeDecisionTree) {
|
||||
resetEngineFromTree(data)
|
||||
}
|
||||
|
||||
function loadRuntimeSystem(data: RuntimeDecisionSystem, options: { activeSlug?: string } = {}) {
|
||||
resetEngineFromSystem(data, options)
|
||||
}
|
||||
|
||||
const activeFlow = computed(() => activeFlowSlug.value)
|
||||
|
||||
const availableFlows = computed(() => {
|
||||
if (!runtimeSystem.value) return [] as Array<{ slug: string; name: string; description?: string; start: string }>
|
||||
return flowOrder.value
|
||||
.filter((slug) => Boolean(runtimeSystem.value!.flows[slug]))
|
||||
.map((slug) => {
|
||||
const treeData = runtimeSystem.value!.flows[slug]
|
||||
return {
|
||||
slug,
|
||||
name: treeData.name || slug,
|
||||
description: treeData.description,
|
||||
start: treeData.start_state,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function setActiveFlow(slug: string) {
|
||||
if (!slug || !flowSnapshots[slug]) {
|
||||
throw new Error(`Flow snapshot not loaded: ${slug}`)
|
||||
}
|
||||
activateFlow(slug)
|
||||
ready.value = true
|
||||
queueMicrotask(() => evaluateAutoTransitions())
|
||||
}
|
||||
|
||||
async function fetchRuntimeTree(slug = 'icao_atc_decision_tree') {
|
||||
ready.value = false
|
||||
const fetcher: any = (globalThis as any).$fetch
|
||||
if (typeof fetcher !== 'function') {
|
||||
throw new Error('Universal fetch is not available in this context')
|
||||
}
|
||||
const data = await fetcher<RuntimeDecisionTree>(`/api/decision-flows/${slug}/runtime`)
|
||||
resetEngineFromTree(data)
|
||||
const data = await fetcher<RuntimeDecisionSystem>('/api/decision-flows/runtime')
|
||||
const activeSlug = slug && data.flows[slug] ? slug : data.main
|
||||
resetEngineFromSystem(data, { activeSlug })
|
||||
}
|
||||
|
||||
function normalizeComparableValue(value: any): any {
|
||||
@@ -417,7 +643,7 @@ export default function useCommunicationsEngine() {
|
||||
function initializeFlight(fpl: any) {
|
||||
const runtime = ensureTree()
|
||||
// Set variables
|
||||
variables.value = {
|
||||
const nextVariables = {
|
||||
...variables.value,
|
||||
callsign: fpl.callsign || fpl.callsign,
|
||||
acf_type: fpl.aircraft?.split('/')[0] || 'A320',
|
||||
@@ -448,6 +674,7 @@ export default function useCommunicationsEngine() {
|
||||
remarks: 'standard',
|
||||
time_now: new Date().toISOString()
|
||||
}
|
||||
assignActiveVariables(nextVariables)
|
||||
|
||||
// Update flight context
|
||||
Object.assign(flightContext.value, {
|
||||
@@ -455,7 +682,7 @@ export default function useCommunicationsEngine() {
|
||||
phase: 'clearance'
|
||||
})
|
||||
|
||||
flags.value = {
|
||||
const nextFlags: EngineFlags = {
|
||||
...flags.value,
|
||||
in_air: false,
|
||||
emergency_active: false,
|
||||
@@ -464,10 +691,11 @@ export default function useCommunicationsEngine() {
|
||||
off_schema_count: 0,
|
||||
radio_checks_done: 0
|
||||
}
|
||||
assignActiveFlags(nextFlags)
|
||||
|
||||
currentStateId.value = runtime.start_state
|
||||
communicationLog.value = []
|
||||
resetAutoHistory(currentStateId.value)
|
||||
setActiveStateId(runtime.start_state)
|
||||
assignCommunicationLog([])
|
||||
resetAutoHistory(runtime.start_state)
|
||||
}
|
||||
|
||||
function updateFrequencyVariables(update: Partial<Record<FrequencyVariableKey, string>>) {
|
||||
@@ -495,7 +723,7 @@ export default function useCommunicationsEngine() {
|
||||
throw new Error('Decision state unavailable')
|
||||
}
|
||||
const candidates = nextCandidates.value
|
||||
.map(id => ({ id, state: states.value[id] }))
|
||||
.map(id => ({ id, state: states.value[id], flow: runtime.slug }))
|
||||
.filter(candidate => candidate.state)
|
||||
|
||||
return {
|
||||
@@ -506,6 +734,7 @@ export default function useCommunicationsEngine() {
|
||||
flags: { ...flags.value },
|
||||
pilot_utterance: pilotTranscript,
|
||||
tree: runtime.name,
|
||||
flow_slug: runtime.slug,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +759,14 @@ export default function useCommunicationsEngine() {
|
||||
flags.value.stack = decision.stack.slice()
|
||||
}
|
||||
|
||||
if (decision.activate_flow && decision.activate_flow !== activeFlowSlug.value) {
|
||||
try {
|
||||
setActiveFlow(decision.activate_flow)
|
||||
} catch (err) {
|
||||
console.warn('[Engine] Failed to activate flow from decision', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.off_schema) {
|
||||
flags.value.off_schema_count++
|
||||
console.log(`[Engine] Off-schema response #${flags.value.off_schema_count}`)
|
||||
@@ -624,7 +861,7 @@ export default function useCommunicationsEngine() {
|
||||
const fallback = typeof raw === 'number' ? raw : Number(raw)
|
||||
next[key] = Number.isNaN(fallback) ? current : fallback
|
||||
}
|
||||
telemetry.value = next
|
||||
assignActiveTelemetry(next)
|
||||
queueMicrotask(() => evaluateAutoTransitions())
|
||||
}
|
||||
|
||||
@@ -693,7 +930,7 @@ export default function useCommunicationsEngine() {
|
||||
flags.value.stack.push(currentStateId.value)
|
||||
}
|
||||
|
||||
currentStateId.value = stateId
|
||||
setActiveStateId(stateId)
|
||||
resetAutoHistory(stateId)
|
||||
const s = currentState.value
|
||||
if (!s) return
|
||||
@@ -764,7 +1001,8 @@ export default function useCommunicationsEngine() {
|
||||
normalized: normalizeATCText(msg, exposeCtxFlat()),
|
||||
state: stateId,
|
||||
radioCheck: options.radioCheck,
|
||||
offSchema: options.offSchema
|
||||
offSchema: options.offSchema,
|
||||
flow: activeFlowSlug.value || undefined,
|
||||
}
|
||||
communicationLog.value.push(entry)
|
||||
}
|
||||
@@ -855,7 +1093,9 @@ export default function useCommunicationsEngine() {
|
||||
nextCandidates,
|
||||
activeFrequency,
|
||||
communicationLog: readonly(communicationLog),
|
||||
clearCommunicationLog: () => { communicationLog.value = [] },
|
||||
clearCommunicationLog: () => { assignCommunicationLog([]) },
|
||||
activeFlow,
|
||||
availableFlows,
|
||||
|
||||
// pm_alt.vue integration
|
||||
flightContext: readonly(flightContext),
|
||||
@@ -865,7 +1105,9 @@ export default function useCommunicationsEngine() {
|
||||
initializeFlight,
|
||||
updateFrequencyVariables,
|
||||
loadRuntimeTree,
|
||||
loadRuntimeSystem,
|
||||
fetchRuntimeTree,
|
||||
setActiveFlow,
|
||||
isReady,
|
||||
|
||||
// Communication
|
||||
|
||||
Reference in New Issue
Block a user