robobyte-front-builder 1.0.21 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Box,
4
+ Button,
5
+ Chip,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogTitle,
10
+ Divider,
11
+ IconButton,
12
+ List,
13
+ ListItemButton,
14
+ ListItemText,
15
+ Stack,
16
+ TextField,
17
+ Tooltip,
18
+ Typography
19
+ } from '@mui/material'
20
+ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
21
+ import DownloadIcon from '@mui/icons-material/Download'
22
+ import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
23
+ import ModelTrainingIcon from '@mui/icons-material/ModelTraining'
24
+ import NoteAddOutlinedIcon from '@mui/icons-material/NoteAddOutlined'
25
+ import {
26
+ subscribe,
27
+ listEntries,
28
+ updateEntry,
29
+ deleteEntry,
30
+ exportJSON,
31
+ clearAll
32
+ } from 'services/sessionLog'
33
+ import { exportTrainingJSONL } from 'views/builder/sidebar/tabs/AiTab/trainingExport'
34
+ import { useBuilder } from 'context/BuilderContext'
35
+
36
+ // Print Builder schemas always carry these zone/page fields. UI Builder schemas
37
+ // don't. Used to auto-tag a snapshot with the right builder kind so we don't
38
+ // need to plumb a prop through every page that mounts BuilderProvider.
39
+ function detectBuilderKind(schema) {
40
+ if (!schema || typeof schema !== 'object') return 'ui'
41
+ if (schema.zones && schema.pageOrder) return 'print'
42
+ return 'ui'
43
+ }
44
+
45
+ function fmtDate(iso) {
46
+ try {
47
+ const d = new Date(iso)
48
+ return d.toLocaleString()
49
+ } catch {
50
+ return iso
51
+ }
52
+ }
53
+
54
+ function builderLabel(b) {
55
+ return b === 'print' ? 'Print' : 'UI'
56
+ }
57
+
58
+ export default function SessionLogDialog({ open, onClose }) {
59
+ const [entries, setEntries] = useState(() => listEntries())
60
+ const [selectedId, setSelectedId] = useState(null)
61
+
62
+ // Pull current schema + logger from the builder so we can snapshot without
63
+ // requiring a save round-trip.
64
+ const { schema, viewMetaData, logSchema } = useBuilder() ?? {}
65
+
66
+ // Re-fetch on every change to the underlying store
67
+ useEffect(() => {
68
+ const unsub = subscribe(() => setEntries(listEntries()))
69
+ // also refresh whenever the dialog opens (in case storage was edited
70
+ // by another tab while it was closed)
71
+ if (open) setEntries(listEntries())
72
+ return unsub
73
+ }, [open])
74
+
75
+ // Pick a sensible default selection
76
+ useEffect(() => {
77
+ if (!open) return
78
+ if (entries.length === 0) { setSelectedId(null); return }
79
+ if (!selectedId || !entries.some(e => e.id === selectedId)) {
80
+ setSelectedId(entries[0].id)
81
+ }
82
+ }, [open, entries, selectedId])
83
+
84
+ const selected = useMemo(
85
+ () => entries.find(e => e.id === selectedId) ?? null,
86
+ [entries, selectedId]
87
+ )
88
+
89
+ // Entries that have a non-empty prompt are eligible for fine-tune export.
90
+ const labeledCount = useMemo(
91
+ () => entries.filter(e => (e.prompt ?? '').trim().length > 0).length,
92
+ [entries]
93
+ )
94
+
95
+ const handleSnapshot = () => {
96
+ if (!schema || typeof logSchema !== 'function') return
97
+ const builderKind = detectBuilderKind(schema)
98
+
99
+ // In Print Builder, schema.root is just the active zone — fold it back
100
+ // into schema.zones so the snapshot matches what a real save would emit.
101
+ let snapshotSchema = schema
102
+ if (builderKind === 'print' && schema.activeZone && schema.root) {
103
+ snapshotSchema = {
104
+ ...schema,
105
+ zones: { ...(schema.zones ?? {}), [schema.activeZone]: schema.root }
106
+ }
107
+ }
108
+
109
+ const title =
110
+ (viewMetaData && viewMetaData.title) ||
111
+ `Snapshot ${new Date().toLocaleString()}`
112
+ const entry = logSchema({
113
+ builder: builderKind,
114
+ trigger: 'snapshot',
115
+ title,
116
+ viewMetaData: viewMetaData ?? {},
117
+ schema: snapshotSchema
118
+ })
119
+ if (entry) setSelectedId(entry.id)
120
+ }
121
+
122
+ const handleExportTraining = () => {
123
+ const { count, skipped } = exportTrainingJSONL(entries)
124
+ if (count === 0) {
125
+ window.alert('No labeled entries to export. Fill in the Prompt field on at least one entry first.')
126
+ return
127
+ }
128
+ const skipMsg = skipped.length > 0
129
+ ? `\n\nSkipped ${skipped.length} entr${skipped.length === 1 ? 'y' : 'ies'} (no prompt or unusable schema).`
130
+ : ''
131
+ window.alert(`Exported ${count} training example${count === 1 ? '' : 's'} as JSONL.${skipMsg}\n\nNext: upload to Together with the CLI:\n together files check <file>\n together files upload <file> --type jsonl --purpose fine-tune\n together fine-tuning create --training-file <id> --model <base> --lora\n\n(--type jsonl is required — Together rejects the upload otherwise.)`)
132
+ }
133
+
134
+ const handlePromptChange = (value) => {
135
+ if (!selected) return
136
+ updateEntry(selected.id, { prompt: value })
137
+ }
138
+
139
+ const handleNotesChange = (value) => {
140
+ if (!selected) return
141
+ updateEntry(selected.id, { notes: value })
142
+ }
143
+
144
+ const handleDelete = (id) => {
145
+ deleteEntry(id)
146
+ }
147
+
148
+ const handleClearAll = () => {
149
+ if (entries.length === 0) return
150
+ if (!window.confirm(`Delete all ${entries.length} logged schemas? This cannot be undone.`)) return
151
+ clearAll()
152
+ }
153
+
154
+ return (
155
+ <Dialog open={open} onClose={onClose} maxWidth='lg' fullWidth PaperProps={{ sx: { height: '85vh' } }}>
156
+ <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, pr: 1 }}>
157
+ <Box sx={{ flex: 1 }}>
158
+ Session Logs
159
+ <Typography variant='caption' sx={{ ml: 1, color: 'text.secondary' }}>
160
+ {entries.length} {entries.length === 1 ? 'entry' : 'entries'} · {labeledCount} labeled
161
+ </Typography>
162
+ </Box>
163
+ <Tooltip title='Snapshot the current builder schema (no save required)'>
164
+ <span>
165
+ <Button
166
+ size='small'
167
+ variant='outlined'
168
+ startIcon={<NoteAddOutlinedIcon fontSize='small' />}
169
+ onClick={handleSnapshot}
170
+ disabled={!schema || typeof logSchema !== 'function'}
171
+ sx={{ mr: 1 }}
172
+ >
173
+ Snapshot
174
+ </Button>
175
+ </span>
176
+ </Tooltip>
177
+ <Tooltip title={
178
+ labeledCount === 0
179
+ ? 'Fill in a Prompt on at least one entry to enable Together fine-tune export'
180
+ : `Export ${labeledCount} labeled example${labeledCount === 1 ? '' : 's'} as Together fine-tune JSONL`
181
+ }>
182
+ <span>
183
+ <IconButton
184
+ size='small'
185
+ onClick={handleExportTraining}
186
+ disabled={labeledCount === 0}
187
+ color='primary'
188
+ >
189
+ <ModelTrainingIcon fontSize='small' />
190
+ </IconButton>
191
+ </span>
192
+ </Tooltip>
193
+ <Tooltip title='Export all as JSON (raw backup)'>
194
+ <span>
195
+ <IconButton size='small' onClick={exportJSON} disabled={entries.length === 0}>
196
+ <DownloadIcon fontSize='small' />
197
+ </IconButton>
198
+ </span>
199
+ </Tooltip>
200
+ <Tooltip title='Delete all'>
201
+ <span>
202
+ <IconButton size='small' color='error' onClick={handleClearAll} disabled={entries.length === 0}>
203
+ <DeleteSweepIcon fontSize='small' />
204
+ </IconButton>
205
+ </span>
206
+ </Tooltip>
207
+ </DialogTitle>
208
+
209
+ <Divider />
210
+
211
+ <DialogContent sx={{ p: 0, display: 'flex', minHeight: 0 }}>
212
+ {/* ── Left: entries list ─────────────────────────────────────────── */}
213
+ <Box sx={{ width: 320, borderRight: '1px solid', borderColor: 'divider', overflow: 'auto' }}>
214
+ {entries.length === 0 ? (
215
+ <Box sx={{ p: 3, color: 'text.secondary', fontSize: 13 }}>
216
+ No entries yet. Click <strong>Snapshot</strong> to capture the current builder schema, or save a view — both are auto-logged.
217
+ </Box>
218
+ ) : (
219
+ <List dense disablePadding>
220
+ {entries.map(entry => (
221
+ <ListItemButton
222
+ key={entry.id}
223
+ selected={entry.id === selectedId}
224
+ onClick={() => setSelectedId(entry.id)}
225
+ sx={{ alignItems: 'flex-start', py: 1.25, pr: 1 }}
226
+ >
227
+ <ListItemText
228
+ primary={
229
+ <Stack direction='row' spacing={1} alignItems='center'>
230
+ <Chip
231
+ label={builderLabel(entry.builder)}
232
+ size='small'
233
+ color={entry.builder === 'print' ? 'secondary' : 'primary'}
234
+ sx={{ height: 18, fontSize: 10 }}
235
+ />
236
+ <Typography variant='body2' noWrap sx={{ flex: 1 }}>
237
+ {entry.title || '(untitled)'}
238
+ </Typography>
239
+ </Stack>
240
+ }
241
+ secondary={
242
+ <Stack direction='row' justifyContent='space-between' alignItems='center'>
243
+ <Typography variant='caption' color='text.secondary'>
244
+ {fmtDate(entry.createdAt)}
245
+ </Typography>
246
+ {entry.prompt
247
+ ? <Chip label='labeled' size='small' color='success' sx={{ height: 16, fontSize: 9 }} />
248
+ : <Chip label='no prompt' size='small' variant='outlined' sx={{ height: 16, fontSize: 9 }} />}
249
+ </Stack>
250
+ }
251
+ secondaryTypographyProps={{ component: 'div' }}
252
+ />
253
+ <IconButton
254
+ size='small'
255
+ edge='end'
256
+ onClick={(e) => { e.stopPropagation(); handleDelete(entry.id) }}
257
+ sx={{ ml: 0.5 }}
258
+ >
259
+ <DeleteOutlineIcon fontSize='small' />
260
+ </IconButton>
261
+ </ListItemButton>
262
+ ))}
263
+ </List>
264
+ )}
265
+ </Box>
266
+
267
+ {/* ── Right: detail / editor ─────────────────────────────────────── */}
268
+ <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
269
+ {!selected ? (
270
+ <Box sx={{ p: 3, color: 'text.secondary', fontSize: 13 }}>
271
+ Select an entry on the left.
272
+ </Box>
273
+ ) : (
274
+ <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
275
+ {/* Header row */}
276
+ <Box sx={{ p: 2, pb: 1 }}>
277
+ <Stack direction='row' spacing={1} alignItems='center' sx={{ mb: 1 }}>
278
+ <Chip
279
+ label={builderLabel(selected.builder)}
280
+ size='small'
281
+ color={selected.builder === 'print' ? 'secondary' : 'primary'}
282
+ />
283
+ <Typography variant='subtitle2'>{selected.title || '(untitled)'}</Typography>
284
+ <Box sx={{ flex: 1 }} />
285
+ <Typography variant='caption' color='text.secondary'>
286
+ {fmtDate(selected.createdAt)}
287
+ </Typography>
288
+ </Stack>
289
+
290
+ <TextField
291
+ fullWidth
292
+ size='small'
293
+ multiline
294
+ minRows={2}
295
+ maxRows={4}
296
+ label='Prompt (training instruction)'
297
+ placeholder='What natural-language instruction would produce this schema? (Filled later — leave empty for now if you prefer.)'
298
+ value={selected.prompt ?? ''}
299
+ onChange={e => handlePromptChange(e.target.value)}
300
+ sx={{ mb: 1 }}
301
+ />
302
+
303
+ <TextField
304
+ fullWidth
305
+ size='small'
306
+ label='Notes (optional)'
307
+ placeholder='Internal notes — not part of the prompt'
308
+ value={selected.notes ?? ''}
309
+ onChange={e => handleNotesChange(e.target.value)}
310
+ />
311
+ </Box>
312
+
313
+ <Divider />
314
+
315
+ {/* Schema preview */}
316
+ <Box sx={{ flex: 1, overflow: 'auto', p: 2, bgcolor: 'background.default' }}>
317
+ <Typography variant='caption' color='text.secondary' sx={{ display: 'block', mb: 1 }}>
318
+ Schema
319
+ </Typography>
320
+ <Box
321
+ component='pre'
322
+ sx={{
323
+ m: 0,
324
+ p: 1.5,
325
+ fontFamily: 'monospace',
326
+ fontSize: 12,
327
+ lineHeight: 1.5,
328
+ bgcolor: 'background.paper',
329
+ border: '1px solid',
330
+ borderColor: 'divider',
331
+ borderRadius: 1,
332
+ overflow: 'auto',
333
+ whiteSpace: 'pre',
334
+ maxHeight: '100%'
335
+ }}
336
+ >
337
+ {JSON.stringify(selected.schema, null, 2)}
338
+ </Box>
339
+ </Box>
340
+ </Box>
341
+ )}
342
+ </Box>
343
+ </DialogContent>
344
+
345
+ <DialogActions>
346
+ <Button onClick={onClose}>Close</Button>
347
+ </DialogActions>
348
+ </Dialog>
349
+ )
350
+ }
@@ -0,0 +1,103 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useRouter } from 'next/router'
3
+ import {
4
+ Button,
5
+ Dialog,
6
+ DialogActions,
7
+ DialogContent,
8
+ DialogContentText,
9
+ DialogTitle
10
+ } from '@mui/material'
11
+ import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'
12
+
13
+ /**
14
+ * Renders nothing visible until the user tries to leave the page (tab close,
15
+ * refresh, browser back, or in-app navigation) while `isDirty` is true.
16
+ *
17
+ * - Tab close / refresh → browser-native confirm (browsers do not allow
18
+ * custom UI here; only `beforeunload` is honored)
19
+ * - In-app navigation → custom MUI dialog (Stay / Leave)
20
+ * - Browser back/forward → handled by Next.js routeChangeStart, so the
21
+ * same custom dialog applies as long as the
22
+ * target stays inside the app
23
+ */
24
+ export default function UnsavedChangesGuard({
25
+ isDirty,
26
+ title = 'Unsaved changes',
27
+ message = 'You have unsaved changes. If you leave this page now, your changes will be lost.',
28
+ stayLabel = 'Stay on page',
29
+ leaveLabel = 'Leave anyway'
30
+ }) {
31
+ const router = useRouter()
32
+ const [pending, setPending] = useState(null) // { url, options }
33
+ // Set just before we re-fire a confirmed navigation so the routeChangeStart
34
+ // handler lets it through.
35
+ const allowNavigationRef = useRef(false)
36
+
37
+ // ── Tab close / refresh ─────────────────────────────────────────────────
38
+ useEffect(() => {
39
+ if (!isDirty) return
40
+ const handler = e => {
41
+ if (allowNavigationRef.current) return
42
+ e.preventDefault()
43
+ // Modern browsers ignore the message and show their own copy, but
44
+ // returnValue must be set for the prompt to appear at all.
45
+ e.returnValue = message
46
+ return message
47
+ }
48
+ window.addEventListener('beforeunload', handler)
49
+ return () => window.removeEventListener('beforeunload', handler)
50
+ }, [isDirty, message])
51
+
52
+ // ── In-app navigation (router push/replace, browser back) ──────────────
53
+ useEffect(() => {
54
+ if (!isDirty) return
55
+ const handler = (url, options) => {
56
+ if (allowNavigationRef.current) return
57
+ // Same-URL changes (e.g. shallow query swaps after save) are not real
58
+ // navigations from the user's POV; let them through.
59
+ if (url === router.asPath) return
60
+
61
+ setPending({ url, options })
62
+ // Documented Next.js pattern for cancelling a route change.
63
+ router.events.emit('routeChangeError')
64
+ // eslint-disable-next-line no-throw-literal
65
+ throw 'Route change aborted by UnsavedChangesGuard.'
66
+ }
67
+ router.events.on('routeChangeStart', handler)
68
+ return () => router.events.off('routeChangeStart', handler)
69
+ }, [isDirty, router])
70
+
71
+ const handleStay = () => setPending(null)
72
+
73
+ const handleLeave = () => {
74
+ if (!pending) return
75
+ const { url, options } = pending
76
+ allowNavigationRef.current = true
77
+ setPending(null)
78
+ // Re-fire the navigation; allowNavigationRef short-circuits the guard.
79
+ router.push(url, undefined, options).finally(() => {
80
+ allowNavigationRef.current = false
81
+ })
82
+ }
83
+
84
+ return (
85
+ <Dialog open={!!pending} onClose={handleStay} maxWidth='xs' fullWidth>
86
+ <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
87
+ <WarningAmberRoundedIcon color='warning' fontSize='small' />
88
+ {title}
89
+ </DialogTitle>
90
+ <DialogContent>
91
+ <DialogContentText>{message}</DialogContentText>
92
+ </DialogContent>
93
+ <DialogActions sx={{ px: 3, pb: 2 }}>
94
+ <Button onClick={handleStay} autoFocus variant='contained' color='primary'>
95
+ {stayLabel}
96
+ </Button>
97
+ <Button onClick={handleLeave} color='error'>
98
+ {leaveLabel}
99
+ </Button>
100
+ </DialogActions>
101
+ </Dialog>
102
+ )
103
+ }
@@ -3,6 +3,12 @@ export const REPORT_VIEWER_MAIN_FIELDS = [
3
3
  { name: 'id', label: 'Report ID', type: 'expression' },
4
4
  { name: 'pageId', label: 'Page ID', type: 'expression' },
5
5
  { name: 'filter', label: 'Filter', type: 'expression' },
6
+
7
+ // Presentation — title/caption render above the toolbar in the viewer.
8
+ // Both accept expressions so they can be data-bound (e.g. `${data.month} report`).
9
+ { name: 'title', label: 'Title', type: 'expression' },
10
+ { name: 'caption', label: 'Caption', type: 'expression' },
11
+
6
12
  { name: 'minimized', label: 'Minimized', type: 'boolean' },
7
13
  { name: 'isRerender', label: 'Is Rerender', type: 'boolean' },
8
14
  { name: 'height', label: 'Height', type: 'expression' },
@@ -23,4 +29,11 @@ export const REPORT_VIEWER_MAIN_FIELDS = [
23
29
  // Shape: [{ label, color, variant, icon, code, confirmation?, disabled? }]
24
30
  // Inside each Calculation, data.row is the current AG Grid row object.
25
31
  { name: 'actionsConfig', label: 'Row Actions', type: 'actions-config-editor' },
32
+
33
+ // Viewer actions: buttons rendered in the toolbar after the Filter / Refresh
34
+ // buttons. Same shape as Row Actions, except each Calculation runs WITHOUT a
35
+ // row context — they're page-level actions for the whole report (e.g.
36
+ // "Export PDF", "Open New Tab", "Reset filters", etc.).
37
+ // Shape: [{ label, color, variant, icon, code, confirmation?, disabled? }]
38
+ { name: 'viewerActions', label: 'Viewer Actions', type: 'actions-config-editor' },
26
39
  ]
@@ -5,18 +5,22 @@
5
5
  * directly — this keeps API keys server-side and avoids CORS restrictions.
6
6
  *
7
7
  * Configuration (.env.local):
8
- * NEXT_PUBLIC_AI_PROVIDER claude | openai (default: claude)
8
+ * NEXT_PUBLIC_AI_PROVIDER claude | openai | together (default: claude)
9
9
  * CLAUDE_API_KEY Anthropic API key ← no NEXT_PUBLIC_ prefix
10
10
  * NEXT_PUBLIC_CLAUDE_MODEL e.g. claude-sonnet-4-6
11
11
  * OPENAI_API_KEY OpenAI API key ← no NEXT_PUBLIC_ prefix
12
12
  * NEXT_PUBLIC_OPENAI_MODEL e.g. gpt-4o
13
+ * TOGETHER_API_KEY Together AI key ← no NEXT_PUBLIC_ prefix
14
+ * NEXT_PUBLIC_TOGETHER_MODEL e.g. meta-llama/Llama-3.3-70B-Instruct-Turbo
15
+ * or your fine-tuned model id
13
16
  */
14
17
 
15
18
  const PROVIDER_ID = process.env.NEXT_PUBLIC_AI_PROVIDER ?? 'claude'
16
19
 
17
20
  const MODEL_LABELS = {
18
- claude: () => process.env.NEXT_PUBLIC_CLAUDE_MODEL ?? 'claude-sonnet-4-6',
19
- openai: () => process.env.NEXT_PUBLIC_OPENAI_MODEL ?? 'gpt-4o',
21
+ claude: () => process.env.NEXT_PUBLIC_CLAUDE_MODEL ?? 'claude-sonnet-4-6',
22
+ openai: () => process.env.NEXT_PUBLIC_OPENAI_MODEL ?? 'gpt-4o',
23
+ together: () => process.env.NEXT_PUBLIC_TOGETHER_MODEL ?? 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
20
24
  }
21
25
 
22
26
  /**
@@ -71,6 +71,71 @@ export function transformAiSchema(aiSchema) {
71
71
  return { root, dialogs: [], actions: [], timers: [] }
72
72
  }
73
73
 
74
+ // ── Reverse: full builder schema → simplified AI schema ──────────────────────
75
+ //
76
+ // The session log stores the full builder schema (with `id`, `valueType`
77
+ // wrappers, and the main/style/advanced prop groups). Training data must be
78
+ // in the simplified format that the AI is expected to emit, otherwise we'd
79
+ // be teaching the model the wrong shape. This is the inverse of
80
+ // transformAiSchema(). It is lossy by design:
81
+ //
82
+ // - `id` fields are dropped (regenerated on import)
83
+ // - `valueType: 'value'` props are unwrapped to bare values
84
+ // - Non-value props (expressions, references) are dropped — the
85
+ // simplified format the AI is trained on cannot represent them.
86
+ // - The `advanced` group is dropped (the AI never produces it).
87
+ //
88
+ // Returns the simplified node, or null if the node is unusable.
89
+
90
+ function unwrapGroup(group = {}) {
91
+ const out = {}
92
+ for (const [k, v] of Object.entries(group)) {
93
+ if (v == null) continue
94
+ if (typeof v === 'object' && 'valueType' in v) {
95
+ // Only the plain `value` form is representable in the simplified format.
96
+ if (v.valueType === 'value' && v.value !== undefined) {
97
+ out[k] = v.value
98
+ }
99
+ // Drop expression / function / reference / other variants.
100
+ } else {
101
+ // Already a plain value (legacy schemas may have unwrapped props).
102
+ out[k] = v
103
+ }
104
+ }
105
+ return out
106
+ }
107
+
108
+ export function simplifyFullNode(node) {
109
+ if (!node || typeof node !== 'object' || !node.type) return null
110
+ const propsGroup = node.props ?? {}
111
+ const main = unwrapGroup(propsGroup.main ?? {})
112
+ const style = unwrapGroup(propsGroup.style ?? {})
113
+ // advanced is intentionally dropped.
114
+
115
+ const simplified = {
116
+ type: node.type,
117
+ props: main,
118
+ children: Array.isArray(node.children)
119
+ ? node.children.map(simplifyFullNode).filter(Boolean)
120
+ : [],
121
+ }
122
+ if (Object.keys(style).length > 0) simplified.style = style
123
+ return simplified
124
+ }
125
+
126
+ /**
127
+ * Convert a full builder schema (the shape stored in the session log) into
128
+ * the simplified shape used by training data and the AI's expected output.
129
+ *
130
+ * Accepts either { root, dialogs?, ... } or a bare root node.
131
+ * Returns { root: <simplified> }.
132
+ */
133
+ export function simplifyFullSchema(full) {
134
+ if (!full || typeof full !== 'object') return { root: null }
135
+ const rootNode = full.root ?? full
136
+ return { root: simplifyFullNode(rootNode) }
137
+ }
138
+
74
139
  // ── Response parser ───────────────────────────────────────────────────────────
75
140
 
76
141
  /**