@ng-render/angular 0.1.0
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/README.md +87 -0
- package/fesm2022/ng-render-angular.mjs +1527 -0
- package/fesm2022/ng-render-angular.mjs.map +1 -0
- package/package.json +43 -0
- package/types/ng-render-angular.d.ts +537 -0
|
@@ -0,0 +1,1527 @@
|
|
|
1
|
+
import { defineSchema, getByPath, setByPath, evaluateVisibility, resolveAction, executeAction, runValidation, resolveBindings, resolveElementProps, resolveActionParam, removeByPath, createMixedStreamParser, applySpecPatch, SPEC_DATA_PART_TYPE, nestedToFlat } from '@json-render/core';
|
|
2
|
+
export { defineCatalog } from '@json-render/core';
|
|
3
|
+
import * as i0 from '@angular/core';
|
|
4
|
+
import { InjectionToken, makeEnvironmentProviders, signal, Injectable, inject, computed, ErrorHandler, input, ViewContainerRef, Injector, EnvironmentInjector, effect, createEnvironmentInjector, ChangeDetectionStrategy, Component, Directive } from '@angular/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The schema for @ng-render/angular.
|
|
8
|
+
*
|
|
9
|
+
* Identical to the React schema — the schema is framework-agnostic.
|
|
10
|
+
*/
|
|
11
|
+
const schema = defineSchema((s) => ({
|
|
12
|
+
spec: s.object({
|
|
13
|
+
root: s.string(),
|
|
14
|
+
elements: s.record(s.object({
|
|
15
|
+
type: s.ref('catalog.components'),
|
|
16
|
+
props: s.propsOf('catalog.components'),
|
|
17
|
+
children: s.array(s.string()),
|
|
18
|
+
visible: s.any(),
|
|
19
|
+
})),
|
|
20
|
+
}),
|
|
21
|
+
catalog: s.object({
|
|
22
|
+
components: s.map({
|
|
23
|
+
props: s.zod(),
|
|
24
|
+
slots: s.array(s.string()),
|
|
25
|
+
description: s.string(),
|
|
26
|
+
example: s.any(),
|
|
27
|
+
}),
|
|
28
|
+
actions: s.map({
|
|
29
|
+
params: s.zod(),
|
|
30
|
+
description: s.string(),
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
}), {
|
|
34
|
+
builtInActions: [
|
|
35
|
+
{
|
|
36
|
+
name: 'setState',
|
|
37
|
+
description: 'Update a value in the state model at the given statePath. Params: { statePath: string, value: any }',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'pushState',
|
|
41
|
+
description: 'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'removeState',
|
|
45
|
+
description: 'Remove an item from an array in state by index. Params: { statePath: string, index: number }',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
defaultRules: [
|
|
49
|
+
'CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: [\'a\', \'b\'], then elements \'a\' and \'b\' MUST exist. A missing child element causes that entire branch of the UI to be invisible.',
|
|
50
|
+
'SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.',
|
|
51
|
+
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
|
|
52
|
+
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
|
|
53
|
+
'When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).',
|
|
54
|
+
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
|
55
|
+
'Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.',
|
|
56
|
+
'For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.',
|
|
57
|
+
'Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.',
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const NG_RENDER_REGISTRY = new InjectionToken('NG_RENDER_REGISTRY');
|
|
62
|
+
const NG_RENDER_ACTION_HANDLERS = new InjectionToken('NG_RENDER_ACTION_HANDLERS');
|
|
63
|
+
const NG_RENDER_NAVIGATE = new InjectionToken('NG_RENDER_NAVIGATE');
|
|
64
|
+
const NG_RENDER_FALLBACK = new InjectionToken('NG_RENDER_FALLBACK');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a type-safe registry from a catalog.
|
|
68
|
+
* Maps component names to Angular component classes.
|
|
69
|
+
*/
|
|
70
|
+
function defineRegistry(_catalog, options) {
|
|
71
|
+
const registry = {};
|
|
72
|
+
if (options.components) {
|
|
73
|
+
for (const [name, componentType] of Object.entries(options.components)) {
|
|
74
|
+
registry[name] = componentType;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const actionMap = options.actions
|
|
78
|
+
? Object.entries(options.actions)
|
|
79
|
+
: [];
|
|
80
|
+
const handlers = (getSetState, getState) => {
|
|
81
|
+
const result = {};
|
|
82
|
+
for (const [name, actionFn] of actionMap) {
|
|
83
|
+
result[name] = async (params) => {
|
|
84
|
+
const setState = getSetState();
|
|
85
|
+
const state = getState();
|
|
86
|
+
if (setState) {
|
|
87
|
+
await actionFn(params, setState, state);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
};
|
|
93
|
+
const executeAction = async (actionName, params, setState, state = {}) => {
|
|
94
|
+
const entry = actionMap.find(([name]) => name === actionName);
|
|
95
|
+
if (entry) {
|
|
96
|
+
await entry[1](params, setState, state);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.warn(`Unknown action: ${actionName}`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return { registry, handlers, executeAction };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Provide ng-render configuration at the environment level.
|
|
106
|
+
* Replaces React's <JSONUIProvider>.
|
|
107
|
+
*/
|
|
108
|
+
function provideNgRender(options) {
|
|
109
|
+
const providers = [
|
|
110
|
+
{ provide: NG_RENDER_REGISTRY, useValue: options.registry },
|
|
111
|
+
];
|
|
112
|
+
if (options.handlers) {
|
|
113
|
+
providers.push({
|
|
114
|
+
provide: NG_RENDER_ACTION_HANDLERS,
|
|
115
|
+
useValue: options.handlers,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (options.navigate) {
|
|
119
|
+
providers.push({
|
|
120
|
+
provide: NG_RENDER_NAVIGATE,
|
|
121
|
+
useValue: options.navigate,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (options.fallback) {
|
|
125
|
+
providers.push({
|
|
126
|
+
provide: NG_RENDER_FALLBACK,
|
|
127
|
+
useValue: options.fallback,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return makeEnvironmentProviders(providers);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class SpecStateService {
|
|
134
|
+
_state = signal({}, ...(ngDevMode ? [{ debugName: "_state" }] : []));
|
|
135
|
+
state = this._state.asReadonly();
|
|
136
|
+
get(path) {
|
|
137
|
+
return getByPath(this._state(), path);
|
|
138
|
+
}
|
|
139
|
+
set(path, value) {
|
|
140
|
+
this._state.update((prev) => {
|
|
141
|
+
const next = { ...prev };
|
|
142
|
+
setByPath(next, path, value);
|
|
143
|
+
return next;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
update(updates) {
|
|
147
|
+
this._state.update((prev) => {
|
|
148
|
+
const next = { ...prev };
|
|
149
|
+
for (const [path, value] of Object.entries(updates)) {
|
|
150
|
+
setByPath(next, path, value);
|
|
151
|
+
}
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
initialize(initialState) {
|
|
156
|
+
this._state.set({ ...initialState });
|
|
157
|
+
}
|
|
158
|
+
mergeInitialState(state) {
|
|
159
|
+
this._state.update((prev) => ({ ...prev, ...state }));
|
|
160
|
+
}
|
|
161
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: SpecStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
162
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: SpecStateService });
|
|
163
|
+
}
|
|
164
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: SpecStateService, decorators: [{
|
|
165
|
+
type: Injectable
|
|
166
|
+
}] });
|
|
167
|
+
|
|
168
|
+
class VisibilityService {
|
|
169
|
+
stateService = inject(SpecStateService);
|
|
170
|
+
ctx = computed(() => ({
|
|
171
|
+
stateModel: this.stateService.state(),
|
|
172
|
+
}), ...(ngDevMode ? [{ debugName: "ctx" }] : []));
|
|
173
|
+
isVisible(condition) {
|
|
174
|
+
return evaluateVisibility(condition, this.ctx());
|
|
175
|
+
}
|
|
176
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: VisibilityService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
177
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: VisibilityService });
|
|
178
|
+
}
|
|
179
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: VisibilityService, decorators: [{
|
|
180
|
+
type: Injectable
|
|
181
|
+
}] });
|
|
182
|
+
|
|
183
|
+
let idCounter = 0;
|
|
184
|
+
function generateUniqueId() {
|
|
185
|
+
idCounter += 1;
|
|
186
|
+
return `${Date.now()}-${idCounter}`;
|
|
187
|
+
}
|
|
188
|
+
function deepResolveValue(value, get) {
|
|
189
|
+
if (value === null || value === undefined)
|
|
190
|
+
return value;
|
|
191
|
+
if (value === '$id')
|
|
192
|
+
return generateUniqueId();
|
|
193
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
194
|
+
const obj = value;
|
|
195
|
+
const keys = Object.keys(obj);
|
|
196
|
+
if (keys.length === 1 && typeof obj['$state'] === 'string') {
|
|
197
|
+
return get(obj['$state']);
|
|
198
|
+
}
|
|
199
|
+
if (keys.length === 1 && '$id' in obj) {
|
|
200
|
+
return generateUniqueId();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(value)) {
|
|
204
|
+
return value.map((item) => deepResolveValue(item, get));
|
|
205
|
+
}
|
|
206
|
+
if (typeof value === 'object') {
|
|
207
|
+
const resolved = {};
|
|
208
|
+
for (const [key, val] of Object.entries(value)) {
|
|
209
|
+
resolved[key] = deepResolveValue(val, get);
|
|
210
|
+
}
|
|
211
|
+
return resolved;
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
class ActionDispatcherService {
|
|
216
|
+
stateService = inject(SpecStateService);
|
|
217
|
+
handlers = inject(NG_RENDER_ACTION_HANDLERS, { optional: true }) ?? {};
|
|
218
|
+
navigate = inject(NG_RENDER_NAVIGATE, {
|
|
219
|
+
optional: true,
|
|
220
|
+
});
|
|
221
|
+
loadingActions = signal(new Set(), ...(ngDevMode ? [{ debugName: "loadingActions" }] : []));
|
|
222
|
+
pendingConfirmation = signal(null, ...(ngDevMode ? [{ debugName: "pendingConfirmation" }] : []));
|
|
223
|
+
async execute(binding) {
|
|
224
|
+
const state = this.stateService.state();
|
|
225
|
+
const resolved = resolveAction(binding, state);
|
|
226
|
+
// Built-in: setState
|
|
227
|
+
if (resolved.action === 'setState' && resolved.params) {
|
|
228
|
+
const statePath = resolved.params['statePath'];
|
|
229
|
+
const value = resolved.params['value'];
|
|
230
|
+
if (statePath) {
|
|
231
|
+
this.stateService.set(statePath, value);
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Built-in: pushState
|
|
236
|
+
if (resolved.action === 'pushState' && resolved.params) {
|
|
237
|
+
const statePath = resolved.params['statePath'];
|
|
238
|
+
const rawValue = resolved.params['value'];
|
|
239
|
+
if (statePath) {
|
|
240
|
+
const resolvedValue = deepResolveValue(rawValue, (p) => this.stateService.get(p));
|
|
241
|
+
const arr = this.stateService.get(statePath) ?? [];
|
|
242
|
+
this.stateService.set(statePath, [...arr, resolvedValue]);
|
|
243
|
+
const clearStatePath = resolved.params['clearStatePath'];
|
|
244
|
+
if (clearStatePath) {
|
|
245
|
+
this.stateService.set(clearStatePath, '');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Built-in: removeState
|
|
251
|
+
if (resolved.action === 'removeState' && resolved.params) {
|
|
252
|
+
const statePath = resolved.params['statePath'];
|
|
253
|
+
const index = resolved.params['index'];
|
|
254
|
+
if (statePath !== undefined && index !== undefined) {
|
|
255
|
+
const arr = this.stateService.get(statePath) ?? [];
|
|
256
|
+
this.stateService.set(statePath, arr.filter((_, i) => i !== index));
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Built-in: push (navigation)
|
|
261
|
+
if (resolved.action === 'push' && resolved.params) {
|
|
262
|
+
const screen = resolved.params['screen'];
|
|
263
|
+
if (screen) {
|
|
264
|
+
const currentScreen = this.stateService.get('/currentScreen');
|
|
265
|
+
const navStack = this.stateService.get('/navStack') ?? [];
|
|
266
|
+
if (currentScreen) {
|
|
267
|
+
this.stateService.set('/navStack', [...navStack, currentScreen]);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
this.stateService.set('/navStack', [...navStack, '']);
|
|
271
|
+
}
|
|
272
|
+
this.stateService.set('/currentScreen', screen);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Built-in: pop (navigation)
|
|
277
|
+
if (resolved.action === 'pop') {
|
|
278
|
+
const navStack = this.stateService.get('/navStack') ?? [];
|
|
279
|
+
if (navStack.length > 0) {
|
|
280
|
+
const previousScreen = navStack[navStack.length - 1];
|
|
281
|
+
this.stateService.set('/navStack', navStack.slice(0, -1));
|
|
282
|
+
if (previousScreen) {
|
|
283
|
+
this.stateService.set('/currentScreen', previousScreen);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
this.stateService.set('/currentScreen', undefined);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const handler = this.handlers[resolved.action];
|
|
292
|
+
if (!handler) {
|
|
293
|
+
console.warn(`No handler registered for action: ${resolved.action}`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// Confirmation flow
|
|
297
|
+
if (resolved.confirm) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
this.pendingConfirmation.set({
|
|
300
|
+
action: resolved,
|
|
301
|
+
handler,
|
|
302
|
+
resolve: () => {
|
|
303
|
+
this.pendingConfirmation.set(null);
|
|
304
|
+
resolve();
|
|
305
|
+
},
|
|
306
|
+
reject: () => {
|
|
307
|
+
this.pendingConfirmation.set(null);
|
|
308
|
+
reject(new Error('Action cancelled'));
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}).then(async () => {
|
|
312
|
+
await this.executeHandler(resolved, handler);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
await this.executeHandler(resolved, handler);
|
|
316
|
+
}
|
|
317
|
+
confirm() {
|
|
318
|
+
this.pendingConfirmation()?.resolve();
|
|
319
|
+
}
|
|
320
|
+
cancel() {
|
|
321
|
+
this.pendingConfirmation()?.reject();
|
|
322
|
+
}
|
|
323
|
+
async executeHandler(resolved, handler) {
|
|
324
|
+
this.loadingActions.update((prev) => {
|
|
325
|
+
const next = new Set(prev);
|
|
326
|
+
next.add(resolved.action);
|
|
327
|
+
return next;
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
await executeAction({
|
|
331
|
+
action: resolved,
|
|
332
|
+
handler,
|
|
333
|
+
setState: (path, value) => this.stateService.set(path, value),
|
|
334
|
+
navigate: this.navigate ?? undefined,
|
|
335
|
+
executeAction: async (name) => {
|
|
336
|
+
await this.execute({ action: name });
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
this.loadingActions.update((prev) => {
|
|
342
|
+
const next = new Set(prev);
|
|
343
|
+
next.delete(resolved.action);
|
|
344
|
+
return next;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ActionDispatcherService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
349
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ActionDispatcherService });
|
|
350
|
+
}
|
|
351
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ActionDispatcherService, decorators: [{
|
|
352
|
+
type: Injectable
|
|
353
|
+
}] });
|
|
354
|
+
|
|
355
|
+
class ValidationService {
|
|
356
|
+
stateService = inject(SpecStateService);
|
|
357
|
+
customFunctions = {};
|
|
358
|
+
fieldConfigs = {};
|
|
359
|
+
fieldStates = signal({}, ...(ngDevMode ? [{ debugName: "fieldStates" }] : []));
|
|
360
|
+
setCustomFunctions(fns) {
|
|
361
|
+
this.customFunctions = fns;
|
|
362
|
+
}
|
|
363
|
+
registerField(path, config) {
|
|
364
|
+
this.fieldConfigs[path] = config;
|
|
365
|
+
}
|
|
366
|
+
validate(path, config) {
|
|
367
|
+
const currentState = this.stateService.state();
|
|
368
|
+
const segments = path.split('/').filter(Boolean);
|
|
369
|
+
let value = currentState;
|
|
370
|
+
for (const seg of segments) {
|
|
371
|
+
if (value != null && typeof value === 'object') {
|
|
372
|
+
value = value[seg];
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
value = undefined;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const result = runValidation(config, {
|
|
380
|
+
value,
|
|
381
|
+
stateModel: currentState,
|
|
382
|
+
customFunctions: this.customFunctions,
|
|
383
|
+
});
|
|
384
|
+
this.fieldStates.update((prev) => ({
|
|
385
|
+
...prev,
|
|
386
|
+
[path]: {
|
|
387
|
+
touched: prev[path]?.touched ?? true,
|
|
388
|
+
validated: true,
|
|
389
|
+
result,
|
|
390
|
+
},
|
|
391
|
+
}));
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
touch(path) {
|
|
395
|
+
this.fieldStates.update((prev) => ({
|
|
396
|
+
...prev,
|
|
397
|
+
[path]: {
|
|
398
|
+
...prev[path],
|
|
399
|
+
touched: true,
|
|
400
|
+
validated: prev[path]?.validated ?? false,
|
|
401
|
+
result: prev[path]?.result ?? null,
|
|
402
|
+
},
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
clear(path) {
|
|
406
|
+
this.fieldStates.update((prev) => {
|
|
407
|
+
const { [path]: _, ...rest } = prev;
|
|
408
|
+
return rest;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
validateAll() {
|
|
412
|
+
let allValid = true;
|
|
413
|
+
for (const [path, config] of Object.entries(this.fieldConfigs)) {
|
|
414
|
+
const result = this.validate(path, config);
|
|
415
|
+
if (!result.valid) {
|
|
416
|
+
allValid = false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return allValid;
|
|
420
|
+
}
|
|
421
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ValidationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
422
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ValidationService });
|
|
423
|
+
}
|
|
424
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ValidationService, decorators: [{
|
|
425
|
+
type: Injectable
|
|
426
|
+
}] });
|
|
427
|
+
|
|
428
|
+
class RepeatScopeService {
|
|
429
|
+
item = undefined;
|
|
430
|
+
index = 0;
|
|
431
|
+
basePath = '';
|
|
432
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: RepeatScopeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
433
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: RepeatScopeService });
|
|
434
|
+
}
|
|
435
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: RepeatScopeService, decorators: [{
|
|
436
|
+
type: Injectable
|
|
437
|
+
}] });
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Custom error handler that absorbs rendering errors for individual elements.
|
|
441
|
+
* Prevents one broken component from crashing the entire tree.
|
|
442
|
+
*/
|
|
443
|
+
class ElementErrorHandler extends ErrorHandler {
|
|
444
|
+
handleError(error) {
|
|
445
|
+
console.error('[json-render] Rendering error in element:', error);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
class ElementRendererComponent {
|
|
450
|
+
element = input.required(...(ngDevMode ? [{ debugName: "element" }] : []));
|
|
451
|
+
spec = input.required(...(ngDevMode ? [{ debugName: "spec" }] : []));
|
|
452
|
+
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
|
|
453
|
+
vcr = inject(ViewContainerRef);
|
|
454
|
+
injector = inject(Injector);
|
|
455
|
+
envInjector = inject(EnvironmentInjector);
|
|
456
|
+
stateService = inject(SpecStateService);
|
|
457
|
+
visibilityService = inject(VisibilityService);
|
|
458
|
+
actionDispatcher = inject(ActionDispatcherService);
|
|
459
|
+
repeatScope = inject(RepeatScopeService, {
|
|
460
|
+
optional: true,
|
|
461
|
+
});
|
|
462
|
+
registry = inject(NG_RENDER_REGISTRY);
|
|
463
|
+
fallbackComponent = inject(NG_RENDER_FALLBACK, {
|
|
464
|
+
optional: true,
|
|
465
|
+
});
|
|
466
|
+
componentRef = null;
|
|
467
|
+
childRenderers = [];
|
|
468
|
+
repeatInjectors = [];
|
|
469
|
+
constructor() {
|
|
470
|
+
effect(() => {
|
|
471
|
+
this.render();
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
render() {
|
|
475
|
+
this.cleanup();
|
|
476
|
+
const element = this.element();
|
|
477
|
+
const spec = this.spec();
|
|
478
|
+
const loading = this.loading();
|
|
479
|
+
// Read state signal to register dependency for re-rendering
|
|
480
|
+
const state = this.stateService.state();
|
|
481
|
+
// Build PropResolutionContext with repeat scope data
|
|
482
|
+
const baseCtx = this.visibilityService.ctx();
|
|
483
|
+
const fullCtx = this.repeatScope
|
|
484
|
+
? {
|
|
485
|
+
...baseCtx,
|
|
486
|
+
repeatItem: this.repeatScope.item,
|
|
487
|
+
repeatIndex: this.repeatScope.index,
|
|
488
|
+
repeatBasePath: this.repeatScope.basePath,
|
|
489
|
+
}
|
|
490
|
+
: baseCtx;
|
|
491
|
+
// Evaluate visibility
|
|
492
|
+
if (element.visible !== undefined &&
|
|
493
|
+
!evaluateVisibility(element.visible, fullCtx)) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Handle repeat elements
|
|
497
|
+
if (element.repeat) {
|
|
498
|
+
this.renderRepeat(element, spec, loading, fullCtx);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// Resolve bindings and props
|
|
502
|
+
const rawProps = element.props;
|
|
503
|
+
const elementBindings = resolveBindings(rawProps, fullCtx);
|
|
504
|
+
const resolvedProps = resolveElementProps(rawProps, fullCtx);
|
|
505
|
+
const resolvedElement = resolvedProps !== element.props
|
|
506
|
+
? { ...element, props: resolvedProps }
|
|
507
|
+
: element;
|
|
508
|
+
// Create emit function
|
|
509
|
+
const onBindings = element.on;
|
|
510
|
+
const emit = (eventName) => {
|
|
511
|
+
const binding = onBindings?.[eventName];
|
|
512
|
+
if (!binding)
|
|
513
|
+
return;
|
|
514
|
+
const actionBindings = Array.isArray(binding)
|
|
515
|
+
? binding
|
|
516
|
+
: [binding];
|
|
517
|
+
for (const b of actionBindings) {
|
|
518
|
+
if (!b.params) {
|
|
519
|
+
this.actionDispatcher.execute(b);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const resolved = {};
|
|
523
|
+
for (const [key, val] of Object.entries(b.params)) {
|
|
524
|
+
resolved[key] = resolveActionParam(val, fullCtx);
|
|
525
|
+
}
|
|
526
|
+
this.actionDispatcher.execute({ ...b, params: resolved });
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
// Create on() function
|
|
530
|
+
const on = (eventName) => {
|
|
531
|
+
const binding = onBindings?.[eventName];
|
|
532
|
+
if (!binding) {
|
|
533
|
+
return { emit: () => { }, shouldPreventDefault: false, bound: false };
|
|
534
|
+
}
|
|
535
|
+
const actionBindings = Array.isArray(binding)
|
|
536
|
+
? binding
|
|
537
|
+
: [binding];
|
|
538
|
+
const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
|
|
539
|
+
return {
|
|
540
|
+
emit: () => emit(eventName),
|
|
541
|
+
shouldPreventDefault,
|
|
542
|
+
bound: true,
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
// Look up component
|
|
546
|
+
const ComponentClass = this.registry[resolvedElement.type] ?? this.fallbackComponent;
|
|
547
|
+
if (!ComponentClass) {
|
|
548
|
+
console.warn(`[ng-render] No renderer for component type: "${resolvedElement.type}"`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// Create the component
|
|
552
|
+
try {
|
|
553
|
+
this.componentRef = this.vcr.createComponent(ComponentClass, {
|
|
554
|
+
injector: this.injector,
|
|
555
|
+
environmentInjector: this.envInjector,
|
|
556
|
+
});
|
|
557
|
+
this.componentRef.setInput('element', resolvedElement);
|
|
558
|
+
this.componentRef.setInput('emit', emit);
|
|
559
|
+
this.componentRef.setInput('on', on);
|
|
560
|
+
if (elementBindings) {
|
|
561
|
+
this.componentRef.setInput('bindings', elementBindings);
|
|
562
|
+
}
|
|
563
|
+
this.componentRef.setInput('loading', loading);
|
|
564
|
+
// Render children into the ChildrenOutletDirective if present
|
|
565
|
+
if (resolvedElement.children && resolvedElement.children.length > 0) {
|
|
566
|
+
// Trigger change detection so ViewChild queries resolve
|
|
567
|
+
this.componentRef.changeDetectorRef.detectChanges();
|
|
568
|
+
// Look for children outlet on the component instance.
|
|
569
|
+
// Components that accept children must declare:
|
|
570
|
+
// @ViewChild(ChildrenOutletDirective, { static: true })
|
|
571
|
+
// childrenOutlet!: ChildrenOutletDirective;
|
|
572
|
+
const instance = this.componentRef.instance;
|
|
573
|
+
const outlet = instance?.childrenOutlet;
|
|
574
|
+
if (outlet?.vcr) {
|
|
575
|
+
this.renderChildren(resolvedElement.children, spec, loading, outlet.vcr);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
// No outlet found — render children as siblings after the component.
|
|
579
|
+
// This handles components without explicit jrChildren directive.
|
|
580
|
+
this.renderChildren(resolvedElement.children, spec, loading, this.vcr);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
console.error(`[ng-render] Error creating component for type "${resolvedElement.type}":`, error);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
renderRepeat(element, spec, loading, ctx) {
|
|
589
|
+
const repeat = element.repeat;
|
|
590
|
+
const items = getByPath(this.stateService.state(), repeat.statePath) ?? [];
|
|
591
|
+
for (let index = 0; index < items.length; index++) {
|
|
592
|
+
const itemValue = items[index];
|
|
593
|
+
const basePath = `${repeat.statePath}/${index}`;
|
|
594
|
+
// Create a child injector with a fresh RepeatScopeService
|
|
595
|
+
const repeatScopeService = new RepeatScopeService();
|
|
596
|
+
repeatScopeService.item = itemValue;
|
|
597
|
+
repeatScopeService.index = index;
|
|
598
|
+
repeatScopeService.basePath = basePath;
|
|
599
|
+
const childInjector = createEnvironmentInjector([{ provide: RepeatScopeService, useValue: repeatScopeService }], this.envInjector);
|
|
600
|
+
this.repeatInjectors.push(childInjector);
|
|
601
|
+
// Render children for this repeat iteration
|
|
602
|
+
if (element.children) {
|
|
603
|
+
for (const childKey of element.children) {
|
|
604
|
+
const childElement = spec.elements[childKey];
|
|
605
|
+
if (!childElement) {
|
|
606
|
+
if (!loading) {
|
|
607
|
+
console.warn(`[ng-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`);
|
|
608
|
+
}
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const childRef = this.vcr.createComponent(ElementRendererComponent, {
|
|
612
|
+
environmentInjector: childInjector,
|
|
613
|
+
});
|
|
614
|
+
childRef.setInput('element', childElement);
|
|
615
|
+
childRef.setInput('spec', spec);
|
|
616
|
+
childRef.setInput('loading', loading);
|
|
617
|
+
this.childRenderers.push(childRef);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
renderChildren(childKeys, spec, loading, vcr) {
|
|
623
|
+
for (const childKey of childKeys) {
|
|
624
|
+
const childElement = spec.elements[childKey];
|
|
625
|
+
if (!childElement) {
|
|
626
|
+
if (!loading) {
|
|
627
|
+
console.warn(`[ng-render] Missing element "${childKey}". This element will not render.`);
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const childRef = vcr.createComponent(ElementRendererComponent, {
|
|
632
|
+
environmentInjector: this.envInjector,
|
|
633
|
+
});
|
|
634
|
+
childRef.setInput('element', childElement);
|
|
635
|
+
childRef.setInput('spec', spec);
|
|
636
|
+
childRef.setInput('loading', loading);
|
|
637
|
+
this.childRenderers.push(childRef);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
cleanup() {
|
|
641
|
+
for (const ref of this.childRenderers) {
|
|
642
|
+
ref.destroy();
|
|
643
|
+
}
|
|
644
|
+
this.childRenderers = [];
|
|
645
|
+
for (const inj of this.repeatInjectors) {
|
|
646
|
+
inj.destroy();
|
|
647
|
+
}
|
|
648
|
+
this.repeatInjectors = [];
|
|
649
|
+
if (this.componentRef) {
|
|
650
|
+
this.componentRef.destroy();
|
|
651
|
+
this.componentRef = null;
|
|
652
|
+
}
|
|
653
|
+
this.vcr.clear();
|
|
654
|
+
}
|
|
655
|
+
ngOnDestroy() {
|
|
656
|
+
this.cleanup();
|
|
657
|
+
}
|
|
658
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ElementRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
659
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.4", type: ElementRendererComponent, isStandalone: true, selector: "jr-element-renderer", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: ErrorHandler, useClass: ElementErrorHandler }], ngImport: i0, template: '', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
660
|
+
}
|
|
661
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ElementRendererComponent, decorators: [{
|
|
662
|
+
type: Component,
|
|
663
|
+
args: [{
|
|
664
|
+
selector: 'jr-element-renderer',
|
|
665
|
+
standalone: true,
|
|
666
|
+
template: '',
|
|
667
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
668
|
+
providers: [{ provide: ErrorHandler, useClass: ElementErrorHandler }],
|
|
669
|
+
}]
|
|
670
|
+
}], ctorParameters: () => [], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }] } });
|
|
671
|
+
|
|
672
|
+
class JsonRendererComponent {
|
|
673
|
+
spec = input(null, ...(ngDevMode ? [{ debugName: "spec" }] : []));
|
|
674
|
+
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
|
|
675
|
+
initialState = input({}, ...(ngDevMode ? [{ debugName: "initialState" }] : []));
|
|
676
|
+
onStateChange = input(undefined, ...(ngDevMode ? [{ debugName: "onStateChange" }] : []));
|
|
677
|
+
stateService = inject(SpecStateService);
|
|
678
|
+
previousSpecState;
|
|
679
|
+
previousInitialState;
|
|
680
|
+
rootElement = computed(() => {
|
|
681
|
+
const s = this.spec();
|
|
682
|
+
if (!s?.root)
|
|
683
|
+
return null;
|
|
684
|
+
return s.elements[s.root] ?? null;
|
|
685
|
+
}, ...(ngDevMode ? [{ debugName: "rootElement" }] : []));
|
|
686
|
+
constructor() {
|
|
687
|
+
// Initialize from initialState input — only when values actually change
|
|
688
|
+
effect(() => {
|
|
689
|
+
const initial = this.initialState();
|
|
690
|
+
const serialized = JSON.stringify(initial);
|
|
691
|
+
if (serialized !== this.previousInitialState) {
|
|
692
|
+
this.previousInitialState = serialized;
|
|
693
|
+
if (initial && Object.keys(initial).length > 0) {
|
|
694
|
+
this.stateService.mergeInitialState(initial);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
// Merge spec.state when spec changes
|
|
699
|
+
effect(() => {
|
|
700
|
+
const s = this.spec();
|
|
701
|
+
if (s?.state && s.state !== this.previousSpecState) {
|
|
702
|
+
this.previousSpecState = s.state;
|
|
703
|
+
if (Object.keys(s.state).length > 0) {
|
|
704
|
+
this.stateService.mergeInitialState(s.state);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
// Wire onStateChange callback — observe state and forward changes
|
|
709
|
+
effect(() => {
|
|
710
|
+
const callback = this.onStateChange();
|
|
711
|
+
const state = this.stateService.state();
|
|
712
|
+
// The callback is invoked with the full state snapshot
|
|
713
|
+
// Components should use the SpecStateService for granular path-level changes
|
|
714
|
+
if (callback && state) {
|
|
715
|
+
callback('', state);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: JsonRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
720
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.4", type: JsonRendererComponent, isStandalone: true, selector: "json-renderer", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, initialState: { classPropertyName: "initialState", publicName: "initialState", isSignal: true, isRequired: false, transformFunction: null }, onStateChange: { classPropertyName: "onStateChange", publicName: "onStateChange", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
|
|
721
|
+
SpecStateService,
|
|
722
|
+
VisibilityService,
|
|
723
|
+
ActionDispatcherService,
|
|
724
|
+
ValidationService,
|
|
725
|
+
], ngImport: i0, template: `
|
|
726
|
+
@if (rootElement(); as root) {
|
|
727
|
+
<jr-element-renderer
|
|
728
|
+
[element]="root"
|
|
729
|
+
[spec]="spec()!"
|
|
730
|
+
[loading]="loading()" />
|
|
731
|
+
}
|
|
732
|
+
`, isInline: true, dependencies: [{ kind: "component", type: ElementRendererComponent, selector: "jr-element-renderer", inputs: ["element", "spec", "loading"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
733
|
+
}
|
|
734
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: JsonRendererComponent, decorators: [{
|
|
735
|
+
type: Component,
|
|
736
|
+
args: [{
|
|
737
|
+
selector: 'json-renderer',
|
|
738
|
+
standalone: true,
|
|
739
|
+
imports: [ElementRendererComponent],
|
|
740
|
+
template: `
|
|
741
|
+
@if (rootElement(); as root) {
|
|
742
|
+
<jr-element-renderer
|
|
743
|
+
[element]="root"
|
|
744
|
+
[spec]="spec()!"
|
|
745
|
+
[loading]="loading()" />
|
|
746
|
+
}
|
|
747
|
+
`,
|
|
748
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
749
|
+
providers: [
|
|
750
|
+
SpecStateService,
|
|
751
|
+
VisibilityService,
|
|
752
|
+
ActionDispatcherService,
|
|
753
|
+
ValidationService,
|
|
754
|
+
],
|
|
755
|
+
}]
|
|
756
|
+
}], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], initialState: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialState", required: false }] }], onStateChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "onStateChange", required: false }] }] } });
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Directive that marks where children should be rendered inside a
|
|
760
|
+
* registered component.
|
|
761
|
+
*
|
|
762
|
+
* Usage in component templates:
|
|
763
|
+
* ```html
|
|
764
|
+
* <div class="card">
|
|
765
|
+
* <ng-template jrChildren></ng-template>
|
|
766
|
+
* </div>
|
|
767
|
+
* ```
|
|
768
|
+
*/
|
|
769
|
+
class ChildrenOutletDirective {
|
|
770
|
+
vcr = inject(ViewContainerRef);
|
|
771
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ChildrenOutletDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
772
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.1.4", type: ChildrenOutletDirective, isStandalone: true, selector: "[jrChildren]", exportAs: ["jrChildren"], ngImport: i0 });
|
|
773
|
+
}
|
|
774
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ChildrenOutletDirective, decorators: [{
|
|
775
|
+
type: Directive,
|
|
776
|
+
args: [{
|
|
777
|
+
selector: '[jrChildren]',
|
|
778
|
+
standalone: true,
|
|
779
|
+
exportAs: 'jrChildren',
|
|
780
|
+
}]
|
|
781
|
+
}] });
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Default confirmation dialog component.
|
|
785
|
+
* Renders when an action has a `confirm` field.
|
|
786
|
+
*
|
|
787
|
+
* Place inside or near the `<json-renderer>` in your template:
|
|
788
|
+
* ```html
|
|
789
|
+
* <json-renderer [spec]="spec">
|
|
790
|
+
* <jr-confirm-dialog />
|
|
791
|
+
* </json-renderer>
|
|
792
|
+
* ```
|
|
793
|
+
*
|
|
794
|
+
* Or use it standalone anywhere the ActionDispatcherService is available.
|
|
795
|
+
*/
|
|
796
|
+
class ConfirmDialogComponent {
|
|
797
|
+
dispatcher = inject(ActionDispatcherService);
|
|
798
|
+
confirm = computed(() => {
|
|
799
|
+
const pending = this.dispatcher.pendingConfirmation();
|
|
800
|
+
return pending?.action.confirm ?? null;
|
|
801
|
+
}, ...(ngDevMode ? [{ debugName: "confirm" }] : []));
|
|
802
|
+
onConfirm() {
|
|
803
|
+
this.dispatcher.confirm();
|
|
804
|
+
}
|
|
805
|
+
onCancel() {
|
|
806
|
+
this.dispatcher.cancel();
|
|
807
|
+
}
|
|
808
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ConfirmDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
809
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.4", type: ConfirmDialogComponent, isStandalone: true, selector: "jr-confirm-dialog", ngImport: i0, template: `
|
|
810
|
+
@if (confirm(); as c) {
|
|
811
|
+
<div class="jr-confirm-overlay" (click)="onCancel()">
|
|
812
|
+
<div class="jr-confirm-dialog" (click)="$event.stopPropagation()">
|
|
813
|
+
<h3 class="jr-confirm-title">{{ c.title }}</h3>
|
|
814
|
+
<p class="jr-confirm-message">{{ c.message }}</p>
|
|
815
|
+
<div class="jr-confirm-actions">
|
|
816
|
+
<button class="jr-confirm-btn jr-confirm-btn-cancel" (click)="onCancel()">
|
|
817
|
+
{{ c.cancelLabel ?? 'Cancel' }}
|
|
818
|
+
</button>
|
|
819
|
+
<button
|
|
820
|
+
class="jr-confirm-btn"
|
|
821
|
+
[class.jr-confirm-btn-danger]="c.variant === 'danger'"
|
|
822
|
+
[class.jr-confirm-btn-primary]="c.variant !== 'danger'"
|
|
823
|
+
(click)="onConfirm()">
|
|
824
|
+
{{ c.confirmLabel ?? 'Confirm' }}
|
|
825
|
+
</button>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
}
|
|
830
|
+
`, isInline: true, styles: [".jr-confirm-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:50}.jr-confirm-dialog{background:#fff;border-radius:8px;padding:24px;max-width:400px;width:100%;box-shadow:0 20px 25px -5px #0000001a}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:#6b7280}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-btn{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px}.jr-confirm-btn-cancel{border:1px solid #d1d5db;background:#fff;color:#374151}.jr-confirm-btn-primary{border:none;background:#3b82f6;color:#fff}.jr-confirm-btn-danger{border:none;background:#dc2626;color:#fff}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
831
|
+
}
|
|
832
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: ConfirmDialogComponent, decorators: [{
|
|
833
|
+
type: Component,
|
|
834
|
+
args: [{ selector: 'jr-confirm-dialog', standalone: true, template: `
|
|
835
|
+
@if (confirm(); as c) {
|
|
836
|
+
<div class="jr-confirm-overlay" (click)="onCancel()">
|
|
837
|
+
<div class="jr-confirm-dialog" (click)="$event.stopPropagation()">
|
|
838
|
+
<h3 class="jr-confirm-title">{{ c.title }}</h3>
|
|
839
|
+
<p class="jr-confirm-message">{{ c.message }}</p>
|
|
840
|
+
<div class="jr-confirm-actions">
|
|
841
|
+
<button class="jr-confirm-btn jr-confirm-btn-cancel" (click)="onCancel()">
|
|
842
|
+
{{ c.cancelLabel ?? 'Cancel' }}
|
|
843
|
+
</button>
|
|
844
|
+
<button
|
|
845
|
+
class="jr-confirm-btn"
|
|
846
|
+
[class.jr-confirm-btn-danger]="c.variant === 'danger'"
|
|
847
|
+
[class.jr-confirm-btn-primary]="c.variant !== 'danger'"
|
|
848
|
+
(click)="onConfirm()">
|
|
849
|
+
{{ c.confirmLabel ?? 'Confirm' }}
|
|
850
|
+
</button>
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
</div>
|
|
854
|
+
}
|
|
855
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".jr-confirm-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:50}.jr-confirm-dialog{background:#fff;border-radius:8px;padding:24px;max-width:400px;width:100%;box-shadow:0 20px 25px -5px #0000001a}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:#6b7280}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-btn{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px}.jr-confirm-btn-cancel{border:1px solid #d1d5db;background:#fff;color:#374151}.jr-confirm-btn-primary{border:none;background:#3b82f6;color:#fff}.jr-confirm-btn-danger{border:none;background:#dc2626;color:#fff}\n"] }]
|
|
856
|
+
}] });
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Default fallback component for unknown element types.
|
|
860
|
+
* Displays a warning in development.
|
|
861
|
+
*
|
|
862
|
+
* Register via provideNgRender:
|
|
863
|
+
* ```ts
|
|
864
|
+
* provideNgRender({
|
|
865
|
+
* registry: myRegistry,
|
|
866
|
+
* fallback: FallbackComponent,
|
|
867
|
+
* })
|
|
868
|
+
* ```
|
|
869
|
+
*/
|
|
870
|
+
class FallbackComponent {
|
|
871
|
+
element = input.required(...(ngDevMode ? [{ debugName: "element" }] : []));
|
|
872
|
+
emit = input.required(...(ngDevMode ? [{ debugName: "emit" }] : []));
|
|
873
|
+
on = input.required(...(ngDevMode ? [{ debugName: "on" }] : []));
|
|
874
|
+
bindings = input(...(ngDevMode ? [undefined, { debugName: "bindings" }] : []));
|
|
875
|
+
loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : []));
|
|
876
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: FallbackComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
877
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.4", type: FallbackComponent, isStandalone: true, selector: "jr-fallback", inputs: { element: { classPropertyName: "element", publicName: "element", isSignal: true, isRequired: true, transformFunction: null }, emit: { classPropertyName: "emit", publicName: "emit", isSignal: true, isRequired: true, transformFunction: null }, on: { classPropertyName: "on", publicName: "on", isSignal: true, isRequired: true, transformFunction: null }, bindings: { classPropertyName: "bindings", publicName: "bindings", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
|
|
878
|
+
<div class="jr-fallback">
|
|
879
|
+
Unknown component: "{{ element().type }}"
|
|
880
|
+
</div>
|
|
881
|
+
`, isInline: true, styles: [".jr-fallback{padding:8px 12px;border:1px dashed #f59e0b;border-radius:6px;background:#fffbeb;color:#92400e;font-size:12px;font-family:monospace}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
882
|
+
}
|
|
883
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.4", ngImport: i0, type: FallbackComponent, decorators: [{
|
|
884
|
+
type: Component,
|
|
885
|
+
args: [{ selector: 'jr-fallback', standalone: true, template: `
|
|
886
|
+
<div class="jr-fallback">
|
|
887
|
+
Unknown component: "{{ element().type }}"
|
|
888
|
+
</div>
|
|
889
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".jr-fallback{padding:8px 12px;border:1px dashed #f59e0b;border-radius:6px;background:#fffbeb;color:#92400e;font-size:12px;font-family:monospace}\n"] }]
|
|
890
|
+
}], propDecorators: { element: [{ type: i0.Input, args: [{ isSignal: true, alias: "element", required: true }] }], emit: [{ type: i0.Input, args: [{ isSignal: true, alias: "emit", required: true }] }], on: [{ type: i0.Input, args: [{ isSignal: true, alias: "on", required: true }] }], bindings: [{ type: i0.Input, args: [{ isSignal: true, alias: "bindings", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }] } });
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Parse a single JSONL line into a patch or usage metadata.
|
|
894
|
+
*/
|
|
895
|
+
function parseLine(line) {
|
|
896
|
+
try {
|
|
897
|
+
const trimmed = line.trim();
|
|
898
|
+
if (!trimmed || trimmed.startsWith('//'))
|
|
899
|
+
return null;
|
|
900
|
+
const parsed = JSON.parse(trimmed);
|
|
901
|
+
if (parsed.__meta === 'usage') {
|
|
902
|
+
return {
|
|
903
|
+
type: 'usage',
|
|
904
|
+
usage: {
|
|
905
|
+
promptTokens: parsed.promptTokens ?? 0,
|
|
906
|
+
completionTokens: parsed.completionTokens ?? 0,
|
|
907
|
+
totalTokens: parsed.totalTokens ?? 0,
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
return { type: 'patch', patch: parsed };
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function setSpecValue(newSpec, path, value) {
|
|
918
|
+
if (path === '/root') {
|
|
919
|
+
newSpec.root = value;
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (path === '/state') {
|
|
923
|
+
newSpec.state = value;
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (path.startsWith('/state/')) {
|
|
927
|
+
if (!newSpec.state)
|
|
928
|
+
newSpec.state = {};
|
|
929
|
+
const statePath = path.slice('/state'.length);
|
|
930
|
+
setByPath(newSpec.state, statePath, value);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (path.startsWith('/elements/')) {
|
|
934
|
+
const pathParts = path.slice('/elements/'.length).split('/');
|
|
935
|
+
const elementKey = pathParts[0];
|
|
936
|
+
if (!elementKey)
|
|
937
|
+
return;
|
|
938
|
+
if (pathParts.length === 1) {
|
|
939
|
+
newSpec.elements[elementKey] = value;
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
const element = newSpec.elements[elementKey];
|
|
943
|
+
if (element) {
|
|
944
|
+
const propPath = '/' + pathParts.slice(1).join('/');
|
|
945
|
+
const newElement = { ...element };
|
|
946
|
+
setByPath(newElement, propPath, value);
|
|
947
|
+
newSpec.elements[elementKey] = newElement;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
function removeSpecValue(newSpec, path) {
|
|
953
|
+
if (path === '/state') {
|
|
954
|
+
delete newSpec.state;
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (path.startsWith('/state/') && newSpec.state) {
|
|
958
|
+
const statePath = path.slice('/state'.length);
|
|
959
|
+
removeByPath(newSpec.state, statePath);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (path.startsWith('/elements/')) {
|
|
963
|
+
const pathParts = path.slice('/elements/'.length).split('/');
|
|
964
|
+
const elementKey = pathParts[0];
|
|
965
|
+
if (!elementKey)
|
|
966
|
+
return;
|
|
967
|
+
if (pathParts.length === 1) {
|
|
968
|
+
const { [elementKey]: _, ...rest } = newSpec.elements;
|
|
969
|
+
newSpec.elements = rest;
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
const element = newSpec.elements[elementKey];
|
|
973
|
+
if (element) {
|
|
974
|
+
const propPath = '/' + pathParts.slice(1).join('/');
|
|
975
|
+
const newElement = { ...element };
|
|
976
|
+
removeByPath(newElement, propPath);
|
|
977
|
+
newSpec.elements[elementKey] = newElement;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function getSpecValue(spec, path) {
|
|
983
|
+
if (path === '/root')
|
|
984
|
+
return spec.root;
|
|
985
|
+
if (path === '/state')
|
|
986
|
+
return spec.state;
|
|
987
|
+
if (path.startsWith('/state/') && spec.state) {
|
|
988
|
+
const statePath = path.slice('/state'.length);
|
|
989
|
+
return getByPath(spec.state, statePath);
|
|
990
|
+
}
|
|
991
|
+
return getByPath(spec, path);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Apply an RFC 6902 JSON patch to a Spec.
|
|
995
|
+
* Returns a new shallow-copied Spec (immutable pattern for signal updates).
|
|
996
|
+
*/
|
|
997
|
+
function applyPatch(spec, patch) {
|
|
998
|
+
const newSpec = {
|
|
999
|
+
...spec,
|
|
1000
|
+
elements: { ...spec.elements },
|
|
1001
|
+
...(spec.state ? { state: { ...spec.state } } : {}),
|
|
1002
|
+
};
|
|
1003
|
+
switch (patch.op) {
|
|
1004
|
+
case 'add':
|
|
1005
|
+
case 'replace':
|
|
1006
|
+
setSpecValue(newSpec, patch.path, patch.value);
|
|
1007
|
+
break;
|
|
1008
|
+
case 'remove':
|
|
1009
|
+
removeSpecValue(newSpec, patch.path);
|
|
1010
|
+
break;
|
|
1011
|
+
case 'move':
|
|
1012
|
+
if (!patch.from)
|
|
1013
|
+
break;
|
|
1014
|
+
const moveValue = getSpecValue(newSpec, patch.from);
|
|
1015
|
+
removeSpecValue(newSpec, patch.from);
|
|
1016
|
+
setSpecValue(newSpec, patch.path, moveValue);
|
|
1017
|
+
break;
|
|
1018
|
+
case 'copy':
|
|
1019
|
+
if (!patch.from)
|
|
1020
|
+
break;
|
|
1021
|
+
const copyValue = getSpecValue(newSpec, patch.from);
|
|
1022
|
+
setSpecValue(newSpec, patch.path, copyValue);
|
|
1023
|
+
break;
|
|
1024
|
+
case 'test':
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
return newSpec;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Create a streaming UI generator.
|
|
1032
|
+
* Angular equivalent of React's `useUIStream` hook.
|
|
1033
|
+
*
|
|
1034
|
+
* Returns signal-based reactive state + imperative send/clear functions.
|
|
1035
|
+
*
|
|
1036
|
+
* @example
|
|
1037
|
+
* ```ts
|
|
1038
|
+
* // In a component
|
|
1039
|
+
* private stream = createUIStream({ api: '/api/generate' });
|
|
1040
|
+
* spec = this.stream.spec;
|
|
1041
|
+
* isStreaming = this.stream.isStreaming;
|
|
1042
|
+
*
|
|
1043
|
+
* async generate() {
|
|
1044
|
+
* await this.stream.send('Create a dashboard');
|
|
1045
|
+
* }
|
|
1046
|
+
*
|
|
1047
|
+
* ngOnDestroy() {
|
|
1048
|
+
* this.stream.destroy();
|
|
1049
|
+
* }
|
|
1050
|
+
* ```
|
|
1051
|
+
*/
|
|
1052
|
+
function createUIStream(options) {
|
|
1053
|
+
const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : []));
|
|
1054
|
+
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : []));
|
|
1055
|
+
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
|
|
1056
|
+
const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : []));
|
|
1057
|
+
const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : []));
|
|
1058
|
+
let abortController = null;
|
|
1059
|
+
const clear = () => {
|
|
1060
|
+
spec.set(null);
|
|
1061
|
+
error.set(null);
|
|
1062
|
+
};
|
|
1063
|
+
const destroy = () => {
|
|
1064
|
+
abortController?.abort();
|
|
1065
|
+
abortController = null;
|
|
1066
|
+
};
|
|
1067
|
+
const send = async (prompt, context) => {
|
|
1068
|
+
abortController?.abort();
|
|
1069
|
+
abortController = new AbortController();
|
|
1070
|
+
isStreaming.set(true);
|
|
1071
|
+
error.set(null);
|
|
1072
|
+
usage.set(null);
|
|
1073
|
+
rawLines.set([]);
|
|
1074
|
+
const previousSpec = context?.['previousSpec'];
|
|
1075
|
+
let currentSpec = previousSpec && previousSpec.root
|
|
1076
|
+
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
1077
|
+
: { root: '', elements: {} };
|
|
1078
|
+
spec.set(currentSpec);
|
|
1079
|
+
try {
|
|
1080
|
+
const response = await fetch(options.api, {
|
|
1081
|
+
method: 'POST',
|
|
1082
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1083
|
+
body: JSON.stringify({ prompt, context, currentSpec }),
|
|
1084
|
+
signal: abortController.signal,
|
|
1085
|
+
});
|
|
1086
|
+
if (!response.ok) {
|
|
1087
|
+
let errorMessage = `HTTP error: ${response.status}`;
|
|
1088
|
+
try {
|
|
1089
|
+
const errorData = await response.json();
|
|
1090
|
+
if (errorData.message)
|
|
1091
|
+
errorMessage = errorData.message;
|
|
1092
|
+
else if (errorData.error)
|
|
1093
|
+
errorMessage = errorData.error;
|
|
1094
|
+
}
|
|
1095
|
+
catch {
|
|
1096
|
+
// Ignore
|
|
1097
|
+
}
|
|
1098
|
+
throw new Error(errorMessage);
|
|
1099
|
+
}
|
|
1100
|
+
const reader = response.body?.getReader();
|
|
1101
|
+
if (!reader)
|
|
1102
|
+
throw new Error('No response body');
|
|
1103
|
+
const decoder = new TextDecoder();
|
|
1104
|
+
let buffer = '';
|
|
1105
|
+
while (true) {
|
|
1106
|
+
const { done, value } = await reader.read();
|
|
1107
|
+
if (done)
|
|
1108
|
+
break;
|
|
1109
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1110
|
+
const lines = buffer.split('\n');
|
|
1111
|
+
buffer = lines.pop() ?? '';
|
|
1112
|
+
for (const line of lines) {
|
|
1113
|
+
const trimmed = line.trim();
|
|
1114
|
+
if (!trimmed)
|
|
1115
|
+
continue;
|
|
1116
|
+
const result = parseLine(trimmed);
|
|
1117
|
+
if (!result)
|
|
1118
|
+
continue;
|
|
1119
|
+
if (result.type === 'usage') {
|
|
1120
|
+
usage.set(result.usage);
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
rawLines.update((prev) => [...prev, trimmed]);
|
|
1124
|
+
currentSpec = applyPatch(currentSpec, result.patch);
|
|
1125
|
+
spec.set({ ...currentSpec });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// Process remaining buffer
|
|
1130
|
+
if (buffer.trim()) {
|
|
1131
|
+
const result = parseLine(buffer.trim());
|
|
1132
|
+
if (result) {
|
|
1133
|
+
if (result.type === 'usage') {
|
|
1134
|
+
usage.set(result.usage);
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
rawLines.update((prev) => [...prev, buffer.trim()]);
|
|
1138
|
+
currentSpec = applyPatch(currentSpec, result.patch);
|
|
1139
|
+
spec.set({ ...currentSpec });
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
options.onComplete?.(currentSpec);
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
if (err.name === 'AbortError')
|
|
1147
|
+
return;
|
|
1148
|
+
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
1149
|
+
error.set(resolvedError);
|
|
1150
|
+
options.onError?.(resolvedError);
|
|
1151
|
+
}
|
|
1152
|
+
finally {
|
|
1153
|
+
isStreaming.set(false);
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
return {
|
|
1157
|
+
spec: spec.asReadonly(),
|
|
1158
|
+
isStreaming: isStreaming.asReadonly(),
|
|
1159
|
+
error: error.asReadonly(),
|
|
1160
|
+
usage: usage.asReadonly(),
|
|
1161
|
+
rawLines: rawLines.asReadonly(),
|
|
1162
|
+
send,
|
|
1163
|
+
clear,
|
|
1164
|
+
destroy,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
let chatMessageIdCounter = 0;
|
|
1169
|
+
function generateChatId() {
|
|
1170
|
+
if (typeof crypto !== 'undefined' &&
|
|
1171
|
+
typeof crypto.randomUUID === 'function') {
|
|
1172
|
+
return crypto.randomUUID();
|
|
1173
|
+
}
|
|
1174
|
+
chatMessageIdCounter += 1;
|
|
1175
|
+
return `msg-${Date.now()}-${chatMessageIdCounter}`;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Create a chat + GenUI interface.
|
|
1179
|
+
* Angular equivalent of React's `useChatUI` hook.
|
|
1180
|
+
*
|
|
1181
|
+
* Manages a multi-turn conversation where each assistant message can contain
|
|
1182
|
+
* both conversational text and a json-render UI spec.
|
|
1183
|
+
*
|
|
1184
|
+
* @example
|
|
1185
|
+
* ```ts
|
|
1186
|
+
* private chat = createChatUI({ api: '/api/chat' });
|
|
1187
|
+
* messages = this.chat.messages;
|
|
1188
|
+
* isStreaming = this.chat.isStreaming;
|
|
1189
|
+
*
|
|
1190
|
+
* async send(text: string) {
|
|
1191
|
+
* await this.chat.send(text);
|
|
1192
|
+
* }
|
|
1193
|
+
*
|
|
1194
|
+
* ngOnDestroy() {
|
|
1195
|
+
* this.chat.destroy();
|
|
1196
|
+
* }
|
|
1197
|
+
* ```
|
|
1198
|
+
*/
|
|
1199
|
+
function createChatUI(options) {
|
|
1200
|
+
const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : []));
|
|
1201
|
+
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : []));
|
|
1202
|
+
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : []));
|
|
1203
|
+
let abortController = null;
|
|
1204
|
+
const clear = () => {
|
|
1205
|
+
messages.set([]);
|
|
1206
|
+
error.set(null);
|
|
1207
|
+
};
|
|
1208
|
+
const destroy = () => {
|
|
1209
|
+
abortController?.abort();
|
|
1210
|
+
abortController = null;
|
|
1211
|
+
};
|
|
1212
|
+
const send = async (text) => {
|
|
1213
|
+
if (!text.trim())
|
|
1214
|
+
return;
|
|
1215
|
+
abortController?.abort();
|
|
1216
|
+
abortController = new AbortController();
|
|
1217
|
+
const userMessage = {
|
|
1218
|
+
id: generateChatId(),
|
|
1219
|
+
role: 'user',
|
|
1220
|
+
text: text.trim(),
|
|
1221
|
+
spec: null,
|
|
1222
|
+
};
|
|
1223
|
+
const assistantId = generateChatId();
|
|
1224
|
+
const assistantMessage = {
|
|
1225
|
+
id: assistantId,
|
|
1226
|
+
role: 'assistant',
|
|
1227
|
+
text: '',
|
|
1228
|
+
spec: null,
|
|
1229
|
+
};
|
|
1230
|
+
messages.update((prev) => [...prev, userMessage, assistantMessage]);
|
|
1231
|
+
isStreaming.set(true);
|
|
1232
|
+
error.set(null);
|
|
1233
|
+
const historyForApi = messages()
|
|
1234
|
+
.filter((m) => m.id !== assistantId)
|
|
1235
|
+
.map((m) => ({ role: m.role, content: m.text }));
|
|
1236
|
+
historyForApi.push({ role: 'user', content: text.trim() });
|
|
1237
|
+
let accumulatedText = '';
|
|
1238
|
+
let currentSpec = { root: '', elements: {} };
|
|
1239
|
+
let hasSpec = false;
|
|
1240
|
+
try {
|
|
1241
|
+
const response = await fetch(options.api, {
|
|
1242
|
+
method: 'POST',
|
|
1243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1244
|
+
body: JSON.stringify({ messages: historyForApi }),
|
|
1245
|
+
signal: abortController.signal,
|
|
1246
|
+
});
|
|
1247
|
+
if (!response.ok) {
|
|
1248
|
+
let errorMessage = `HTTP error: ${response.status}`;
|
|
1249
|
+
try {
|
|
1250
|
+
const errorData = await response.json();
|
|
1251
|
+
if (errorData.message)
|
|
1252
|
+
errorMessage = errorData.message;
|
|
1253
|
+
else if (errorData.error)
|
|
1254
|
+
errorMessage = errorData.error;
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
// Ignore
|
|
1258
|
+
}
|
|
1259
|
+
throw new Error(errorMessage);
|
|
1260
|
+
}
|
|
1261
|
+
const reader = response.body?.getReader();
|
|
1262
|
+
if (!reader)
|
|
1263
|
+
throw new Error('No response body');
|
|
1264
|
+
const decoder = new TextDecoder();
|
|
1265
|
+
const parser = createMixedStreamParser({
|
|
1266
|
+
onPatch(patch) {
|
|
1267
|
+
hasSpec = true;
|
|
1268
|
+
applySpecPatch(currentSpec, patch);
|
|
1269
|
+
messages.update((prev) => prev.map((m) => m.id === assistantId
|
|
1270
|
+
? {
|
|
1271
|
+
...m,
|
|
1272
|
+
spec: {
|
|
1273
|
+
root: currentSpec.root,
|
|
1274
|
+
elements: { ...currentSpec.elements },
|
|
1275
|
+
...(currentSpec.state
|
|
1276
|
+
? { state: { ...currentSpec.state } }
|
|
1277
|
+
: {}),
|
|
1278
|
+
},
|
|
1279
|
+
}
|
|
1280
|
+
: m));
|
|
1281
|
+
},
|
|
1282
|
+
onText(line) {
|
|
1283
|
+
accumulatedText += (accumulatedText ? '\n' : '') + line;
|
|
1284
|
+
messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m));
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
while (true) {
|
|
1288
|
+
const { done, value } = await reader.read();
|
|
1289
|
+
if (done)
|
|
1290
|
+
break;
|
|
1291
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
1292
|
+
}
|
|
1293
|
+
parser.flush();
|
|
1294
|
+
const finalMessage = {
|
|
1295
|
+
id: assistantId,
|
|
1296
|
+
role: 'assistant',
|
|
1297
|
+
text: accumulatedText,
|
|
1298
|
+
spec: hasSpec
|
|
1299
|
+
? {
|
|
1300
|
+
root: currentSpec.root,
|
|
1301
|
+
elements: { ...currentSpec.elements },
|
|
1302
|
+
...(currentSpec.state
|
|
1303
|
+
? { state: { ...currentSpec.state } }
|
|
1304
|
+
: {}),
|
|
1305
|
+
}
|
|
1306
|
+
: null,
|
|
1307
|
+
};
|
|
1308
|
+
options.onComplete?.(finalMessage);
|
|
1309
|
+
}
|
|
1310
|
+
catch (err) {
|
|
1311
|
+
if (err.name === 'AbortError')
|
|
1312
|
+
return;
|
|
1313
|
+
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
1314
|
+
error.set(resolvedError);
|
|
1315
|
+
messages.update((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0));
|
|
1316
|
+
options.onError?.(resolvedError);
|
|
1317
|
+
}
|
|
1318
|
+
finally {
|
|
1319
|
+
isStreaming.set(false);
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
return {
|
|
1323
|
+
messages: messages.asReadonly(),
|
|
1324
|
+
isStreaming: isStreaming.asReadonly(),
|
|
1325
|
+
error: error.asReadonly(),
|
|
1326
|
+
send,
|
|
1327
|
+
clear,
|
|
1328
|
+
destroy,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Create a two-way bound prop helper.
|
|
1334
|
+
* Angular equivalent of React's `useBoundProp` hook.
|
|
1335
|
+
*
|
|
1336
|
+
* Must be called within an injection context (constructor, factory, etc.)
|
|
1337
|
+
* where SpecStateService is available.
|
|
1338
|
+
*
|
|
1339
|
+
* @example
|
|
1340
|
+
* ```ts
|
|
1341
|
+
* // In a component that receives bindings
|
|
1342
|
+
* private stateService = inject(SpecStateService);
|
|
1343
|
+
*
|
|
1344
|
+
* // Create bound prop for a form input
|
|
1345
|
+
* get emailBinding(): BoundProp<string> {
|
|
1346
|
+
* return boundProp(
|
|
1347
|
+
* this.element().props['value'] as string,
|
|
1348
|
+
* this.bindings()?.['value'],
|
|
1349
|
+
* this.stateService,
|
|
1350
|
+
* );
|
|
1351
|
+
* }
|
|
1352
|
+
*
|
|
1353
|
+
* onInput(event: Event) {
|
|
1354
|
+
* this.emailBinding.setValue((event.target as HTMLInputElement).value);
|
|
1355
|
+
* }
|
|
1356
|
+
* ```
|
|
1357
|
+
*/
|
|
1358
|
+
function boundProp(propValue, bindingPath, stateService) {
|
|
1359
|
+
return {
|
|
1360
|
+
value: propValue,
|
|
1361
|
+
setValue: (value) => {
|
|
1362
|
+
if (bindingPath) {
|
|
1363
|
+
stateService.set(bindingPath, value);
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Create a two-way bound prop helper using dependency injection.
|
|
1370
|
+
* Convenience wrapper that injects SpecStateService automatically.
|
|
1371
|
+
*
|
|
1372
|
+
* Must be called within an injection context.
|
|
1373
|
+
*
|
|
1374
|
+
* @example
|
|
1375
|
+
* ```ts
|
|
1376
|
+
* // In a component constructor or field initializer
|
|
1377
|
+
* private createBound = injectBoundProp();
|
|
1378
|
+
*
|
|
1379
|
+
* get emailBinding() {
|
|
1380
|
+
* return this.createBound(
|
|
1381
|
+
* this.element().props['value'] as string,
|
|
1382
|
+
* this.bindings()?.['value'],
|
|
1383
|
+
* );
|
|
1384
|
+
* }
|
|
1385
|
+
* ```
|
|
1386
|
+
*/
|
|
1387
|
+
function injectBoundProp() {
|
|
1388
|
+
const stateService = inject(SpecStateService);
|
|
1389
|
+
return (propValue, bindingPath) => boundProp(propValue, bindingPath, stateService);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Type guard that validates a data part payload looks like a valid SpecDataPart.
|
|
1394
|
+
*/
|
|
1395
|
+
function isSpecDataPart(data) {
|
|
1396
|
+
if (typeof data !== 'object' || data === null)
|
|
1397
|
+
return false;
|
|
1398
|
+
const obj = data;
|
|
1399
|
+
switch (obj['type']) {
|
|
1400
|
+
case 'patch':
|
|
1401
|
+
return typeof obj['patch'] === 'object' && obj['patch'] !== null;
|
|
1402
|
+
case 'flat':
|
|
1403
|
+
case 'nested':
|
|
1404
|
+
return typeof obj['spec'] === 'object' && obj['spec'] !== null;
|
|
1405
|
+
default:
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Build a Spec by replaying all spec data parts from a message's parts array.
|
|
1411
|
+
* Returns null if no spec data parts are present.
|
|
1412
|
+
*
|
|
1413
|
+
* Works with the AI SDK's UIMessage.parts array.
|
|
1414
|
+
*
|
|
1415
|
+
* @example
|
|
1416
|
+
* ```ts
|
|
1417
|
+
* const spec = buildSpecFromParts(message.parts);
|
|
1418
|
+
* if (spec) {
|
|
1419
|
+
* // render with <json-renderer [spec]="spec" />
|
|
1420
|
+
* }
|
|
1421
|
+
* ```
|
|
1422
|
+
*/
|
|
1423
|
+
function buildSpecFromParts(parts) {
|
|
1424
|
+
const spec = { root: '', elements: {} };
|
|
1425
|
+
let hasSpec = false;
|
|
1426
|
+
for (const part of parts) {
|
|
1427
|
+
if (part.type === SPEC_DATA_PART_TYPE) {
|
|
1428
|
+
if (!isSpecDataPart(part.data))
|
|
1429
|
+
continue;
|
|
1430
|
+
const payload = part.data;
|
|
1431
|
+
if (payload.type === 'patch') {
|
|
1432
|
+
hasSpec = true;
|
|
1433
|
+
applySpecPatch(spec, payload.patch);
|
|
1434
|
+
}
|
|
1435
|
+
else if (payload.type === 'flat') {
|
|
1436
|
+
hasSpec = true;
|
|
1437
|
+
Object.assign(spec, payload.spec);
|
|
1438
|
+
}
|
|
1439
|
+
else if (payload.type === 'nested') {
|
|
1440
|
+
hasSpec = true;
|
|
1441
|
+
const flat = nestedToFlat(payload.spec);
|
|
1442
|
+
Object.assign(spec, flat);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return hasSpec ? spec : null;
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Extract and join all text content from a message's parts array.
|
|
1450
|
+
*
|
|
1451
|
+
* @example
|
|
1452
|
+
* ```ts
|
|
1453
|
+
* const text = getTextFromParts(message.parts);
|
|
1454
|
+
* ```
|
|
1455
|
+
*/
|
|
1456
|
+
function getTextFromParts(parts) {
|
|
1457
|
+
return parts
|
|
1458
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
1459
|
+
.map((p) => p.text.trim())
|
|
1460
|
+
.filter(Boolean)
|
|
1461
|
+
.join('\n\n');
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Convert a flat element list to a Spec.
|
|
1465
|
+
* Input elements use key/parentKey to establish identity and relationships.
|
|
1466
|
+
*
|
|
1467
|
+
* @example
|
|
1468
|
+
* ```ts
|
|
1469
|
+
* const spec = flatToTree([
|
|
1470
|
+
* { key: 'root', type: 'Stack', props: {}, children: [] },
|
|
1471
|
+
* { key: 'text', parentKey: 'root', type: 'Text', props: { content: 'Hello' } },
|
|
1472
|
+
* ]);
|
|
1473
|
+
* ```
|
|
1474
|
+
*/
|
|
1475
|
+
function flatToTree(elements) {
|
|
1476
|
+
const elementMap = {};
|
|
1477
|
+
let root = '';
|
|
1478
|
+
for (const element of elements) {
|
|
1479
|
+
elementMap[element.key] = {
|
|
1480
|
+
type: element.type,
|
|
1481
|
+
props: element.props,
|
|
1482
|
+
children: [],
|
|
1483
|
+
visible: element.visible,
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
for (const element of elements) {
|
|
1487
|
+
if (element.parentKey) {
|
|
1488
|
+
const parent = elementMap[element.parentKey];
|
|
1489
|
+
if (parent) {
|
|
1490
|
+
if (!parent.children)
|
|
1491
|
+
parent.children = [];
|
|
1492
|
+
parent.children.push(element.key);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
root = element.key;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return { root, elements: elementMap };
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Extract both spec and text from message parts.
|
|
1503
|
+
* Combines buildSpecFromParts and getTextFromParts.
|
|
1504
|
+
*
|
|
1505
|
+
* @example
|
|
1506
|
+
* ```ts
|
|
1507
|
+
* const { spec, text, hasSpec } = extractFromParts(message.parts);
|
|
1508
|
+
* ```
|
|
1509
|
+
*/
|
|
1510
|
+
function extractFromParts(parts) {
|
|
1511
|
+
const spec = buildSpecFromParts(parts);
|
|
1512
|
+
const text = getTextFromParts(parts);
|
|
1513
|
+
const hasSpec = spec !== null && Object.keys(spec.elements || {}).length > 0;
|
|
1514
|
+
return { spec, text, hasSpec };
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/*
|
|
1518
|
+
* Public API Surface of @ng-render/angular
|
|
1519
|
+
*/
|
|
1520
|
+
// Schema
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Generated bundle index. Do not edit.
|
|
1524
|
+
*/
|
|
1525
|
+
|
|
1526
|
+
export { ActionDispatcherService, ChildrenOutletDirective, ConfirmDialogComponent, ElementRendererComponent, FallbackComponent, JsonRendererComponent, NG_RENDER_ACTION_HANDLERS, NG_RENDER_FALLBACK, NG_RENDER_NAVIGATE, NG_RENDER_REGISTRY, RepeatScopeService, SpecStateService, ValidationService, VisibilityService, applyPatch, boundProp, buildSpecFromParts, createChatUI, createUIStream, defineRegistry, extractFromParts, flatToTree, getTextFromParts, injectBoundProp, provideNgRender, schema };
|
|
1527
|
+
//# sourceMappingURL=ng-render-angular.mjs.map
|