robobyte-front-builder 1.0.21 → 1.0.23
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.
- package/INTEGRATION.md +1586 -0
- package/README.md +791 -74
- package/RoboByteBuilder_User_Manual.docx +0 -0
- package/package.json +4 -2
- package/src/context/BuilderContext.jsx +63 -7
- package/src/pages/api/ai.js +20 -0
- package/src/pages/printBuilder/index.jsx +3 -3
- package/src/pages/reportModule/reportBuilder/reportViewer/index.js +59 -3
- package/src/pages/viewBuilder/index.jsx +2 -2
- package/src/services/Endpoints/ReportBuilderEndpoints.js +7 -7
- package/src/services/sessionLog.js +171 -0
- package/src/views/builder/SessionLogDialog.jsx +350 -0
- package/src/views/builder/UnsavedChangesGuard.jsx +103 -0
- package/src/views/builder/inspector/definitions/reportViewer/main.js +13 -0
- package/src/views/builder/sidebar/tabs/AiTab/aiProvider.js +7 -3
- package/src/views/builder/sidebar/tabs/AiTab/schemaTransformer.js +65 -0
- package/src/views/builder/sidebar/tabs/AiTab/trainingExport.js +131 -0
- package/src/views/builder/sidebar/tabs/ViewTab.jsx +173 -13
- package/src/views/builder/viewer/ViewerComponentWrapper.jsx +9 -5
- package/src/views/builder/viewer/ViewerToolbar.jsx +18 -3
- package/src/views/builder/viewer/renderers/ReportViewerRenderer.jsx +46 -2
- package/src/views/genericTable/SGrid.js +656 -384
- package/src/views/printBuilder/PrintBuilderViewer.jsx +22 -2
|
@@ -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:
|
|
19
|
-
openai:
|
|
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
|
/**
|