mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 18:45:33 +08:00
Build decision flow editor and runtime integration
This commit is contained in:
12
server/api/decision-flows/[slug]/runtime.get.ts
Normal file
12
server/api/decision-flows/[slug]/runtime.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createError } from 'h3'
|
||||
import { buildRuntimeDecisionTree } from '../../../services/decisionFlowService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slugParam = event.context.params?.slug
|
||||
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
|
||||
const tree = await buildRuntimeDecisionTree(slugParam.trim())
|
||||
return tree
|
||||
})
|
||||
8
server/api/editor/flows.get.ts
Normal file
8
server/api/editor/flows.get.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { listDecisionFlows } from '../../services/decisionFlowService'
|
||||
import { requireAdmin } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const flows = await listDecisionFlows()
|
||||
return flows
|
||||
})
|
||||
71
server/api/editor/flows.post.ts
Normal file
71
server/api/editor/flows.post.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { readBody, createError } from 'h3'
|
||||
import { requireAdmin } from '../../utils/auth'
|
||||
import { DecisionFlow } from '../../models/DecisionFlow'
|
||||
import { getFlowWithNodes } from '../../services/decisionFlowService'
|
||||
|
||||
function sanitizeSlug(input: string) {
|
||||
return input.toLowerCase().replace(/[^a-z0-9-_]/gi, '-')
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const body = await readBody<Record<string, any>>(event)
|
||||
|
||||
const rawSlug = typeof body.slug === 'string' ? body.slug.trim() : ''
|
||||
const slug = rawSlug ? sanitizeSlug(rawSlug) : ''
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Slug is required' })
|
||||
}
|
||||
|
||||
const rawName = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (!rawName) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Name is required' })
|
||||
}
|
||||
|
||||
const existing = await DecisionFlow.findOne({ slug })
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'A decision flow with this slug already exists' })
|
||||
}
|
||||
|
||||
const startState = (typeof body.startState === 'string' ? body.startState.trim() : '') || 'START'
|
||||
const description = typeof body.description === 'string' ? body.description.trim() : undefined
|
||||
const schemaVersion = (typeof body.schemaVersion === 'string' ? body.schemaVersion.trim() : '') || '1.0'
|
||||
|
||||
const roles = Array.isArray(body.roles)
|
||||
? body.roles
|
||||
.map((role: any) => (typeof role === 'string' ? role.trim() : ''))
|
||||
.filter((role: string) => role.length)
|
||||
: ['pilot', 'atc', 'system']
|
||||
|
||||
const phases = Array.isArray(body.phases)
|
||||
? body.phases
|
||||
.map((phase: any) => (typeof phase === 'string' ? phase.trim() : ''))
|
||||
.filter((phase: string) => phase.length)
|
||||
: []
|
||||
|
||||
const endStates = Array.isArray(body.endStates)
|
||||
? body.endStates
|
||||
.map((state: any) => (typeof state === 'string' ? state.trim() : ''))
|
||||
.filter((state: string) => state.length)
|
||||
: [startState]
|
||||
|
||||
const flow = new DecisionFlow({
|
||||
slug,
|
||||
name: rawName,
|
||||
description,
|
||||
schemaVersion,
|
||||
startState,
|
||||
endStates,
|
||||
variables: body.variables && typeof body.variables === 'object' ? body.variables : {},
|
||||
flags: body.flags && typeof body.flags === 'object' ? body.flags : {},
|
||||
policies: body.policies && typeof body.policies === 'object' ? body.policies : {},
|
||||
hooks: body.hooks && typeof body.hooks === 'object' ? body.hooks : {},
|
||||
roles,
|
||||
phases,
|
||||
})
|
||||
|
||||
await flow.save()
|
||||
|
||||
const { flow: serialized } = await getFlowWithNodes(slug)
|
||||
return serialized
|
||||
})
|
||||
14
server/api/editor/flows/[slug]/index.get.ts
Normal file
14
server/api/editor/flows/[slug]/index.get.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createError } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { getFlowWithNodes } from '../../../../services/decisionFlowService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const slug = event.context.params?.slug
|
||||
if (typeof slug !== 'string' || !slug.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
|
||||
const data = await getFlowWithNodes(slug.trim())
|
||||
return data
|
||||
})
|
||||
150
server/api/editor/flows/[slug]/index.put.ts
Normal file
150
server/api/editor/flows/[slug]/index.put.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { DecisionFlow } from '../../../../models/DecisionFlow'
|
||||
import { getFlowWithNodes } from '../../../../services/decisionFlowService'
|
||||
|
||||
function sanitizeStringArray(values: any): string[] | undefined {
|
||||
if (!Array.isArray(values)) return undefined
|
||||
const mapped = values
|
||||
.map((value: any) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter((value: string) => value.length)
|
||||
if (!mapped.length) return undefined
|
||||
return Array.from(new Set(mapped))
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const slugParam = event.context.params?.slug
|
||||
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
|
||||
const slug = slugParam.trim()
|
||||
const flow = await DecisionFlow.findOne({ slug })
|
||||
if (!flow) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, any>>(event)
|
||||
|
||||
if (typeof body.name === 'string' && body.name.trim()) {
|
||||
flow.name = body.name.trim()
|
||||
}
|
||||
|
||||
if (typeof body.description === 'string') {
|
||||
flow.description = body.description.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.schemaVersion === 'string') {
|
||||
const version = body.schemaVersion.trim()
|
||||
flow.schemaVersion = version || flow.schemaVersion
|
||||
}
|
||||
|
||||
if (typeof body.startState === 'string' && body.startState.trim()) {
|
||||
flow.startState = body.startState.trim()
|
||||
}
|
||||
|
||||
const endStates = sanitizeStringArray(body.endStates)
|
||||
if (endStates) {
|
||||
flow.endStates = endStates
|
||||
}
|
||||
|
||||
const roles = sanitizeStringArray(body.roles)
|
||||
if (roles) {
|
||||
flow.roles = roles
|
||||
}
|
||||
|
||||
const phases = sanitizeStringArray(body.phases)
|
||||
if (phases) {
|
||||
flow.phases = phases
|
||||
}
|
||||
|
||||
if (body.variables && typeof body.variables === 'object') {
|
||||
flow.variables = body.variables
|
||||
flow.markModified('variables')
|
||||
}
|
||||
|
||||
if (body.flags && typeof body.flags === 'object') {
|
||||
flow.flags = body.flags
|
||||
flow.markModified('flags')
|
||||
}
|
||||
|
||||
if (body.policies && typeof body.policies === 'object') {
|
||||
flow.policies = body.policies
|
||||
flow.markModified('policies')
|
||||
}
|
||||
|
||||
if (body.hooks && typeof body.hooks === 'object') {
|
||||
flow.hooks = body.hooks
|
||||
flow.markModified('hooks')
|
||||
}
|
||||
|
||||
if (body.layout && typeof body.layout === 'object') {
|
||||
const layout = flow.layout || { zoom: 1, pan: { x: 0, y: 0 }, groups: [] }
|
||||
const zoomValue = body.layout.zoom
|
||||
const zoom = typeof zoomValue === 'number' ? zoomValue : Number(zoomValue)
|
||||
if (Number.isFinite(zoom)) {
|
||||
layout.zoom = Math.min(Math.max(zoom, 0.25), 3)
|
||||
}
|
||||
if (body.layout.pan && typeof body.layout.pan === 'object') {
|
||||
const panX = body.layout.pan.x
|
||||
const panY = body.layout.pan.y
|
||||
const parsedX = typeof panX === 'number' ? panX : Number(panX)
|
||||
const parsedY = typeof panY === 'number' ? panY : Number(panY)
|
||||
if (Number.isFinite(parsedX)) layout.pan = layout.pan || { x: 0, y: 0 }
|
||||
if (Number.isFinite(parsedX)) layout.pan.x = parsedX
|
||||
if (Number.isFinite(parsedY)) layout.pan = layout.pan || { x: 0, y: 0 }
|
||||
if (Number.isFinite(parsedY)) layout.pan.y = parsedY
|
||||
}
|
||||
if (Array.isArray(body.layout.groups)) {
|
||||
layout.groups = body.layout.groups
|
||||
.map((group: any) => {
|
||||
if (!group || typeof group !== 'object') return null
|
||||
const id = typeof group.id === 'string' ? group.id.trim() : ''
|
||||
const label = typeof group.label === 'string' ? group.label.trim() : ''
|
||||
if (!id || !label) return null
|
||||
if (!group.bounds || typeof group.bounds !== 'object') return null
|
||||
const bounds = {
|
||||
x: Number(group.bounds.x) || 0,
|
||||
y: Number(group.bounds.y) || 0,
|
||||
width: Number(group.bounds.width) || 0,
|
||||
height: Number(group.bounds.height) || 0,
|
||||
}
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
color: typeof group.color === 'string' ? group.color.trim() || undefined : undefined,
|
||||
bounds,
|
||||
}
|
||||
})
|
||||
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
||||
}
|
||||
flow.layout = layout
|
||||
flow.markModified('layout')
|
||||
}
|
||||
|
||||
if (body.metadata && typeof body.metadata === 'object') {
|
||||
const metadata = flow.metadata || {}
|
||||
if (typeof body.metadata.notes === 'string') {
|
||||
metadata.notes = body.metadata.notes.trim() || undefined
|
||||
}
|
||||
if (Array.isArray(body.metadata.tags)) {
|
||||
metadata.tags = body.metadata.tags
|
||||
.map((tag: any) => (typeof tag === 'string' ? tag.trim() : ''))
|
||||
.filter((tag: string) => tag.length)
|
||||
}
|
||||
if (typeof body.metadata.ownerId === 'string') {
|
||||
metadata.ownerId = body.metadata.ownerId.trim() || undefined
|
||||
}
|
||||
if (typeof body.metadata.lastEditedBy === 'string') {
|
||||
metadata.lastEditedBy = body.metadata.lastEditedBy.trim() || undefined
|
||||
}
|
||||
flow.metadata = metadata
|
||||
flow.markModified('metadata')
|
||||
}
|
||||
|
||||
await flow.save()
|
||||
|
||||
const data = await getFlowWithNodes(slug)
|
||||
return data
|
||||
})
|
||||
101
server/api/editor/flows/[slug]/nodes.post.ts
Normal file
101
server/api/editor/flows/[slug]/nodes.post.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { DecisionFlow } from '../../../../models/DecisionFlow'
|
||||
import { DecisionNode } from '../../../../models/DecisionNode'
|
||||
import {
|
||||
sanitizeLayout,
|
||||
sanitizeLLMTemplate,
|
||||
sanitizeMetadata,
|
||||
sanitizeTransition,
|
||||
} from '../../../../utils/decisionSanitizer'
|
||||
import { serializeNodeDocument } from '../../../../services/decisionFlowService'
|
||||
|
||||
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const slugParam = event.context.params?.slug
|
||||
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
|
||||
const slug = slugParam.trim()
|
||||
const flow = await DecisionFlow.findOne({ slug })
|
||||
if (!flow) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, any>>(event)
|
||||
const rawStateId = typeof body.stateId === 'string' ? body.stateId.trim() : ''
|
||||
if (!rawStateId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'stateId is required' })
|
||||
}
|
||||
|
||||
const stateId = rawStateId.toUpperCase()
|
||||
const existingNode = await DecisionNode.findOne({ flow: flow._id, stateId })
|
||||
if (existingNode) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'State already exists in this flow' })
|
||||
}
|
||||
|
||||
const role = typeof body.role === 'string' ? body.role.trim().toLowerCase() : ''
|
||||
if (!ROLE_SET.has(role)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' })
|
||||
}
|
||||
|
||||
const phase = typeof body.phase === 'string' ? body.phase.trim() : ''
|
||||
if (!phase) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'phase is required' })
|
||||
}
|
||||
|
||||
const transitions = Array.isArray(body.transitions)
|
||||
? body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
: []
|
||||
|
||||
const layout = sanitizeLayout(body.layout) || { x: 0, y: 0 }
|
||||
const metadata = sanitizeMetadata(body.metadata)
|
||||
const llmTemplate = sanitizeLLMTemplate(body.llmTemplate)
|
||||
|
||||
const readbackRequired = Array.isArray(body.readbackRequired)
|
||||
? body.readbackRequired
|
||||
.map((entry: any) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry: string) => entry.length)
|
||||
: []
|
||||
|
||||
const node = new DecisionNode({
|
||||
flow: flow._id,
|
||||
stateId,
|
||||
title: typeof body.title === 'string' ? body.title.trim() || undefined : undefined,
|
||||
summary: typeof body.summary === 'string' ? body.summary.trim() || undefined : undefined,
|
||||
role,
|
||||
phase,
|
||||
sayTemplate: typeof body.sayTemplate === 'string' ? body.sayTemplate.trim() || undefined : undefined,
|
||||
utteranceTemplate:
|
||||
typeof body.utteranceTemplate === 'string' ? body.utteranceTemplate.trim() || undefined : undefined,
|
||||
elseSayTemplate:
|
||||
typeof body.elseSayTemplate === 'string' ? body.elseSayTemplate.trim() || undefined : undefined,
|
||||
readbackRequired,
|
||||
autoBehavior: typeof body.autoBehavior === 'string' ? body.autoBehavior.trim() || undefined : undefined,
|
||||
actions: Array.isArray(body.actions) ? body.actions : [],
|
||||
handoff:
|
||||
body.handoff && typeof body.handoff === 'object' && typeof body.handoff.to === 'string'
|
||||
? {
|
||||
to: body.handoff.to.trim(),
|
||||
freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined,
|
||||
note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined,
|
||||
}
|
||||
: undefined,
|
||||
guard: typeof body.guard === 'string' ? body.guard.trim() || undefined : undefined,
|
||||
trigger: typeof body.trigger === 'string' ? body.trigger.trim() || undefined : undefined,
|
||||
frequency: typeof body.frequency === 'string' ? body.frequency.trim() || undefined : undefined,
|
||||
frequencyName:
|
||||
typeof body.frequencyName === 'string' ? body.frequencyName.trim() || undefined : undefined,
|
||||
transitions,
|
||||
layout,
|
||||
metadata,
|
||||
llmTemplate,
|
||||
})
|
||||
|
||||
await node.save()
|
||||
|
||||
return serializeNodeDocument(node)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createError } from 'h3'
|
||||
import { requireAdmin } from '../../../../../../utils/auth'
|
||||
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
|
||||
import { DecisionNode } from '../../../../../../models/DecisionNode'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const slugParam = event.context.params?.slug
|
||||
const stateParam = event.context.params?.stateId
|
||||
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
if (typeof stateParam !== 'string' || !stateParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
|
||||
}
|
||||
|
||||
const slug = slugParam.trim()
|
||||
const stateId = stateParam.trim().toUpperCase()
|
||||
|
||||
const flow = await DecisionFlow.findOne({ slug })
|
||||
if (!flow) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const result = await DecisionNode.deleteOne({ flow: flow._id, stateId })
|
||||
if (!result.deletedCount) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'State not found' })
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
137
server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts
Normal file
137
server/api/editor/flows/[slug]/nodes/[stateId]/index.put.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdmin } from '../../../../../../utils/auth'
|
||||
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
|
||||
import { DecisionNode } from '../../../../../../models/DecisionNode'
|
||||
import {
|
||||
sanitizeLayout,
|
||||
sanitizeLLMTemplate,
|
||||
sanitizeMetadata,
|
||||
sanitizeTransition,
|
||||
} from '../../../../../../utils/decisionSanitizer'
|
||||
import { serializeNodeDocument } from '../../../../../../services/decisionFlowService'
|
||||
|
||||
const ROLE_SET = new Set(['pilot', 'atc', 'system'])
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const slugParam = event.context.params?.slug
|
||||
const stateParam = event.context.params?.stateId
|
||||
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
if (typeof stateParam !== 'string' || !stateParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
|
||||
}
|
||||
|
||||
const slug = slugParam.trim()
|
||||
const stateId = stateParam.trim().toUpperCase()
|
||||
|
||||
const flow = await DecisionFlow.findOne({ slug })
|
||||
if (!flow) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const node = await DecisionNode.findOne({ flow: flow._id, stateId })
|
||||
if (!node) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'State not found' })
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, any>>(event)
|
||||
|
||||
if (typeof body.title === 'string') {
|
||||
node.title = body.title.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.summary === 'string') {
|
||||
node.summary = body.summary.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.role === 'string') {
|
||||
const role = body.role.trim().toLowerCase()
|
||||
if (!ROLE_SET.has(role)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'role must be pilot, atc or system' })
|
||||
}
|
||||
node.role = role
|
||||
}
|
||||
|
||||
if (typeof body.phase === 'string' && body.phase.trim()) {
|
||||
node.phase = body.phase.trim()
|
||||
}
|
||||
|
||||
if (typeof body.sayTemplate === 'string') {
|
||||
node.sayTemplate = body.sayTemplate.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.utteranceTemplate === 'string') {
|
||||
node.utteranceTemplate = body.utteranceTemplate.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.elseSayTemplate === 'string') {
|
||||
node.elseSayTemplate = body.elseSayTemplate.trim() || undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(body.readbackRequired)) {
|
||||
node.readbackRequired = body.readbackRequired
|
||||
.map((entry: any) => (typeof entry === 'string' ? entry.trim() : ''))
|
||||
.filter((entry: string) => entry.length)
|
||||
}
|
||||
|
||||
if (typeof body.autoBehavior === 'string') {
|
||||
node.autoBehavior = body.autoBehavior.trim() || undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(body.actions)) {
|
||||
node.actions = body.actions
|
||||
}
|
||||
|
||||
if (body.handoff && typeof body.handoff === 'object') {
|
||||
if (typeof body.handoff.to === 'string' && body.handoff.to.trim()) {
|
||||
node.handoff = {
|
||||
to: body.handoff.to.trim(),
|
||||
freq: typeof body.handoff.freq === 'string' ? body.handoff.freq.trim() || undefined : undefined,
|
||||
note: typeof body.handoff.note === 'string' ? body.handoff.note.trim() || undefined : undefined,
|
||||
}
|
||||
} else {
|
||||
node.handoff = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof body.guard === 'string') {
|
||||
node.guard = body.guard.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.trigger === 'string') {
|
||||
node.trigger = body.trigger.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.frequency === 'string') {
|
||||
node.frequency = body.frequency.trim() || undefined
|
||||
}
|
||||
|
||||
if (typeof body.frequencyName === 'string') {
|
||||
node.frequencyName = body.frequencyName.trim() || undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(body.transitions)) {
|
||||
node.transitions = body.transitions.map((transition: any, index: number) => sanitizeTransition(transition, index))
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(body.layout)
|
||||
if (layout) {
|
||||
node.layout = layout
|
||||
}
|
||||
|
||||
const metadata = sanitizeMetadata(body.metadata)
|
||||
if (metadata) {
|
||||
node.metadata = metadata
|
||||
}
|
||||
|
||||
const llmTemplate = sanitizeLLMTemplate(body.llmTemplate)
|
||||
if (llmTemplate) {
|
||||
node.llmTemplate = llmTemplate
|
||||
}
|
||||
|
||||
await node.save()
|
||||
|
||||
return serializeNodeDocument(node)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdmin } from '../../../../../../utils/auth'
|
||||
import { DecisionFlow } from '../../../../../../models/DecisionFlow'
|
||||
import { DecisionNode } from '../../../../../../models/DecisionNode'
|
||||
import { sanitizeLayout } from '../../../../../../utils/decisionSanitizer'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const slugParam = event.context.params?.slug
|
||||
const stateParam = event.context.params?.stateId
|
||||
if (typeof slugParam !== 'string' || !slugParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing flow identifier' })
|
||||
}
|
||||
if (typeof stateParam !== 'string' || !stateParam.trim()) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing state identifier' })
|
||||
}
|
||||
|
||||
const slug = slugParam.trim()
|
||||
const stateId = stateParam.trim().toUpperCase()
|
||||
|
||||
const flow = await DecisionFlow.findOne({ slug })
|
||||
if (!flow) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const node = await DecisionNode.findOne({ flow: flow._id, stateId })
|
||||
if (!node) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'State not found' })
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, any>>(event)
|
||||
const layoutUpdate = sanitizeLayout(body)
|
||||
if (!layoutUpdate) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Invalid layout payload' })
|
||||
}
|
||||
|
||||
const existingLayout = node.layout || { x: 0, y: 0 }
|
||||
node.layout = { ...existingLayout, ...layoutUpdate }
|
||||
await node.save()
|
||||
|
||||
return { success: true, layout: node.layout }
|
||||
})
|
||||
19
server/api/editor/flows/import.post.ts
Normal file
19
server/api/editor/flows/import.post.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { readBody } from 'h3'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { importATCDecisionTree } from '../../../services/decisionImportService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
const body = await readBody<Record<string, any> | undefined>(event)
|
||||
|
||||
const { flow, importedStates } = await importATCDecisionTree({
|
||||
slug: typeof body?.slug === 'string' ? body.slug : undefined,
|
||||
name: typeof body?.name === 'string' ? body.name : undefined,
|
||||
description: typeof body?.description === 'string' ? body.description : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
flow,
|
||||
importedStates,
|
||||
}
|
||||
})
|
||||
103
server/models/DecisionFlow.ts
Normal file
103
server/models/DecisionFlow.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import mongoose from 'mongoose'
|
||||
import type {
|
||||
DecisionFlowLayout,
|
||||
DecisionFlowMetadata,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
export interface DecisionFlowDocument extends mongoose.Document {
|
||||
slug: string
|
||||
name: string
|
||||
description?: string
|
||||
schemaVersion?: string
|
||||
startState: string
|
||||
endStates: string[]
|
||||
variables: Record<string, any>
|
||||
flags: Record<string, any>
|
||||
policies: Record<string, any>
|
||||
hooks: Record<string, any>
|
||||
roles: string[]
|
||||
phases: string[]
|
||||
layout?: DecisionFlowLayout
|
||||
metadata?: DecisionFlowMetadata
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const decisionFlowSchema = new mongoose.Schema<DecisionFlowDocument>(
|
||||
{
|
||||
slug: { type: String, required: true, unique: true, index: true },
|
||||
name: { type: String, required: true },
|
||||
description: { type: String },
|
||||
schemaVersion: { type: String },
|
||||
startState: { type: String, required: true },
|
||||
endStates: { type: [String], default: () => [] },
|
||||
variables: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
||||
flags: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
||||
policies: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
||||
hooks: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
||||
roles: { type: [String], default: () => [] },
|
||||
phases: { type: [String], default: () => [] },
|
||||
layout: {
|
||||
type: new mongoose.Schema<DecisionFlowLayout>(
|
||||
{
|
||||
zoom: { type: Number, default: 1 },
|
||||
pan: {
|
||||
type: new mongoose.Schema({ x: { type: Number, default: 0 }, y: { type: Number, default: 0 } }, { _id: false }),
|
||||
default: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
groups: {
|
||||
type: [
|
||||
new mongoose.Schema(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
color: { type: String },
|
||||
bounds: {
|
||||
type: new mongoose.Schema(
|
||||
{
|
||||
x: { type: Number, required: true },
|
||||
y: { type: Number, required: true },
|
||||
width: { type: Number, required: true },
|
||||
height: { type: Number, required: true },
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
],
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
default: () => ({ zoom: 1, pan: { x: 0, y: 0 }, groups: [] }),
|
||||
},
|
||||
metadata: {
|
||||
type: new mongoose.Schema<DecisionFlowMetadata>(
|
||||
{
|
||||
notes: { type: String },
|
||||
tags: { type: [String], default: () => [] },
|
||||
ownerId: { type: String },
|
||||
lastEditedBy: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
decisionFlowSchema.index({ updatedAt: -1 })
|
||||
|
||||
decisionFlowSchema.set('toJSON', {
|
||||
virtuals: true,
|
||||
getters: true,
|
||||
})
|
||||
|
||||
export const DecisionFlow =
|
||||
(mongoose.models.DecisionFlow as mongoose.Model<DecisionFlowDocument>) ||
|
||||
mongoose.model<DecisionFlowDocument>('DecisionFlow', decisionFlowSchema)
|
||||
178
server/models/DecisionNode.ts
Normal file
178
server/models/DecisionNode.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import mongoose from 'mongoose'
|
||||
import type {
|
||||
DecisionNodeAutoTrigger,
|
||||
DecisionNodeLayout,
|
||||
DecisionNodeLLMPlaceholder,
|
||||
DecisionNodeLLMTemplate,
|
||||
DecisionNodeMetadata,
|
||||
DecisionNodeModel,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
export interface DecisionNodeDocument
|
||||
extends mongoose.Document,
|
||||
Omit<DecisionNodeModel, 'stateId' | 'transitions'> {
|
||||
flow: mongoose.Types.ObjectId
|
||||
stateId: string
|
||||
transitions: DecisionNodeTransition[]
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const llmPlaceholderSchema = new mongoose.Schema<DecisionNodeLLMPlaceholder>(
|
||||
{
|
||||
key: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
description: { type: String },
|
||||
required: { type: Boolean, default: false },
|
||||
example: { type: String },
|
||||
defaultValue: { type: String },
|
||||
type: { type: String, default: 'text' },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const llmTemplateSchema = new mongoose.Schema<DecisionNodeLLMTemplate>(
|
||||
{
|
||||
summary: { type: String },
|
||||
prompt: { type: String },
|
||||
responseSchema: { type: String },
|
||||
autoProceed: { type: Boolean, default: false },
|
||||
temperature: { type: Number },
|
||||
topP: { type: Number },
|
||||
maxOutputTokens: { type: Number },
|
||||
placeholders: { type: [llmPlaceholderSchema], default: () => [] },
|
||||
guardrails: { type: [String], default: () => [] },
|
||||
notes: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const metadataSchema = new mongoose.Schema<DecisionNodeMetadata>(
|
||||
{
|
||||
tags: { type: [String], default: () => [] },
|
||||
notes: { type: String },
|
||||
pinned: { type: Boolean, default: false },
|
||||
complexity: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const layoutSchema = new mongoose.Schema<DecisionNodeLayout>(
|
||||
{
|
||||
x: { type: Number, required: true },
|
||||
y: { type: Number, required: true },
|
||||
width: { type: Number },
|
||||
height: { type: Number },
|
||||
color: { type: String },
|
||||
icon: { type: String },
|
||||
locked: { type: Boolean, default: false },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const autoTriggerSchema = new mongoose.Schema<DecisionNodeAutoTrigger>(
|
||||
{
|
||||
id: { type: String, required: true },
|
||||
type: { type: String, enum: ['telemetry', 'variable', 'expression'], required: true },
|
||||
parameter: { type: String },
|
||||
variable: { type: String },
|
||||
operator: { type: String },
|
||||
value: { type: mongoose.Schema.Types.Mixed },
|
||||
unit: { type: String },
|
||||
expression: { type: String },
|
||||
description: { type: String },
|
||||
once: { type: Boolean, default: true },
|
||||
delayMs: { type: Number },
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const transitionSchema = new mongoose.Schema<DecisionNodeTransition>(
|
||||
{
|
||||
key: { type: String, required: true },
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'],
|
||||
default: 'next',
|
||||
},
|
||||
target: { type: String, required: true },
|
||||
label: { type: String },
|
||||
description: { type: String },
|
||||
condition: { type: String },
|
||||
guard: { type: String },
|
||||
order: { type: Number, default: 0 },
|
||||
timer: {
|
||||
type: new mongoose.Schema(
|
||||
{
|
||||
afterSeconds: { type: Number, required: true },
|
||||
allowManualProceed: { type: Boolean, default: true },
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
default: undefined,
|
||||
},
|
||||
autoTrigger: { type: autoTriggerSchema, default: undefined },
|
||||
metadata: {
|
||||
type: new mongoose.Schema(
|
||||
{
|
||||
color: { type: String },
|
||||
icon: { type: String },
|
||||
notes: { type: String },
|
||||
previewTemplate: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const decisionNodeSchema = new mongoose.Schema<DecisionNodeDocument>(
|
||||
{
|
||||
flow: { type: mongoose.Schema.Types.ObjectId, ref: 'DecisionFlow', required: true, index: true },
|
||||
stateId: { type: String, required: true },
|
||||
title: { type: String },
|
||||
summary: { type: String },
|
||||
role: { type: String, enum: ['pilot', 'atc', 'system'], required: true },
|
||||
phase: { type: String, required: true },
|
||||
sayTemplate: { type: String },
|
||||
utteranceTemplate: { type: String },
|
||||
elseSayTemplate: { type: String },
|
||||
readbackRequired: { type: [String], default: () => [] },
|
||||
autoBehavior: { type: String },
|
||||
actions: { type: [mongoose.Schema.Types.Mixed], default: () => [] },
|
||||
handoff: {
|
||||
type: new mongoose.Schema(
|
||||
{
|
||||
to: { type: String, required: true },
|
||||
freq: { type: String },
|
||||
note: { type: String },
|
||||
},
|
||||
{ _id: false }
|
||||
),
|
||||
default: undefined,
|
||||
},
|
||||
guard: { type: String },
|
||||
trigger: { type: String },
|
||||
frequency: { type: String },
|
||||
frequencyName: { type: String },
|
||||
transitions: { type: [transitionSchema], default: () => [] },
|
||||
layout: { type: layoutSchema, default: undefined },
|
||||
metadata: { type: metadataSchema, default: undefined },
|
||||
llmTemplate: { type: llmTemplateSchema, default: undefined },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
decisionNodeSchema.index({ flow: 1, stateId: 1 }, { unique: true })
|
||||
|
||||
decisionNodeSchema.set('toJSON', {
|
||||
virtuals: true,
|
||||
getters: true,
|
||||
})
|
||||
|
||||
export const DecisionNode =
|
||||
(mongoose.models.DecisionNode as mongoose.Model<DecisionNodeDocument>) ||
|
||||
mongoose.model<DecisionNodeDocument>('DecisionNode', decisionNodeSchema)
|
||||
203
server/services/decisionFlowService.ts
Normal file
203
server/services/decisionFlowService.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createError } from 'h3'
|
||||
import { DecisionFlow, type DecisionFlowDocument } from '../models/DecisionFlow'
|
||||
import { DecisionNode, type DecisionNodeDocument } from '../models/DecisionNode'
|
||||
import type {
|
||||
DecisionFlowModel,
|
||||
DecisionFlowSummary,
|
||||
DecisionNodeModel,
|
||||
DecisionNodeTransition,
|
||||
RuntimeDecisionAutoTransition,
|
||||
RuntimeDecisionState,
|
||||
RuntimeDecisionTree,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
export function serializeFlowDocument(doc: DecisionFlowDocument, nodeCount = 0): DecisionFlowModel {
|
||||
return {
|
||||
id: String(doc._id),
|
||||
slug: doc.slug,
|
||||
name: doc.name,
|
||||
description: doc.description || undefined,
|
||||
schemaVersion: doc.schemaVersion || undefined,
|
||||
startState: doc.startState,
|
||||
endStates: Array.isArray(doc.endStates) ? doc.endStates : [],
|
||||
variables: doc.variables || {},
|
||||
flags: doc.flags || {},
|
||||
policies: doc.policies || {},
|
||||
hooks: doc.hooks || {},
|
||||
roles: Array.isArray(doc.roles) ? doc.roles : [],
|
||||
phases: Array.isArray(doc.phases) ? doc.phases : [],
|
||||
layout: doc.layout || undefined,
|
||||
metadata: doc.metadata || undefined,
|
||||
createdAt: doc.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
updatedAt: doc.updatedAt?.toISOString?.() || new Date().toISOString(),
|
||||
nodeCount,
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeNodeDocument(doc: DecisionNodeDocument): DecisionNodeModel {
|
||||
const obj = doc.toObject<DecisionNodeDocument>({ virtuals: false })
|
||||
return {
|
||||
stateId: obj.stateId,
|
||||
title: obj.title || undefined,
|
||||
summary: obj.summary || undefined,
|
||||
role: obj.role as any,
|
||||
phase: obj.phase,
|
||||
sayTemplate: obj.sayTemplate || undefined,
|
||||
utteranceTemplate: obj.utteranceTemplate || undefined,
|
||||
elseSayTemplate: obj.elseSayTemplate || undefined,
|
||||
readbackRequired: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : [],
|
||||
autoBehavior: obj.autoBehavior || undefined,
|
||||
actions: Array.isArray(obj.actions) ? obj.actions : [],
|
||||
handoff: obj.handoff || undefined,
|
||||
guard: obj.guard || undefined,
|
||||
trigger: obj.trigger || undefined,
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
transitions: Array.isArray(obj.transitions) ? obj.transitions : [],
|
||||
layout: obj.layout || undefined,
|
||||
metadata: obj.metadata || undefined,
|
||||
llmTemplate: obj.llmTemplate || undefined,
|
||||
createdAt: obj.createdAt?.toISOString?.(),
|
||||
updatedAt: obj.updatedAt?.toISOString?.(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDecisionFlows(): Promise<DecisionFlowSummary[]> {
|
||||
const flows = await DecisionFlow.find().sort({ updatedAt: -1 }).lean()
|
||||
if (!flows.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ids = flows.map((flow) => flow._id)
|
||||
const counts = await DecisionNode.aggregate([
|
||||
{ $match: { flow: { $in: ids } } },
|
||||
{ $group: { _id: '$flow', count: { $sum: 1 } } },
|
||||
])
|
||||
|
||||
const countMap = counts.reduce<Record<string, number>>((acc, entry) => {
|
||||
acc[String(entry._id)] = entry.count
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return flows.map((flow) => ({
|
||||
id: String(flow._id),
|
||||
slug: flow.slug,
|
||||
name: flow.name,
|
||||
description: flow.description || undefined,
|
||||
startState: flow.startState,
|
||||
nodeCount: countMap[String(flow._id)] || 0,
|
||||
updatedAt: flow.updatedAt?.toISOString?.() || new Date().toISOString(),
|
||||
createdAt: flow.createdAt?.toISOString?.() || new Date().toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getFlowWithNodes(slug: string): Promise<{ flow: DecisionFlowModel; nodes: DecisionNodeModel[] }> {
|
||||
const flowDoc = await DecisionFlow.findOne({ slug })
|
||||
if (!flowDoc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const nodes = await DecisionNode.find({ flow: flowDoc._id }).sort({ stateId: 1 })
|
||||
const flow = serializeFlowDocument(flowDoc, nodes.length)
|
||||
const serializedNodes = nodes.map((node) => serializeNodeDocument(node))
|
||||
|
||||
return { flow, nodes: serializedNodes }
|
||||
}
|
||||
|
||||
function toRuntimeTransitions(
|
||||
transitions: DecisionNodeTransition[],
|
||||
types: Array<DecisionNodeTransition['type']>,
|
||||
includeAuto = false
|
||||
) {
|
||||
return transitions
|
||||
.filter((transition) => types.includes(transition.type) || (includeAuto && transition.type === 'auto'))
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((transition) => ({
|
||||
to: transition.target,
|
||||
label: transition.label || undefined,
|
||||
when: transition.condition || undefined,
|
||||
guard: transition.guard || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function toRuntimeTimers(transitions: DecisionNodeTransition[]): RuntimeDecisionState['timer_next'] {
|
||||
return transitions
|
||||
.filter((transition) => transition.type === 'timer' && transition.timer)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((transition) => ({
|
||||
to: transition.target,
|
||||
after_s: transition.timer?.afterSeconds ?? 0,
|
||||
label: transition.label || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function toRuntimeAutoTransitions(transitions: DecisionNodeTransition[]): RuntimeDecisionAutoTransition[] {
|
||||
return transitions
|
||||
.filter((transition) => transition.autoTrigger)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((transition) => ({
|
||||
id: transition.key,
|
||||
to: transition.target,
|
||||
label: transition.label || undefined,
|
||||
description: transition.description || undefined,
|
||||
condition: transition.condition || undefined,
|
||||
guard: transition.guard || undefined,
|
||||
trigger: transition.autoTrigger || null,
|
||||
metadata: transition.metadata || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function serializeRuntimeState(node: DecisionNodeDocument): RuntimeDecisionState {
|
||||
const obj = node.toObject<DecisionNodeDocument>({ virtuals: false })
|
||||
const transitions = Array.isArray(obj.transitions) ? obj.transitions : []
|
||||
|
||||
return {
|
||||
role: obj.role as any,
|
||||
phase: obj.phase,
|
||||
say_tpl: obj.sayTemplate || undefined,
|
||||
utterance_tpl: obj.utteranceTemplate || undefined,
|
||||
else_say_tpl: obj.elseSayTemplate || undefined,
|
||||
next: toRuntimeTransitions(transitions, ['next'], true),
|
||||
ok_next: toRuntimeTransitions(transitions, ['ok']),
|
||||
bad_next: toRuntimeTransitions(transitions, ['bad']),
|
||||
timer_next: toRuntimeTimers(transitions),
|
||||
auto: obj.autoBehavior || null,
|
||||
readback_required: Array.isArray(obj.readbackRequired) ? obj.readbackRequired : undefined,
|
||||
actions: Array.isArray(obj.actions) ? obj.actions : undefined,
|
||||
handoff: obj.handoff || undefined,
|
||||
guard: obj.guard || undefined,
|
||||
trigger: obj.trigger || undefined,
|
||||
frequency: obj.frequency || undefined,
|
||||
frequencyName: obj.frequencyName || undefined,
|
||||
auto_transitions: toRuntimeAutoTransitions(transitions),
|
||||
metadata: obj.metadata || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRuntimeDecisionTree(slug: string): Promise<RuntimeDecisionTree> {
|
||||
const flowDoc = await DecisionFlow.findOne({ slug })
|
||||
if (!flowDoc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Decision flow not found' })
|
||||
}
|
||||
|
||||
const nodes = await DecisionNode.find({ flow: flowDoc._id })
|
||||
const states = nodes.reduce<Record<string, RuntimeDecisionState>>((acc, node) => {
|
||||
acc[node.stateId] = serializeRuntimeState(node)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
schema_version: flowDoc.schemaVersion || '1.0',
|
||||
name: flowDoc.slug,
|
||||
description: flowDoc.description || undefined,
|
||||
start_state: flowDoc.startState,
|
||||
end_states: Array.isArray(flowDoc.endStates) ? flowDoc.endStates : [],
|
||||
variables: flowDoc.variables || {},
|
||||
flags: flowDoc.flags || {},
|
||||
policies: flowDoc.policies || {},
|
||||
hooks: flowDoc.hooks || {},
|
||||
roles: Array.isArray(flowDoc.roles) ? flowDoc.roles : [],
|
||||
phases: Array.isArray(flowDoc.phases) ? flowDoc.phases : [],
|
||||
states,
|
||||
}
|
||||
}
|
||||
197
server/services/decisionImportService.ts
Normal file
197
server/services/decisionImportService.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import atcDecisionTree from '~~/shared/data/atcDecisionTree'
|
||||
import { DecisionFlow } from '../models/DecisionFlow'
|
||||
import { DecisionNode } from '../models/DecisionNode'
|
||||
import type { DecisionNodeTransition } from '~~/shared/types/decision'
|
||||
import { getFlowWithNodes } from './decisionFlowService'
|
||||
|
||||
interface LegacyTransition {
|
||||
to?: string
|
||||
when?: string
|
||||
label?: string
|
||||
condition?: string
|
||||
guard?: string
|
||||
after_s?: number
|
||||
allowManualProceed?: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ImportDecisionTreeOptions {
|
||||
slug?: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
pilot: '#0ea5e9',
|
||||
atc: '#22d3ee',
|
||||
system: '#f59e0b',
|
||||
}
|
||||
|
||||
function createTransition(
|
||||
type: DecisionNodeTransition['type'],
|
||||
data: LegacyTransition,
|
||||
order: number
|
||||
): DecisionNodeTransition | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const target = typeof data.to === 'string' ? data.to.trim() : ''
|
||||
if (!target) return null
|
||||
|
||||
const transition: DecisionNodeTransition = {
|
||||
key: `${type}_${randomUUID().slice(0, 8)}`,
|
||||
type,
|
||||
target,
|
||||
order,
|
||||
}
|
||||
|
||||
const label = typeof data.label === 'string' ? data.label.trim() : undefined
|
||||
if (label) transition.label = label
|
||||
|
||||
const condition =
|
||||
typeof data.when === 'string'
|
||||
? data.when.trim()
|
||||
: typeof data.condition === 'string'
|
||||
? data.condition.trim()
|
||||
: undefined
|
||||
if (condition) transition.condition = condition
|
||||
|
||||
const guard = typeof data.guard === 'string' ? data.guard.trim() : undefined
|
||||
if (guard) transition.guard = guard
|
||||
|
||||
const description = typeof data.description === 'string' ? data.description.trim() : undefined
|
||||
if (description) transition.description = description
|
||||
|
||||
if (type === 'timer') {
|
||||
const after = typeof data.after_s === 'number' ? data.after_s : Number(data.after_s)
|
||||
if (Number.isFinite(after)) {
|
||||
transition.timer = {
|
||||
afterSeconds: Number(after),
|
||||
allowManualProceed: data.allowManualProceed !== false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transition
|
||||
}
|
||||
|
||||
export async function importATCDecisionTree(options: ImportDecisionTreeOptions = {}) {
|
||||
const slug = typeof options.slug === 'string' && options.slug.trim().length
|
||||
? options.slug.trim()
|
||||
: atcDecisionTree.name || 'icao_atc_decision_tree'
|
||||
|
||||
const name = typeof options.name === 'string' && options.name.trim().length
|
||||
? options.name.trim()
|
||||
: 'ATC Decision Tree'
|
||||
|
||||
const existingFlow = await DecisionFlow.findOne({ slug })
|
||||
const flow = existingFlow || new DecisionFlow({ slug, name })
|
||||
|
||||
flow.name = name
|
||||
flow.description = options.description ?? atcDecisionTree.description ?? flow.description
|
||||
flow.schemaVersion = atcDecisionTree.schema_version || '1.0'
|
||||
flow.startState = atcDecisionTree.start_state
|
||||
flow.endStates = Array.isArray(atcDecisionTree.end_states) ? atcDecisionTree.end_states : []
|
||||
flow.variables = atcDecisionTree.variables || {}
|
||||
flow.flags = atcDecisionTree.flags || {}
|
||||
flow.policies = atcDecisionTree.policies || {}
|
||||
flow.hooks = atcDecisionTree.hooks || {}
|
||||
flow.roles = Array.isArray(atcDecisionTree.roles) ? atcDecisionTree.roles : flow.roles
|
||||
flow.phases = Array.isArray(atcDecisionTree.phases) ? atcDecisionTree.phases : flow.phases
|
||||
flow.layout = flow.layout || { zoom: 0.9, pan: { x: 0, y: 0 }, groups: [] }
|
||||
|
||||
await flow.save()
|
||||
|
||||
await DecisionNode.deleteMany({ flow: flow._id })
|
||||
|
||||
const phases = Array.isArray(flow.phases) && flow.phases.length ? flow.phases : ['General']
|
||||
const phaseColumns = new Map<string, number>()
|
||||
phases.forEach((phase, index) => phaseColumns.set(phase, index))
|
||||
const phaseRowCounters = new Map<string, number>()
|
||||
|
||||
const stateEntries = Object.entries(atcDecisionTree.states || {})
|
||||
const nodesToInsert = stateEntries.map(([stateId, state]) => {
|
||||
const role = typeof state.role === 'string' ? state.role : 'system'
|
||||
const phase = typeof state.phase === 'string' ? state.phase : 'General'
|
||||
|
||||
const columnIndex = phaseColumns.has(phase) ? phaseColumns.get(phase)! : phaseColumns.size
|
||||
if (!phaseColumns.has(phase)) {
|
||||
phaseColumns.set(phase, columnIndex)
|
||||
}
|
||||
const rowIndex = phaseRowCounters.get(phase) || 0
|
||||
phaseRowCounters.set(phase, rowIndex + 1)
|
||||
|
||||
const transitions: DecisionNodeTransition[] = []
|
||||
let order = 0
|
||||
|
||||
if (Array.isArray(state.next)) {
|
||||
for (const entry of state.next as LegacyTransition[]) {
|
||||
const transition = createTransition('next', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.ok_next)) {
|
||||
for (const entry of state.ok_next as LegacyTransition[]) {
|
||||
const transition = createTransition('ok', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.bad_next)) {
|
||||
for (const entry of state.bad_next as LegacyTransition[]) {
|
||||
const transition = createTransition('bad', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(state.timer_next)) {
|
||||
for (const entry of state.timer_next as LegacyTransition[]) {
|
||||
const transition = createTransition('timer', entry, order++)
|
||||
if (transition) transitions.push(transition)
|
||||
}
|
||||
}
|
||||
|
||||
const readbackRequired = Array.isArray(state.readback_required)
|
||||
? state.readback_required.filter((item: any) => typeof item === 'string' && item.trim().length)
|
||||
: []
|
||||
|
||||
const layout = {
|
||||
x: columnIndex * 340,
|
||||
y: rowIndex * 220,
|
||||
color: ROLE_COLORS[role] || '#38bdf8',
|
||||
}
|
||||
|
||||
return {
|
||||
flow: flow._id,
|
||||
stateId,
|
||||
title: typeof state.title === 'string' ? state.title.trim() || undefined : undefined,
|
||||
summary: typeof state.summary === 'string' ? state.summary.trim() || undefined : undefined,
|
||||
role,
|
||||
phase,
|
||||
sayTemplate: typeof state.say_tpl === 'string' ? state.say_tpl : undefined,
|
||||
utteranceTemplate: typeof state.utterance_tpl === 'string' ? state.utterance_tpl : undefined,
|
||||
elseSayTemplate: typeof state.else_say_tpl === 'string' ? state.else_say_tpl : undefined,
|
||||
readbackRequired,
|
||||
autoBehavior: typeof state.auto === 'string' ? state.auto : undefined,
|
||||
actions: Array.isArray(state.actions) ? state.actions : [],
|
||||
handoff: state.handoff && typeof state.handoff === 'object' ? state.handoff : undefined,
|
||||
guard: typeof state.guard === 'string' ? state.guard : undefined,
|
||||
trigger: typeof state.trigger === 'string' ? state.trigger : undefined,
|
||||
frequency: typeof state.frequency === 'string' ? state.frequency : undefined,
|
||||
frequencyName: typeof state.frequencyName === 'string' ? state.frequencyName : undefined,
|
||||
transitions,
|
||||
layout,
|
||||
}
|
||||
})
|
||||
|
||||
await DecisionNode.insertMany(nodesToInsert)
|
||||
|
||||
const { flow: serializedFlow, nodes } = await getFlowWithNodes(slug)
|
||||
|
||||
return {
|
||||
flow: serializedFlow,
|
||||
nodes,
|
||||
importedStates: nodes.length,
|
||||
}
|
||||
}
|
||||
|
||||
258
server/utils/decisionSanitizer.ts
Normal file
258
server/utils/decisionSanitizer.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
DecisionNodeAutoTrigger,
|
||||
DecisionNodeLayout,
|
||||
DecisionNodeLLMPlaceholder,
|
||||
DecisionNodeLLMTemplate,
|
||||
DecisionNodeMetadata,
|
||||
DecisionNodeTransition,
|
||||
} from '~~/shared/types/decision'
|
||||
|
||||
const TRANSITION_TYPES = new Set(['next', 'ok', 'bad', 'timer', 'auto', 'interrupt', 'return'])
|
||||
const AUTO_TRIGGER_TYPES = new Set(['telemetry', 'variable', 'expression'])
|
||||
const COMPARISON_OPERATORS = new Set(['>', '>=', '<', '<=', '==', '!='])
|
||||
|
||||
function asTrimmedString(input: any): string | undefined {
|
||||
if (typeof input === 'string') {
|
||||
const trimmed = input.trim()
|
||||
return trimmed.length ? trimmed : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function asNumber(input: any): number | undefined {
|
||||
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||
return input
|
||||
}
|
||||
if (typeof input === 'string' && input.trim()) {
|
||||
const parsed = Number(input)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function asBoolean(input: any, fallback: boolean): boolean {
|
||||
if (typeof input === 'boolean') return input
|
||||
if (typeof input === 'string') {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (normalized === 'true') return true
|
||||
if (normalized === 'false') return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function sanitizeLayout(raw: any): DecisionNodeLayout | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const x = asNumber(raw.x) ?? 0
|
||||
const y = asNumber(raw.y) ?? 0
|
||||
const layout: DecisionNodeLayout = { x, y }
|
||||
const width = asNumber(raw.width)
|
||||
const height = asNumber(raw.height)
|
||||
if (typeof width === 'number') layout.width = width
|
||||
if (typeof height === 'number') layout.height = height
|
||||
const color = asTrimmedString(raw.color)
|
||||
if (color) layout.color = color
|
||||
const icon = asTrimmedString(raw.icon)
|
||||
if (icon) layout.icon = icon
|
||||
if (typeof raw.locked === 'boolean') {
|
||||
layout.locked = raw.locked
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
export function sanitizeMetadata(raw: any): DecisionNodeMetadata | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const metadata: DecisionNodeMetadata = {}
|
||||
if (Array.isArray(raw.tags)) {
|
||||
metadata.tags = raw.tags
|
||||
.map((tag: any) => asTrimmedString(tag))
|
||||
.filter((tag): tag is string => Boolean(tag))
|
||||
}
|
||||
const notes = asTrimmedString(raw.notes)
|
||||
if (notes) metadata.notes = notes
|
||||
if (typeof raw.pinned === 'boolean') metadata.pinned = raw.pinned
|
||||
const complexity = asTrimmedString(raw.complexity)
|
||||
if (complexity && ['low', 'medium', 'high'].includes(complexity)) {
|
||||
metadata.complexity = complexity as DecisionNodeMetadata['complexity']
|
||||
}
|
||||
return Object.keys(metadata).length ? metadata : undefined
|
||||
}
|
||||
|
||||
function sanitizeLLMPlaceholder(raw: any): DecisionNodeLLMPlaceholder | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const key = asTrimmedString(raw.key)
|
||||
const label = asTrimmedString(raw.label)
|
||||
if (!key || !label) return null
|
||||
const placeholder: DecisionNodeLLMPlaceholder = { key, label }
|
||||
const description = asTrimmedString(raw.description)
|
||||
if (description) placeholder.description = description
|
||||
if (typeof raw.required === 'boolean') placeholder.required = raw.required
|
||||
const example = asTrimmedString(raw.example)
|
||||
if (example) placeholder.example = example
|
||||
const defaultValue = asTrimmedString(raw.defaultValue)
|
||||
if (defaultValue !== undefined) placeholder.defaultValue = defaultValue
|
||||
const type = asTrimmedString(raw.type)
|
||||
if (type && ['text', 'number', 'choice'].includes(type)) {
|
||||
placeholder.type = type as DecisionNodeLLMPlaceholder['type']
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
|
||||
export function sanitizeLLMTemplate(raw: any): DecisionNodeLLMTemplate | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const template: DecisionNodeLLMTemplate = {}
|
||||
const summary = asTrimmedString(raw.summary)
|
||||
if (summary) template.summary = summary
|
||||
const prompt = asTrimmedString(raw.prompt)
|
||||
if (prompt) template.prompt = prompt
|
||||
const schema = asTrimmedString(raw.responseSchema)
|
||||
if (schema) template.responseSchema = schema
|
||||
if (raw.autoProceed !== undefined) template.autoProceed = asBoolean(raw.autoProceed, false)
|
||||
const temperature = asNumber(raw.temperature)
|
||||
if (typeof temperature === 'number') template.temperature = temperature
|
||||
const topP = asNumber(raw.topP)
|
||||
if (typeof topP === 'number') template.topP = topP
|
||||
const maxOutputTokens = asNumber(raw.maxOutputTokens)
|
||||
if (typeof maxOutputTokens === 'number') template.maxOutputTokens = maxOutputTokens
|
||||
if (Array.isArray(raw.guardrails)) {
|
||||
template.guardrails = raw.guardrails
|
||||
.map((item: any) => asTrimmedString(item))
|
||||
.filter((item): item is string => Boolean(item))
|
||||
}
|
||||
const notes = asTrimmedString(raw.notes)
|
||||
if (notes) template.notes = notes
|
||||
if (Array.isArray(raw.placeholders)) {
|
||||
const placeholders = raw.placeholders
|
||||
.map((item: any) => sanitizeLLMPlaceholder(item))
|
||||
.filter((item): item is DecisionNodeLLMPlaceholder => Boolean(item))
|
||||
template.placeholders = placeholders
|
||||
}
|
||||
return Object.keys(template).length ? template : undefined
|
||||
}
|
||||
|
||||
export function sanitizeAutoTrigger(raw: any): DecisionNodeAutoTrigger | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined
|
||||
const type = asTrimmedString(raw.type)
|
||||
if (!type || !AUTO_TRIGGER_TYPES.has(type)) {
|
||||
throw new Error('Invalid auto trigger type')
|
||||
}
|
||||
const trigger: DecisionNodeAutoTrigger = {
|
||||
id: asTrimmedString(raw.id) || `auto_${randomUUID()}`,
|
||||
type: type as DecisionNodeAutoTrigger['type'],
|
||||
}
|
||||
|
||||
if (type === 'expression') {
|
||||
const expression = asTrimmedString(raw.expression)
|
||||
if (!expression) {
|
||||
throw new Error('Expression trigger requires an expression')
|
||||
}
|
||||
trigger.expression = expression
|
||||
} else if (type === 'telemetry') {
|
||||
const parameter = asTrimmedString(raw.parameter)
|
||||
if (!parameter) {
|
||||
throw new Error('Telemetry trigger requires a parameter')
|
||||
}
|
||||
trigger.parameter = parameter as DecisionNodeAutoTrigger['parameter']
|
||||
const operator = asTrimmedString(raw.operator)
|
||||
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
|
||||
throw new Error('Telemetry trigger requires a valid operator')
|
||||
}
|
||||
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
|
||||
const value = raw.value !== undefined ? raw.value : undefined
|
||||
if (value === undefined) {
|
||||
throw new Error('Telemetry trigger requires a value')
|
||||
}
|
||||
const numericValue = asNumber(value)
|
||||
trigger.value = numericValue !== undefined ? numericValue : value
|
||||
const unit = asTrimmedString(raw.unit)
|
||||
if (unit) trigger.unit = unit
|
||||
} else if (type === 'variable') {
|
||||
const variable = asTrimmedString(raw.variable)
|
||||
if (!variable) {
|
||||
throw new Error('Variable trigger requires a variable path')
|
||||
}
|
||||
trigger.variable = variable
|
||||
const operator = asTrimmedString(raw.operator)
|
||||
if (!operator || !COMPARISON_OPERATORS.has(operator)) {
|
||||
throw new Error('Variable trigger requires a valid operator')
|
||||
}
|
||||
trigger.operator = operator as DecisionNodeAutoTrigger['operator']
|
||||
const value = raw.value !== undefined ? raw.value : undefined
|
||||
if (value === undefined) {
|
||||
throw new Error('Variable trigger requires a value')
|
||||
}
|
||||
const numericValue = asNumber(value)
|
||||
trigger.value = numericValue !== undefined ? numericValue : value
|
||||
}
|
||||
|
||||
if (raw.once !== undefined) {
|
||||
trigger.once = asBoolean(raw.once, true)
|
||||
}
|
||||
const delayMs = asNumber(raw.delayMs)
|
||||
if (typeof delayMs === 'number') trigger.delayMs = delayMs
|
||||
const description = asTrimmedString(raw.description)
|
||||
if (description) trigger.description = description
|
||||
return trigger
|
||||
}
|
||||
|
||||
export function sanitizeTransition(raw: any, index = 0): DecisionNodeTransition {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid transition payload')
|
||||
}
|
||||
const key = asTrimmedString(raw.key) || `tr_${randomUUID()}`
|
||||
const type = asTrimmedString(raw.type)
|
||||
const normalizedType = type && TRANSITION_TYPES.has(type) ? type : 'next'
|
||||
const target = asTrimmedString(raw.target)
|
||||
if (!target) {
|
||||
throw new Error('Transition target is required')
|
||||
}
|
||||
|
||||
const transition: DecisionNodeTransition = {
|
||||
key,
|
||||
type: normalizedType as DecisionNodeTransition['type'],
|
||||
target,
|
||||
order: typeof raw.order === 'number' ? raw.order : index,
|
||||
}
|
||||
|
||||
const label = asTrimmedString(raw.label)
|
||||
if (label) transition.label = label
|
||||
const description = asTrimmedString(raw.description)
|
||||
if (description) transition.description = description
|
||||
const condition = asTrimmedString(raw.condition)
|
||||
if (condition) transition.condition = condition
|
||||
const guard = asTrimmedString(raw.guard)
|
||||
if (guard) transition.guard = guard
|
||||
|
||||
if (raw.timer && typeof raw.timer === 'object') {
|
||||
const timerValue = asNumber(raw.timer.afterSeconds)
|
||||
if (typeof timerValue === 'number') {
|
||||
transition.timer = {
|
||||
afterSeconds: timerValue,
|
||||
allowManualProceed: asBoolean(raw.timer.allowManualProceed, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.autoTrigger) {
|
||||
transition.autoTrigger = sanitizeAutoTrigger(raw.autoTrigger)
|
||||
}
|
||||
|
||||
if (raw.metadata && typeof raw.metadata === 'object') {
|
||||
const color = asTrimmedString(raw.metadata.color)
|
||||
const icon = asTrimmedString(raw.metadata.icon)
|
||||
const notes = asTrimmedString(raw.metadata.notes)
|
||||
const previewTemplate = asTrimmedString(raw.metadata.previewTemplate)
|
||||
const metadata: DecisionNodeTransition['metadata'] = {}
|
||||
if (color) metadata.color = color
|
||||
if (icon) metadata.icon = icon
|
||||
if (notes) metadata.notes = notes
|
||||
if (previewTemplate) metadata.previewTemplate = previewTemplate
|
||||
if (Object.keys(metadata).length) {
|
||||
transition.metadata = metadata
|
||||
}
|
||||
}
|
||||
|
||||
return transition
|
||||
}
|
||||
Reference in New Issue
Block a user