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
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robobyte-front-builder",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.23",
|
|
4
4
|
"description": "RoboByte low-code UI builder, Report builder, and navigation extension system",
|
|
5
5
|
"main": "src/lib/index.js",
|
|
6
6
|
"files": [
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"public",
|
|
9
9
|
"styles",
|
|
10
10
|
"next.config.js",
|
|
11
|
-
"jsconfig.json"
|
|
11
|
+
"jsconfig.json",
|
|
12
|
+
"INTEGRATION.md",
|
|
13
|
+
"RoboByteBuilder_User_Manual.docx"
|
|
12
14
|
],
|
|
13
15
|
"exports": {
|
|
14
16
|
".": "./src/lib/index.js",
|
|
@@ -2,6 +2,9 @@ import { createContext, useCallback, useContext, useEffect, useReducer, useRef,
|
|
|
2
2
|
import { executeActionsByEvent } from 'services/builderHelper/actionExecutor'
|
|
3
3
|
import { findNode } from 'services/builderHelper'
|
|
4
4
|
import { executeJSCode } from 'services/builderHelper/jsExecutor'
|
|
5
|
+
import UnsavedChangesGuard from 'views/builder/UnsavedChangesGuard'
|
|
6
|
+
import SessionLogDialog from 'views/builder/SessionLogDialog'
|
|
7
|
+
import { logSchema as logSchemaToStore } from 'services/sessionLog'
|
|
5
8
|
|
|
6
9
|
const BuilderContext = createContext(null)
|
|
7
10
|
|
|
@@ -136,6 +139,16 @@ function historyReducer(state, action) {
|
|
|
136
139
|
future: state.future.slice(1)
|
|
137
140
|
}
|
|
138
141
|
}
|
|
142
|
+
// LOAD — replace the schema without marking it as a user edit (e.g. fetched
|
|
143
|
+
// from server). Clears history so the freshly loaded state is the new baseline.
|
|
144
|
+
case 'LOAD': {
|
|
145
|
+
return { past: [], present: action.payload, future: [] }
|
|
146
|
+
}
|
|
147
|
+
// MARK_CLEAN — keep the present schema but reset history so isDirty becomes
|
|
148
|
+
// false. Used after a successful save.
|
|
149
|
+
case 'MARK_CLEAN': {
|
|
150
|
+
return { past: [], present: state.present, future: [] }
|
|
151
|
+
}
|
|
139
152
|
default:
|
|
140
153
|
return state
|
|
141
154
|
}
|
|
@@ -150,12 +163,17 @@ export function BuilderProvider({ children }) {
|
|
|
150
163
|
|
|
151
164
|
// Convenience aliases — schema reads the present snapshot;
|
|
152
165
|
// setSchema dispatches a SET action (automatically tracked in history).
|
|
153
|
-
const schema
|
|
154
|
-
const setSchema
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const
|
|
166
|
+
const schema = historyState.present
|
|
167
|
+
const setSchema = useCallback((updater) => dispatch({ type: 'SET', payload: updater }), [])
|
|
168
|
+
const loadSchema = useCallback((next) => dispatch({ type: 'LOAD', payload: next }), [])
|
|
169
|
+
const markClean = useCallback(() => dispatch({ type: 'MARK_CLEAN' }), [])
|
|
170
|
+
const undo = useCallback(() => dispatch({ type: 'UNDO' }), [])
|
|
171
|
+
const redo = useCallback(() => dispatch({ type: 'REDO' }), [])
|
|
172
|
+
const canUndo = historyState.past.length > 0
|
|
173
|
+
const canRedo = historyState.future.length > 0
|
|
174
|
+
// isDirty — present schema differs from the last loaded/saved baseline.
|
|
175
|
+
// Equivalent to canUndo because LOAD and MARK_CLEAN both reset past to [].
|
|
176
|
+
const isDirty = canUndo
|
|
159
177
|
const [selectedId, setSelectedId] = useState(null)
|
|
160
178
|
const [viewMetaData, setViewMetaData] = useState({
|
|
161
179
|
id: null,
|
|
@@ -181,6 +199,30 @@ export function BuilderProvider({ children }) {
|
|
|
181
199
|
const [draggingNodeId, setDraggingNodeId] = useState(null)
|
|
182
200
|
const [clipboard, setClipboard] = useState(null) // copy/paste clipboard
|
|
183
201
|
|
|
202
|
+
// ── Session log dialog (training-data capture) ───────────────────────────
|
|
203
|
+
const [sessionLogOpen, setSessionLogOpen] = useState(false)
|
|
204
|
+
const openSessionLog = useCallback(() => setSessionLogOpen(true), [])
|
|
205
|
+
const closeSessionLog = useCallback(() => setSessionLogOpen(false), [])
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Append a schema snapshot to the session log. Call this from save handlers
|
|
209
|
+
* so every successful save becomes a labelable training example.
|
|
210
|
+
* `prompt` is intentionally left empty — developers fill it in later via
|
|
211
|
+
* the Session Logs dialog.
|
|
212
|
+
*/
|
|
213
|
+
const logSchema = useCallback(({ builder, trigger = 'save', title = '', viewMetaData = {}, schema: schemaArg }) => {
|
|
214
|
+
return logSchemaToStore({
|
|
215
|
+
builder,
|
|
216
|
+
trigger,
|
|
217
|
+
title,
|
|
218
|
+
viewMetaData,
|
|
219
|
+
schema: schemaArg ?? schema,
|
|
220
|
+
prompt: ''
|
|
221
|
+
})
|
|
222
|
+
// schema is captured at call time when omitted; keep deps minimal
|
|
223
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
224
|
+
}, [schema])
|
|
225
|
+
|
|
184
226
|
// ── Unified ref storage ────────────────────────────────────────────────────
|
|
185
227
|
// dataRef : mutable non-reactive store; read/write from any function
|
|
186
228
|
// without triggering re-renders. Access via dataRef.current.
|
|
@@ -273,6 +315,9 @@ export function BuilderProvider({ children }) {
|
|
|
273
315
|
value={{
|
|
274
316
|
schema,
|
|
275
317
|
setSchema,
|
|
318
|
+
loadSchema,
|
|
319
|
+
markClean,
|
|
320
|
+
isDirty,
|
|
276
321
|
|
|
277
322
|
// ── Undo / redo ──────────────────────────────────────────────────────
|
|
278
323
|
undo,
|
|
@@ -326,9 +371,20 @@ export function BuilderProvider({ children }) {
|
|
|
326
371
|
validationErrors,
|
|
327
372
|
setValidationErrors,
|
|
328
373
|
submitForm,
|
|
329
|
-
resetForm
|
|
374
|
+
resetForm,
|
|
375
|
+
|
|
376
|
+
// ── Session log (AI training-data capture) ───────────────────────────
|
|
377
|
+
// logSchema({ builder, trigger?, title?, viewMetaData?, schema? })
|
|
378
|
+
// appends an entry to localStorage. Schema defaults to the current
|
|
379
|
+
// builder schema if not provided.
|
|
380
|
+
// openSessionLog / closeSessionLog control the review dialog.
|
|
381
|
+
logSchema,
|
|
382
|
+
openSessionLog,
|
|
383
|
+
closeSessionLog
|
|
330
384
|
}}
|
|
331
385
|
>
|
|
386
|
+
<UnsavedChangesGuard isDirty={isDirty} />
|
|
387
|
+
<SessionLogDialog open={sessionLogOpen} onClose={closeSessionLog} />
|
|
332
388
|
{children}
|
|
333
389
|
</BuilderContext.Provider>
|
|
334
390
|
)
|
package/src/pages/api/ai.js
CHANGED
|
@@ -42,6 +42,26 @@ const PROVIDERS = {
|
|
|
42
42
|
}),
|
|
43
43
|
extract: (data) => data?.choices?.[0]?.message?.content ?? '',
|
|
44
44
|
},
|
|
45
|
+
|
|
46
|
+
// Together AI — OpenAI-compatible API. Set TOGETHER_API_KEY and
|
|
47
|
+
// (optionally) NEXT_PUBLIC_TOGETHER_MODEL — point this at the base
|
|
48
|
+
// model for testing, or at your fine-tuned model id once a fine-tune
|
|
49
|
+
// job has finished (e.g. "username/llama-3.3-70b-rbb-ft-2026-05-10").
|
|
50
|
+
together: {
|
|
51
|
+
url: 'https://api.together.xyz/v1/chat/completions',
|
|
52
|
+
apiKey: () => process.env.TOGETHER_API_KEY ?? '',
|
|
53
|
+
model: () => process.env.NEXT_PUBLIC_TOGETHER_MODEL ?? 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
|
|
54
|
+
headers: (key) => ({
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
Authorization: `Bearer ${key}`,
|
|
57
|
+
}),
|
|
58
|
+
body: (model, messages, system) => ({
|
|
59
|
+
model,
|
|
60
|
+
max_tokens: 8096,
|
|
61
|
+
messages: [{ role: 'system', content: system }, ...messages],
|
|
62
|
+
}),
|
|
63
|
+
extract: (data) => data?.choices?.[0]?.message?.content ?? '',
|
|
64
|
+
},
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
export default async function handler(req, res) {
|
|
@@ -147,7 +147,7 @@ function PrintSidebar({ collapsed }) {
|
|
|
147
147
|
function PrintBuilderContent({ isDark, onToggleTheme }) {
|
|
148
148
|
const router = useRouter()
|
|
149
149
|
const { id } = router.query
|
|
150
|
-
const {
|
|
150
|
+
const { loadSchema } = useBuilder()
|
|
151
151
|
|
|
152
152
|
const [metaData, setMetaData] = useState({ id: null, title: '' })
|
|
153
153
|
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
|
@@ -166,7 +166,7 @@ function PrintBuilderContent({ isDark, onToggleTheme }) {
|
|
|
166
166
|
// Ensure root reflects activeZone
|
|
167
167
|
const activeZone = merged.activeZone ?? INITIAL_PRINT_SCHEMA.activeZone
|
|
168
168
|
merged.root = merged.zones?.[activeZone] ?? createEmptyNode(activeZone)
|
|
169
|
-
|
|
169
|
+
loadSchema(merged)
|
|
170
170
|
setMetaData({ id: response.data.id, title: response.data.title })
|
|
171
171
|
}
|
|
172
172
|
} catch (err) {
|
|
@@ -178,7 +178,7 @@ function PrintBuilderContent({ isDark, onToggleTheme }) {
|
|
|
178
178
|
if (id) {
|
|
179
179
|
handleLoad(id)
|
|
180
180
|
} else {
|
|
181
|
-
|
|
181
|
+
loadSchema(INITIAL_PRINT_SCHEMA)
|
|
182
182
|
}
|
|
183
183
|
}, [id])
|
|
184
184
|
|
|
@@ -8,7 +8,7 @@ import CardHeader from "@mui/material/CardHeader";
|
|
|
8
8
|
import Grid from "@mui/material/Grid";
|
|
9
9
|
import {Plus} from "mdi-material-ui";
|
|
10
10
|
import TAGGrid from "views/genericTable/TAGGrid";
|
|
11
|
-
import {SettingsOutlined} from "@mui/icons-material";
|
|
11
|
+
import {SettingsOutlined, AssessmentOutlined} from "@mui/icons-material";
|
|
12
12
|
import UpdateReportPermissionDialog from "views/rolePermissions/UpdateReportPermissionDialog";
|
|
13
13
|
import {getReportSession} from 'src/services/helper/reportSessionHelper'
|
|
14
14
|
import BlankLayout from '../../../../lib/layouts/BlankLayout';
|
|
@@ -41,6 +41,13 @@ const ReportViewer = (params) => {
|
|
|
41
41
|
globalParams,
|
|
42
42
|
isRerender = false,
|
|
43
43
|
updateRef,
|
|
44
|
+
// Presentation — render above the toolbar. If title is omitted, the
|
|
45
|
+
// report's own name from builderMetadata.name is used.
|
|
46
|
+
title,
|
|
47
|
+
caption,
|
|
48
|
+
// Page-level toolbar buttons rendered after Filter / Refresh.
|
|
49
|
+
// Built by ReportViewerRenderer with bound onClick handlers.
|
|
50
|
+
viewerActions,
|
|
44
51
|
// nodeId / reportRefs are injected by the UI builder's ReportViewerRenderer.
|
|
45
52
|
// nodeId — the builder schema node ID for this ReportViewer instance
|
|
46
53
|
// reportRefs — the shared registry { [nodeId]: updateRef } for all reports on the page
|
|
@@ -187,14 +194,56 @@ const ReportViewer = (params) => {
|
|
|
187
194
|
<CircularProgress/>
|
|
188
195
|
</Box>
|
|
189
196
|
) : !builderModel ? (
|
|
190
|
-
|
|
191
|
-
|
|
197
|
+
// ── Empty state ─────────────────────────────────────────────────────
|
|
198
|
+
// Shown when no `id` / `pageId` has resolved yet. In the standalone
|
|
199
|
+
// /report/viewer page this is the persistent "pick a report" state;
|
|
200
|
+
// inside the builder it usually flashes for one render before the
|
|
201
|
+
// model loads.
|
|
202
|
+
<Box
|
|
203
|
+
sx={{
|
|
204
|
+
minHeight: minimized ? 'auto' : '60vh',
|
|
205
|
+
display: 'flex',
|
|
206
|
+
flexDirection: 'column',
|
|
207
|
+
alignItems: 'center',
|
|
208
|
+
justifyContent: 'center',
|
|
209
|
+
gap: 1.5,
|
|
210
|
+
p: 4,
|
|
211
|
+
color: 'text.secondary',
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<Box
|
|
215
|
+
sx={{
|
|
216
|
+
width: 72,
|
|
217
|
+
height: 72,
|
|
218
|
+
borderRadius: '50%',
|
|
219
|
+
display: 'flex',
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
justifyContent: 'center',
|
|
222
|
+
bgcolor: 'action.hover',
|
|
223
|
+
color: 'text.disabled',
|
|
224
|
+
mb: 0.5,
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<AssessmentOutlined sx={{ fontSize: 36 }} />
|
|
228
|
+
</Box>
|
|
229
|
+
<Typography variant='h6' sx={{ fontWeight: 600, color: 'text.primary', textAlign: 'center' }}>
|
|
192
230
|
No report selected
|
|
193
231
|
</Typography>
|
|
232
|
+
<Typography variant='body2' sx={{ color: 'text.secondary', textAlign: 'center', maxWidth: 380 }}>
|
|
233
|
+
Pick a report from the list, or pass a <code>pageId</code> / <code>id</code> to load one here.
|
|
234
|
+
</Typography>
|
|
194
235
|
</Box>
|
|
195
236
|
) : (
|
|
196
237
|
<>
|
|
197
238
|
<Box display="flex" gap={1} flexDirection={'column'} sx={{ width: '100%' }}>
|
|
239
|
+
{/*
|
|
240
|
+
Studio header — only shown on the standalone /report/viewer page
|
|
241
|
+
(noHeader is set by the builder when ReportViewer is embedded as
|
|
242
|
+
a component, which suppresses this row entirely). The title and
|
|
243
|
+
caption are now rendered inside SGrid's toolbar on the same row
|
|
244
|
+
as Filter / Refresh / viewer actions, so this header is just the
|
|
245
|
+
"go to studio" affordance for the standalone page.
|
|
246
|
+
*/}
|
|
198
247
|
{(minimized !== true && noHeader !== true) &&
|
|
199
248
|
<Box display="flex" justifyContent={'space-between'} alignItems={'center'} gap={1} sx={{p: 2}}>
|
|
200
249
|
<Typography variant="subtitle1" color="text.secondary">{builderMetadata?.name ?? ""}</Typography>
|
|
@@ -209,6 +258,7 @@ const ReportViewer = (params) => {
|
|
|
209
258
|
</IconButton>
|
|
210
259
|
</Tooltip>
|
|
211
260
|
</Box>}
|
|
261
|
+
|
|
212
262
|
<Box sx={{width: '100%'}}>
|
|
213
263
|
<SGrid
|
|
214
264
|
key={`rv-${payloadVersion}`}
|
|
@@ -220,6 +270,12 @@ const ReportViewer = (params) => {
|
|
|
220
270
|
setOutGridApi={setOutGridApi}
|
|
221
271
|
externalTimer={externalTimer}
|
|
222
272
|
actions={actions}
|
|
273
|
+
viewerActions={viewerActions}
|
|
274
|
+
// Toolbar left side. Fall back to the report's own name from
|
|
275
|
+
// builderMetadata so standalone-page views still get a sensible
|
|
276
|
+
// title without explicit configuration.
|
|
277
|
+
title={title ?? builderMetadata?.name}
|
|
278
|
+
caption={caption}
|
|
223
279
|
reportTitle={builderMetadata?.name}
|
|
224
280
|
filter={sessionPayload?.externalFilter ?? filter}
|
|
225
281
|
columnsConfig={columnsConfig ?? []}
|
|
@@ -78,7 +78,7 @@ function CollapseToggle({ collapsed, onClick, side }) {
|
|
|
78
78
|
function ViewBuilderContent({ isDark, onToggleTheme }) {
|
|
79
79
|
const router = useRouter()
|
|
80
80
|
const { id } = router.query
|
|
81
|
-
const {
|
|
81
|
+
const { loadSchema, setViewMetaData } = useBuilder()
|
|
82
82
|
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
|
83
83
|
const [rightCollapsed, setRightCollapsed] = useState(false)
|
|
84
84
|
|
|
@@ -90,7 +90,7 @@ function ViewBuilderContent({ isDark, onToggleTheme }) {
|
|
|
90
90
|
if (response) {
|
|
91
91
|
const parsed = JSON.parse(response.data.value)
|
|
92
92
|
// Backfill dialogs array for schemas saved before the dialogs feature
|
|
93
|
-
|
|
93
|
+
loadSchema({ dialogs: [], ...parsed })
|
|
94
94
|
setViewMetaData({
|
|
95
95
|
id: response.data.id,
|
|
96
96
|
title: response.data.title,
|
|
@@ -4,43 +4,43 @@ export const ReportBuilderEndpoints = {
|
|
|
4
4
|
Post: {
|
|
5
5
|
GetModels: {
|
|
6
6
|
group: 'ReportBuilder', name: 'GetModels',
|
|
7
|
-
URL: '
|
|
7
|
+
URL: 'ReportBuilder/GetTypes',
|
|
8
8
|
ContentType: ContentTypes.Json,
|
|
9
9
|
DataType: DataTypes.Params
|
|
10
10
|
},
|
|
11
11
|
GetModelFields: {
|
|
12
12
|
group: 'ReportBuilder', name: 'GetModelFields',
|
|
13
|
-
URL: '
|
|
13
|
+
URL: 'ReportBuilder/GetModelFields',
|
|
14
14
|
ContentType: ContentTypes.Json,
|
|
15
15
|
DataType: DataTypes.Params
|
|
16
16
|
},
|
|
17
17
|
GenericGet: {
|
|
18
18
|
group: 'ReportBuilder', name: 'GenericGet',
|
|
19
|
-
URL: '
|
|
19
|
+
URL: 'ReportBuilder/GenericGet',
|
|
20
20
|
ContentType: ContentTypes.Json,
|
|
21
21
|
DataType: DataTypes.Body
|
|
22
22
|
},
|
|
23
23
|
GetRawColumns: {
|
|
24
24
|
group: 'ReportBuilder', name: 'GetRawColumns',
|
|
25
|
-
URL: '
|
|
25
|
+
URL: 'ReportBuilder/GetRawColumns',
|
|
26
26
|
ContentType: ContentTypes.Json,
|
|
27
27
|
DataType: DataTypes.Body
|
|
28
28
|
},
|
|
29
29
|
GetAllReports: {
|
|
30
30
|
group: 'ReportBuilder', name: 'GetAllReports',
|
|
31
|
-
URL: '
|
|
31
|
+
URL: 'ReportBuilder/GetAllReports',
|
|
32
32
|
ContentType: ContentTypes.Json,
|
|
33
33
|
DataType: DataTypes.Body
|
|
34
34
|
},
|
|
35
35
|
GetReportDetails: {
|
|
36
36
|
group: 'ReportBuilder', name: 'GetReportDetails',
|
|
37
|
-
URL: '
|
|
37
|
+
URL: 'ReportBuilder/GetReportDetails',
|
|
38
38
|
ContentType: ContentTypes.Json,
|
|
39
39
|
DataType: DataTypes.Body
|
|
40
40
|
},
|
|
41
41
|
AddUpdate: {
|
|
42
42
|
group: 'ReportBuilder', name: 'AddUpdate',
|
|
43
|
-
URL: '
|
|
43
|
+
URL: 'ReportBuilder/AddUpdate',
|
|
44
44
|
ContentType: ContentTypes.Json,
|
|
45
45
|
DataType: DataTypes.Body
|
|
46
46
|
},
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session log — captures every successful save of a UI Builder or Print
|
|
3
|
+
* Builder schema while developers use the tool. The captured entries
|
|
4
|
+
* become future training data for the AI assistant.
|
|
5
|
+
*
|
|
6
|
+
* Storage:
|
|
7
|
+
* localStorage key rbb:sessionLogs:v1
|
|
8
|
+
* value JSON array of entries (newest first)
|
|
9
|
+
*
|
|
10
|
+
* Entry shape:
|
|
11
|
+
* {
|
|
12
|
+
* id: string, // unique
|
|
13
|
+
* createdAt: string, // ISO timestamp
|
|
14
|
+
* builder: 'ui' | 'print',
|
|
15
|
+
* trigger: string, // 'save' | future: 'manual', 'auto'
|
|
16
|
+
* title: string, // saved view title (for display only)
|
|
17
|
+
* viewMetaData: object, // builder-specific id/group/etc.
|
|
18
|
+
* schema: object, // the actual saved schema
|
|
19
|
+
* prompt: string, // empty by default — filled later by the
|
|
20
|
+
* // developer to label the example for
|
|
21
|
+
* // training (e.g. the natural-language
|
|
22
|
+
* // instruction the schema corresponds to).
|
|
23
|
+
* notes: string, // optional free-form developer notes
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const STORAGE_KEY = 'rbb:sessionLogs:v1'
|
|
28
|
+
|
|
29
|
+
// ── Internal: pub/sub so the dialog re-renders on changes ────────────────────
|
|
30
|
+
const listeners = new Set()
|
|
31
|
+
function notify() { listeners.forEach(fn => { try { fn() } catch (_) {} }) }
|
|
32
|
+
|
|
33
|
+
export function subscribe(fn) {
|
|
34
|
+
listeners.add(fn)
|
|
35
|
+
return () => listeners.delete(fn)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Internal: read/write ─────────────────────────────────────────────────────
|
|
39
|
+
function safeRead() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(STORAGE_KEY) : null
|
|
42
|
+
if (!raw) return []
|
|
43
|
+
const parsed = JSON.parse(raw)
|
|
44
|
+
return Array.isArray(parsed) ? parsed : []
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn('[sessionLog] failed to read storage:', e)
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safeWrite(entries) {
|
|
52
|
+
try {
|
|
53
|
+
if (typeof window === 'undefined') return
|
|
54
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
|
|
55
|
+
notify()
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.warn('[sessionLog] failed to write storage (likely quota):', e)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function uid() {
|
|
62
|
+
try { return `log_${crypto.randomUUID()}` }
|
|
63
|
+
catch { return `log_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Append a new entry. Returns the created entry.
|
|
70
|
+
*
|
|
71
|
+
* @param {Object} params
|
|
72
|
+
* @param {'ui'|'print'} params.builder
|
|
73
|
+
* @param {string} [params.trigger='save']
|
|
74
|
+
* @param {string} [params.title='']
|
|
75
|
+
* @param {Object} [params.viewMetaData]
|
|
76
|
+
* @param {Object} params.schema - the actual schema being logged
|
|
77
|
+
* @param {string} [params.prompt='']
|
|
78
|
+
* @param {string} [params.notes='']
|
|
79
|
+
*/
|
|
80
|
+
export function logSchema({
|
|
81
|
+
builder,
|
|
82
|
+
trigger = 'save',
|
|
83
|
+
title = '',
|
|
84
|
+
viewMetaData = {},
|
|
85
|
+
schema,
|
|
86
|
+
prompt = '',
|
|
87
|
+
notes = ''
|
|
88
|
+
}) {
|
|
89
|
+
if (!schema) {
|
|
90
|
+
console.warn('[sessionLog] refusing to log empty schema')
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
const entry = {
|
|
94
|
+
id: uid(),
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
builder,
|
|
97
|
+
trigger,
|
|
98
|
+
title,
|
|
99
|
+
viewMetaData,
|
|
100
|
+
// deep clone via JSON to detach from any reactive state
|
|
101
|
+
schema: JSON.parse(JSON.stringify(schema)),
|
|
102
|
+
prompt,
|
|
103
|
+
notes
|
|
104
|
+
}
|
|
105
|
+
const next = [entry, ...safeRead()]
|
|
106
|
+
safeWrite(next)
|
|
107
|
+
return entry
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** All entries, newest first. */
|
|
111
|
+
export function listEntries() {
|
|
112
|
+
return safeRead()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getEntry(id) {
|
|
116
|
+
return safeRead().find(e => e.id === id) ?? null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Patch an entry — typically used to fill in `prompt` / `notes`. */
|
|
120
|
+
export function updateEntry(id, patch) {
|
|
121
|
+
const entries = safeRead()
|
|
122
|
+
const idx = entries.findIndex(e => e.id === id)
|
|
123
|
+
if (idx === -1) return null
|
|
124
|
+
const updated = { ...entries[idx], ...patch, id, createdAt: entries[idx].createdAt }
|
|
125
|
+
entries[idx] = updated
|
|
126
|
+
safeWrite(entries)
|
|
127
|
+
return updated
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function deleteEntry(id) {
|
|
131
|
+
const next = safeRead().filter(e => e.id !== id)
|
|
132
|
+
safeWrite(next)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clearAll() {
|
|
136
|
+
safeWrite([])
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Trigger a JSON file download of all entries.
|
|
141
|
+
* Filename: rbb-session-logs-YYYY-MM-DD.json
|
|
142
|
+
*/
|
|
143
|
+
export function exportJSON() {
|
|
144
|
+
if (typeof window === 'undefined') return
|
|
145
|
+
const entries = safeRead()
|
|
146
|
+
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
|
147
|
+
const url = URL.createObjectURL(blob)
|
|
148
|
+
const a = document.createElement('a')
|
|
149
|
+
const stamp = new Date().toISOString().slice(0, 10)
|
|
150
|
+
a.href = url
|
|
151
|
+
a.download = `rbb-session-logs-${stamp}.json`
|
|
152
|
+
document.body.appendChild(a)
|
|
153
|
+
a.click()
|
|
154
|
+
document.body.removeChild(a)
|
|
155
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Bulk import (e.g. restoring from a backup). Replaces existing entries.
|
|
160
|
+
*/
|
|
161
|
+
export function importJSON(json) {
|
|
162
|
+
let parsed
|
|
163
|
+
try {
|
|
164
|
+
parsed = typeof json === 'string' ? JSON.parse(json) : json
|
|
165
|
+
} catch (e) {
|
|
166
|
+
throw new Error('Invalid JSON')
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(parsed)) throw new Error('Expected an array of entries')
|
|
169
|
+
safeWrite(parsed)
|
|
170
|
+
return parsed.length
|
|
171
|
+
}
|