mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Build decision flow editor and runtime integration
This commit is contained in:
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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user